// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * scripts/build-marche-stats.mjs — fusion des shards d'évaluation Immo-Ka * (tmp/eval-shards/*.jsonl, produits par scripts/eval-immoka.ts) : * 1. écrit la table `vp_eval` dans data/immoka.db (le « merge » annonce ↔ * estimation Vrai-Prix, requêtable en SQL) ; * 2. calcule src/data/marche-stats.json — performance de la mesure face aux * prix demandés du marché actif, servi par /stats/marche. * Données 100 % réelles — aucune valeur inventée. À relancer quand la copie * d'immoka.db ou le moteur change. */ import Database from "better-sqlite3"; import { readdirSync, readFileSync, writeFileSync } from "fs"; import path from "path"; const IMMOKA_DB = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db"); const SHARD_DIR = path.join(process.cwd(), "tmp", "eval-shards"); /* ---------------- 1. fusion des shards → table vp_eval ---------------- */ const files = readdirSync(SHARD_DIR).filter((f) => f.endsWith(".jsonl")); if (!files.length) throw new Error(`aucun shard dans ${SHARD_DIR}`); const rows = files.flatMap((f) => readFileSync(path.join(SHARD_DIR, f), "utf8") .split("\n") .filter(Boolean) .map((l) => JSON.parse(l)) ); console.log(`${rows.length} évaluations lues (${files.length} shards)`); const db = new Database(IMMOKA_DB); db.pragma("journal_mode = WAL"); db.exec(` DROP TABLE IF EXISTS vp_eval; CREATE TABLE vp_eval ( uid TEXT PRIMARY KEY, -- listings.uid (annonce Immo-Ka) unit_id TEXT, -- units.id_provinc (rôle MAMH) match_m REAL, -- distance du jumelage spatial lat/lng (m) price REAL, -- prix demandé au moment de la copie est REAL, low REAL, high REAL, -- estimation Vrai-Prix + P10-P90 confidence_pct REAL, confidence TEXT, model_est REAL, comps_est REAL, model_weight REAL, n_comps INTEGER, cost_est REAL, -- méthode du coût calibrée (terrain + bâtiment déprécié) role_est REAL, -- méthode du rôle indexé (ratios de vente IAAO) ens_est REAL, -- ensemble : médiane (hybride, coût, rôle indexé) valeur_role REAL, type_prop TEXT, municipalite TEXT, ratio REAL, -- est / prix demandé diff REAL, -- prix demandé − est ($) evaluated_at TEXT ); `); const ins = db.prepare(` INSERT OR REPLACE INTO vp_eval VALUES (@uid,@unit_id,@match_m,@price,@est,@low,@high,@cpct,@clevel,@model,@comps,@mw,@ncomps, @cost,@ridx,@ens,@role,@type,@muni,@ratio,@diff,@at)`); const at = new Date().toISOString().slice(0, 10); db.transaction(() => { for (const r of rows) ins.run({ ...r, ratio: r.price > 0 && r.est > 0 ? r.est / r.price : null, diff: r.est > 0 ? r.price - r.est : null, at, }); })(); console.log("table vp_eval écrite dans", IMMOKA_DB); /* ---------------- 2. agrégats → src/data/marche-stats.json ---------------- */ const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; const r4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000); // univers : annonces résidentielles plausibles (≥ 50 000 $, même seuil que les // ventes retenues par le moteur) avec estimation positive const val = db .prepare( `SELECT e.uid, e.price, e.est, e.low, e.high, e.confidence AS lvl, e.n_comps, e.match_m, e.model_est, e.comps_est, e.cost_est, e.role_est, e.ens_est, e.valeur_role, e.type_prop, l.region, l.city, l.property_type, l.bedrooms FROM vp_eval e JOIN listings l ON l.uid = e.uid WHERE e.price >= 50000 AND e.est > 0` ) .all(); const totalListings = db .prepare( `SELECT COUNT(*) AS n FROM listings WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>0` ) .get().n; const geolocated = db .prepare( `SELECT COUNT(*) AS n FROM listings WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>0 AND lat IS NOT NULL AND lng IS NOT NULL` ) .get().n; const stats = (subset) => { const ratios = subset.map((r) => r.est / r.price); const apes = subset.map((r) => Math.abs(r.est - r.price) / r.price); const diffs = subset.map((r) => r.price - r.est).sort((a, b) => a - b); const q = (p) => (diffs.length ? diffs[Math.min(diffs.length - 1, Math.floor(p * diffs.length))] : null); const inRange = subset.filter((r) => r.price >= r.low && r.price <= r.high).length; return { n: subset.length, medianRatio: r4(median(ratios)), mdape: r4(median(apes)), within10: r4(apes.filter((a) => a <= 0.1).length / (apes.length || 1)), within20: r4(apes.filter((a) => a <= 0.2).length / (apes.length || 1)), askAbove: r4(subset.filter((r) => r.price > r.est).length / (subset.length || 1)), inP10P90: r4(inRange / (subset.length || 1)), medianAsk: median(subset.map((r) => r.price)), medianEst: median(subset.map((r) => r.est)), // écart prix demandé − estimation : la « prime d'affichage » mesurée medianDiff: median(diffs) == null ? null : Math.round(median(diffs)), medianDiffPct: r4(median(subset.map((r) => (r.price - r.est) / r.est))), p25Diff: q(0.25) == null ? null : Math.round(q(0.25)), p75Diff: q(0.75) == null ? null : Math.round(q(0.75)), }; }; // clés géographiques : les sources d'Immo-Ka mélangent accents et graphies // (« Montreal » / « Montréal ») et laissent parfois la région vide → on groupe // sur une clé pliée (minuscules sans accents) et on affiche la graphie la plus // fréquente ; les vides sont exclus. const fold = (s) => s .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .trim(); const groupBy = (key, { normalize = false } = {}) => { const m = new Map(); for (const r of val) { const raw = (r[key] ?? "").toString().trim(); if (normalize && !raw) continue; const k = normalize ? fold(raw) : raw || "?"; if (!m.has(k)) m.set(k, { rows: [], labels: new Map() }); const g = m.get(k); g.rows.push(r); g.labels.set(raw, (g.labels.get(raw) ?? 0) + 1); } return [...m.values()] .filter((g) => g.rows.length >= 30) .sort((a, b) => b.rows.length - a.rows.length) .map((g) => ({ key: [...g.labels.entries()].sort((a, b) => b[1] - a[1])[0][0], ...stats(g.rows), })); }; // histogramme du ratio estimation / prix demandé (pas de 5 %, queues groupées) const hist = []; for (let lo = 0.5; lo < 1.5 - 1e-9; lo += 0.05) hist.push({ lo: r4(lo), hi: r4(lo + 0.05), n: 0 }); let below = 0, above = 0; for (const r of val) { const q = r.est / r.price; if (q < 0.5) below++; else if (q >= 1.5) above++; else hist[Math.min(hist.length - 1, Math.floor((q - 0.5) / 0.05))].n++; } // tranches de prix demandé const brackets = [ [0, 300000, "<300k"], [300000, 500000, "300-500k"], [500000, 750000, "500-750k"], [750000, 1000000, "750k-1M"], [1000000, Infinity, ">1M"], ].map(([lo, hi, label]) => ({ key: label, ...stats(val.filter((r) => r.price >= lo && r.price < hi)), })); // nuage prix demandé × estimation (échantillon stable pour le graphique) const step = Math.max(1, Math.floor(val.length / 1500)); const scatter = val .filter((_, i) => i % step === 0) .map((r) => ({ p: Math.round(r.price), e: Math.round(r.est), l: r.lvl })); // duel des méthodes — la mesure principale reste l'hybride 65/35 ; les autres // (hédonique pur, comparables purs, coût calibré, rôle indexé, ensemble) sont // évaluées sur le même univers, chacune là où elle est disponible const methodStats = (field) => { const sub = val.filter((r) => r[field] != null && r[field] > 0); const ratios = sub.map((r) => r[field] / r.price); const apes = sub.map((r) => Math.abs(r[field] - r.price) / r.price); return { n: sub.length, coverage: r4(sub.length / (val.length || 1)), medianRatio: r4(median(ratios)), mdape: r4(median(apes)), within10: r4(apes.filter((a) => a <= 0.1).length / (apes.length || 1)), within20: r4(apes.filter((a) => a <= 0.2).length / (apes.length || 1)), medianDiff: sub.length ? Math.round(median(sub.map((r) => r.price - r[field]))) : null, }; }; const methods = [ { key: "hybride", field: "est", main: true }, { key: "hedonique", field: "model_est", main: false }, { key: "comparables", field: "comps_est", main: false }, { key: "cout", field: "cost_est", main: false }, { key: "role_indexe", field: "role_est", main: false }, { key: "ensemble", field: "ens_est", main: false }, ].map((m) => ({ key: m.key, main: m.main, ...methodStats(m.field) })); const out = { generated: new Date().toISOString(), source: { app: "Immo-Ka", copiedAt: at, totalListings, geolocated, evaluated: rows.length, valid: val.length, matchMedianM: r4(median(val.map((r) => r.match_m).filter((v) => v != null))), }, global: stats(val), methods, byConfidence: ["A", "B", "C", "D"].map((lvl) => ({ key: lvl, ...stats(val.filter((r) => r.lvl === lvl)), })), // type côté annonce (Maison/Condo/…) : plus parlant que le type du rôle — // beaucoup de condos sont classés « unifamilial » dans le rôle MAMH. byType: groupBy("property_type", { normalize: true }).slice(0, 10), byRegion: groupBy("region", { normalize: true }) .filter((g) => !/^\d+$/.test(g.key)) // certaines sources donnent le code de région, pas le nom .slice(0, 18), byCity: groupBy("city", { normalize: true }).slice(0, 15), byBracket: brackets, histogram: { below, above, bins: hist }, scatter, }; // Destination : src/data/marche-stats.json (instantané versionné, secours du site) // ou MARCHE_STATS_OUT (ex. data/marche-stats.json, lu à chaud par src/lib/marche-stats.ts // lors du rafraîchissement nocturne — évite tout rebuild et toute dérive git). const dest = process.env.MARCHE_STATS_OUT ?? path.join(process.cwd(), "src", "data", "marche-stats.json"); writeFileSync(dest, JSON.stringify(out)); console.log(`écrit ${dest} — global :`, out.global);