SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
10.1 KB · 247 lines javascript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * scripts/build-marche-stats.mjs — fusion des shards d'évaluation Immo-Ka4 * (tmp/eval-shards/*.jsonl, produits par scripts/eval-immoka.ts) :5 *   1. écrit la table `vp_eval` dans data/immoka.db (le « merge » annonce ↔6 *      estimation Vrai-Prix, requêtable en SQL) ;7 *   2. calcule src/data/marche-stats.json — performance de la mesure face aux8 *      prix demandés du marché actif, servi par /stats/marche.9 * Données 100 % réelles — aucune valeur inventée. À relancer quand la copie10 * d'immoka.db ou le moteur change.11 */12import Database from "better-sqlite3";13import { readdirSync, readFileSync, writeFileSync } from "fs";14import path from "path";1516const IMMOKA_DB = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db");17const SHARD_DIR = path.join(process.cwd(), "tmp", "eval-shards");1819/* ---------------- 1. fusion des shards → table vp_eval ---------------- */20const files = readdirSync(SHARD_DIR).filter((f) => f.endsWith(".jsonl"));21if (!files.length) throw new Error(`aucun shard dans ${SHARD_DIR}`);22const rows = files.flatMap((f) =>23  readFileSync(path.join(SHARD_DIR, f), "utf8")24    .split("\n")25    .filter(Boolean)26    .map((l) => JSON.parse(l))27);28console.log(`${rows.length} évaluations lues (${files.length} shards)`);2930const db = new Database(IMMOKA_DB);31db.pragma("journal_mode = WAL");32db.exec(`33  DROP TABLE IF EXISTS vp_eval;34  CREATE TABLE vp_eval (35    uid TEXT PRIMARY KEY,          -- listings.uid (annonce Immo-Ka)36    unit_id TEXT,                  -- units.id_provinc (rôle MAMH)37    match_m REAL,                  -- distance du jumelage spatial lat/lng (m)38    price REAL,                    -- prix demandé au moment de la copie39    est REAL, low REAL, high REAL, -- estimation Vrai-Prix + P10-P9040    confidence_pct REAL, confidence TEXT,41    model_est REAL, comps_est REAL, model_weight REAL, n_comps INTEGER,42    cost_est REAL,                 -- méthode du coût calibrée (terrain + bâtiment déprécié)43    role_est REAL,                 -- méthode du rôle indexé (ratios de vente IAAO)44    ens_est REAL,                  -- ensemble : médiane (hybride, coût, rôle indexé)45    valeur_role REAL, type_prop TEXT, municipalite TEXT,46    ratio REAL,                    -- est / prix demandé47    diff REAL,                     -- prix demandé − est ($)48    evaluated_at TEXT49  );50`);51const ins = db.prepare(`52  INSERT OR REPLACE INTO vp_eval VALUES53  (@uid,@unit_id,@match_m,@price,@est,@low,@high,@cpct,@clevel,@model,@comps,@mw,@ncomps,54   @cost,@ridx,@ens,@role,@type,@muni,@ratio,@diff,@at)`);55const at = new Date().toISOString().slice(0, 10);56db.transaction(() => {57  for (const r of rows)58    ins.run({59      ...r,60      ratio: r.price > 0 && r.est > 0 ? r.est / r.price : null,61      diff: r.est > 0 ? r.price - r.est : null,62      at,63    });64})();65console.log("table vp_eval écrite dans", IMMOKA_DB);6667/* ---------------- 2. agrégats → src/data/marche-stats.json ---------------- */68const median = (xs) => {69  if (!xs.length) return null;70  const s = [...xs].sort((a, b) => a - b);71  const m = Math.floor(s.length / 2);72  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;73};74const r4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);7576// univers : annonces résidentielles plausibles (≥ 50 000 $, même seuil que les77// ventes retenues par le moteur) avec estimation positive78const val = db79  .prepare(80    `SELECT e.uid, e.price, e.est, e.low, e.high, e.confidence AS lvl, e.n_comps,81            e.match_m, e.model_est, e.comps_est, e.cost_est, e.role_est, e.ens_est,82            e.valeur_role, e.type_prop, l.region, l.city,83            l.property_type, l.bedrooms84     FROM vp_eval e JOIN listings l ON l.uid = e.uid85     WHERE e.price >= 50000 AND e.est > 0`86  )87  .all();8889const totalListings = db90  .prepare(91    `SELECT COUNT(*) AS n FROM listings92     WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>0`93  )94  .get().n;95const geolocated = db96  .prepare(97    `SELECT COUNT(*) AS n FROM listings98     WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>099       AND lat IS NOT NULL AND lng IS NOT NULL`100  )101  .get().n;102103const stats = (subset) => {104  const ratios = subset.map((r) => r.est / r.price);105  const apes = subset.map((r) => Math.abs(r.est - r.price) / r.price);106  const diffs = subset.map((r) => r.price - r.est).sort((a, b) => a - b);107  const q = (p) => (diffs.length ? diffs[Math.min(diffs.length - 1, Math.floor(p * diffs.length))] : null);108  const inRange = subset.filter((r) => r.price >= r.low && r.price <= r.high).length;109  return {110    n: subset.length,111    medianRatio: r4(median(ratios)),112    mdape: r4(median(apes)),113    within10: r4(apes.filter((a) => a <= 0.1).length / (apes.length || 1)),114    within20: r4(apes.filter((a) => a <= 0.2).length / (apes.length || 1)),115    askAbove: r4(subset.filter((r) => r.price > r.est).length / (subset.length || 1)),116    inP10P90: r4(inRange / (subset.length || 1)),117    medianAsk: median(subset.map((r) => r.price)),118    medianEst: median(subset.map((r) => r.est)),119    // écart prix demandé − estimation : la « prime d'affichage » mesurée120    medianDiff: median(diffs) == null ? null : Math.round(median(diffs)),121    medianDiffPct: r4(median(subset.map((r) => (r.price - r.est) / r.est))),122    p25Diff: q(0.25) == null ? null : Math.round(q(0.25)),123    p75Diff: q(0.75) == null ? null : Math.round(q(0.75)),124  };125};126127// clés géographiques : les sources d'Immo-Ka mélangent accents et graphies128// (« Montreal » / « Montréal ») et laissent parfois la région vide → on groupe129// sur une clé pliée (minuscules sans accents) et on affiche la graphie la plus130// fréquente ; les vides sont exclus.131const fold = (s) =>132  s133    .normalize("NFD")134    .replace(/[\u0300-\u036f]/g, "")135    .toLowerCase()136    .trim();137const groupBy = (key, { normalize = false } = {}) => {138  const m = new Map();139  for (const r of val) {140    const raw = (r[key] ?? "").toString().trim();141    if (normalize && !raw) continue;142    const k = normalize ? fold(raw) : raw || "?";143    if (!m.has(k)) m.set(k, { rows: [], labels: new Map() });144    const g = m.get(k);145    g.rows.push(r);146    g.labels.set(raw, (g.labels.get(raw) ?? 0) + 1);147  }148  return [...m.values()]149    .filter((g) => g.rows.length >= 30)150    .sort((a, b) => b.rows.length - a.rows.length)151    .map((g) => ({152      key: [...g.labels.entries()].sort((a, b) => b[1] - a[1])[0][0],153      ...stats(g.rows),154    }));155};156157// histogramme du ratio estimation / prix demandé (pas de 5 %, queues groupées)158const hist = [];159for (let lo = 0.5; lo < 1.5 - 1e-9; lo += 0.05) hist.push({ lo: r4(lo), hi: r4(lo + 0.05), n: 0 });160let below = 0,161  above = 0;162for (const r of val) {163  const q = r.est / r.price;164  if (q < 0.5) below++;165  else if (q >= 1.5) above++;166  else hist[Math.min(hist.length - 1, Math.floor((q - 0.5) / 0.05))].n++;167}168169// tranches de prix demandé170const brackets = [171  [0, 300000, "<300k"],172  [300000, 500000, "300-500k"],173  [500000, 750000, "500-750k"],174  [750000, 1000000, "750k-1M"],175  [1000000, Infinity, ">1M"],176].map(([lo, hi, label]) => ({177  key: label,178  ...stats(val.filter((r) => r.price >= lo && r.price < hi)),179}));180181// nuage prix demandé × estimation (échantillon stable pour le graphique)182const step = Math.max(1, Math.floor(val.length / 1500));183const scatter = val184  .filter((_, i) => i % step === 0)185  .map((r) => ({ p: Math.round(r.price), e: Math.round(r.est), l: r.lvl }));186187// duel des méthodes — la mesure principale reste l'hybride 65/35 ; les autres188// (hédonique pur, comparables purs, coût calibré, rôle indexé, ensemble) sont189// évaluées sur le même univers, chacune là où elle est disponible190const methodStats = (field) => {191  const sub = val.filter((r) => r[field] != null && r[field] > 0);192  const ratios = sub.map((r) => r[field] / r.price);193  const apes = sub.map((r) => Math.abs(r[field] - r.price) / r.price);194  return {195    n: sub.length,196    coverage: r4(sub.length / (val.length || 1)),197    medianRatio: r4(median(ratios)),198    mdape: r4(median(apes)),199    within10: r4(apes.filter((a) => a <= 0.1).length / (apes.length || 1)),200    within20: r4(apes.filter((a) => a <= 0.2).length / (apes.length || 1)),201    medianDiff: sub.length ? Math.round(median(sub.map((r) => r.price - r[field]))) : null,202  };203};204const methods = [205  { key: "hybride", field: "est", main: true },206  { key: "hedonique", field: "model_est", main: false },207  { key: "comparables", field: "comps_est", main: false },208  { key: "cout", field: "cost_est", main: false },209  { key: "role_indexe", field: "role_est", main: false },210  { key: "ensemble", field: "ens_est", main: false },211].map((m) => ({ key: m.key, main: m.main, ...methodStats(m.field) }));212213const out = {214  generated: new Date().toISOString(),215  source: {216    app: "Immo-Ka",217    copiedAt: at,218    totalListings,219    geolocated,220    evaluated: rows.length,221    valid: val.length,222    matchMedianM: r4(median(val.map((r) => r.match_m).filter((v) => v != null))),223  },224  global: stats(val),225  methods,226  byConfidence: ["A", "B", "C", "D"].map((lvl) => ({227    key: lvl,228    ...stats(val.filter((r) => r.lvl === lvl)),229  })),230  // type côté annonce (Maison/Condo/…) : plus parlant que le type du rôle —231  // beaucoup de condos sont classés « unifamilial » dans le rôle MAMH.232  byType: groupBy("property_type", { normalize: true }).slice(0, 10),233  byRegion: groupBy("region", { normalize: true })234    .filter((g) => !/^\d+$/.test(g.key)) // certaines sources donnent le code de région, pas le nom235    .slice(0, 18),236  byCity: groupBy("city", { normalize: true }).slice(0, 15),237  byBracket: brackets,238  histogram: { below, above, bins: hist },239  scatter,240};241// Destination : src/data/marche-stats.json (instantané versionné, secours du site)242// ou MARCHE_STATS_OUT (ex. data/marche-stats.json, lu à chaud par src/lib/marche-stats.ts243// lors du rafraîchissement nocturne — évite tout rebuild et toute dérive git).244const dest = process.env.MARCHE_STATS_OUT ?? path.join(process.cwd(), "src", "data", "marche-stats.json");245writeFileSync(dest, JSON.stringify(out));246console.log(`écrit ${dest} — global :`, out.global);247