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%
13.0 KB · 343 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * scripts/eval-immoka.ts — évalue en lot les annonces actives d'Immo-Ka avec4 * le moteur Vrai-Prix (même chemin que estimateByUnitId, sans le contexte5 * marché, inutile pour les agrégats).6 *7 * Jumelage 100 % spatial : chaque annonce (lat/lng géocodés par Immo-Ka) est8 * appariée à l'unité du rôle MAMH la plus proche (bbox sur idx_units_geo,9 * haversine ≤ 75 m puis repli ≤ 250 m), départagée par compatibilité de type,10 * superficie habitable et année de construction — indispensable pour les11 * condos/plex où plusieurs unités partagent les mêmes coordonnées.12 *13 * Sharding par processus (14 cœurs M4M36) :14 *   SHARDS=6 SHARD=0..5 npx tsx scripts/eval-immoka.ts15 * Chaque shard écrit tmp/eval-shards/shard-N.jsonl ; la fusion + les agrégats16 * sont faits par scripts/build-marche-stats.mjs.17 *18 * Env : IMMOKA_DB (défaut data/immoka.db), VRAIPRIX_DB (défaut data/vraiprix.db),19 *       LIMIT (test), SHARD/SHARDS.20 */21import Database from "better-sqlite3";22import fs from "fs";23import path from "path";24import { getCandidates, getDb, getMarketIndex, type TxRow, type UnitRow } from "../src/lib/db";25import { estimate, haversineM, type CompInput, type Subject } from "../src/lib/engine";26import { indexType } from "../src/lib/estimator";2728const SHARD = Number(process.env.SHARD ?? 0);29const SHARDS = Number(process.env.SHARDS ?? 1);30const LIMIT = Number(process.env.LIMIT ?? 0);31const IMMOKA_DB = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db");32const OUT_DIR = path.join(process.cwd(), "tmp", "eval-shards");3334// --- répliques des helpers privés d'estimator.ts (même logique exactement) ---35function toComps(rows: TxRow[]): CompInput[] {36  return rows.map((r) => ({37    id: r.id,38    date: r.date,39    amount: r.amount,40    lat: r.lat,41    lng: r.lng,42    propertyType: r.property_type,43    yearBuilt: r.year_built,44    floorArea: r.floor_area,45    street: r.street,46    city: r.city,47  }));48}49function since(months: number): string {50  const d = new Date();51  d.setMonth(d.getMonth() - months);52  return d.toISOString().slice(0, 10);53}54function candidatesAround(lat: number, lng: number): TxRow[] {55  for (const halfDeg of [0.02, 0.1]) {56    const rows = getCandidates(lat, lng, halfDeg, since(30), 600);57    if (rows.length >= 30) return rows;58  }59  return getCandidates(lat, lng, 0.25, since(36), 600);60}61const nowISO = new Date().toISOString().slice(0, 10);6263// L'indice de marché ne dépend que du type (3 valeurs) — cache évident en lot.64const idxCache = new Map<string, { month: string; idx: number }[]>();65function idxFor(typeProp: string) {66  const k = indexType(typeProp);67  let v = idxCache.get(k);68  if (!v) {69    v = getMarketIndex(k);70    idxCache.set(k, v);71  }72  return v;73}7475/* ------------------------- jumelage spatial lat/lng ------------------------- */76// types d'unités plausibles selon le type d'annonce Immo-Ka (clé pliée)77function typeBucket(pt: string | null): Set<string> | null {78  const t = (pt ?? "")79    .normalize("NFD")80    .replace(/[\u0300-\u036f]/g, "")81    .toLowerCase();82  if (!t) return null; // inconnu → aucune contrainte83  if (t.includes("condo")) return new Set(["condo_ou_multi", "unifamilial", "plex"]);84  if (/(du|tri|multi|quadru)plex/.test(t) || t.includes("plex")) return new Set(["plex", "condo_ou_multi"]);85  if (t.includes("terrain") || t.includes("lot")) return new Set(["terrain"]);86  if (t.includes("commerc") || t.includes("industri") || t.includes("bureau")) return new Set(["autre"]);87  if (t.includes("chalet")) return new Set(["chalet", "unifamilial"]);88  if (t.includes("ferme")) return new Set(["unifamilial", "autre", "terrain"]);89  if (t.includes("mobile")) return new Set(["maison_mobile", "unifamilial"]);90  // maison, jumelé, détachée, maison de ville, propriétés résidentielles…91  return new Set(["unifamilial", "chalet", "maison_mobile"]);92}9394const unitsInBox = getDb().prepare(95  `SELECT * FROM units96   WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`97);9899interface ListingRow {100  uid: string;101  price: number;102  lat: number;103  lng: number;104  property_type: string | null;105  area_sqft: number | null;106  year_built: number | null;107}108109/** Unité la plus plausible pour l'annonce : distance + type + superficie + année. */110function matchUnit(l: ListingRow): { u: UnitRow; dist: number } | null {111  const bucket = typeBucket(l.property_type);112  const areaM2 = l.area_sqft && l.area_sqft > 100 ? l.area_sqft * 0.092903 : null;113  for (const [halfDeg, capM] of [114    [0.001, 75],115    [0.003, 250],116  ] as const) {117    const cands = unitsInBox.all(118      l.lat - halfDeg,119      l.lat + halfDeg,120      l.lng - halfDeg,121      l.lng + halfDeg122    ) as UnitRow[];123    let best: { u: UnitRow; dist: number; score: number } | null = null;124    for (const u of cands) {125      if (u.lat == null || u.lng == null) continue;126      const dist = haversineM(l.lat, l.lng, u.lat, u.lng);127      if (dist > capM) continue;128      let score = dist;129      if (bucket && !bucket.has(u.type_prop)) score += 120;130      if (areaM2 && u.aire_etages_m2 && u.aire_etages_m2 > 10)131        score += Math.min(1.5, Math.abs(areaM2 - u.aire_etages_m2) / u.aire_etages_m2) * 150;132      if (l.year_built && u.annee_construction && u.annee_construction > 1500)133        score += Math.min(50, Math.abs(l.year_built - u.annee_construction)) * 1.5;134      if (!best || score < best.score) best = { u, dist, score };135    }136    if (best) return { u: best.u, dist: best.dist };137  }138  return null;139}140141/* --------------- méthodes additionnelles : coût & rôle indexé --------------- */142/**143 * Méthode du coût, calibrée sur le marché par la technique du144 * résiduel : terrain au marché = valeur_terrain(rôle) × ratio de vente médian145 * du type ; bâtiment = aire d'étages × coût unitaire net médian $/m², lu dans146 * les ventes des 18 derniers mois (résiduel = prix ajusté au marché − terrain)147 * par type × tranche d'âge — la dépréciation est donc observée, pas imposée.148 * Méthode du rôle indexé (étude de ratios IAAO) : valeur = valeur_role ×149 * ratio médian vente/rôle de la municipalité (repli : province), par type.150 */151const AGE_BANDS = [5, 15, 30, 50, 75, Infinity];152const ageBand = (age: number | null): string => {153  if (age == null || age < 0) return "?";154  return String(AGE_BANDS.find((b) => age <= b));155};156157interface CalibTx {158  amount: number;159  date: string;160  type_prop: string;161  municipalite: string | null;162  valeur_terrain: number | null;163  aire_etages_m2: number | null;164  annee_construction: number | null;165  valeur_role: number | null;166}167const nowYear = new Date().getFullYear();168const idxByMonth = (bucket: string) => {169  const m = new Map<string, number>();170  for (const p of getMarketIndex(bucket)) m.set(p.month, p.idx);171  return m;172};173const calib = (() => {174  const txs = getDb()175    .prepare(176      `SELECT t.amount, t.date, u.type_prop, u.municipalite, u.valeur_terrain,177              u.aire_etages_m2, u.annee_construction, u.valeur_role178       FROM transactions t JOIN units u ON u.id_provinc = t.id_provinc179       WHERE t.date >= ? AND t.amount BETWEEN 50000 AND 5000000`180    )181    .all(since(18)) as CalibTx[];182  const idx: Record<string, Map<string, number>> = {183    unifamilial: idxByMonth("unifamilial"),184    plex: idxByMonth("plex"),185    condo: idxByMonth("condo"),186  };187  const adj = (t: CalibTx) => {188    const i = idx[indexType(t.type_prop)]?.get(t.date.slice(0, 7));189    return i && i > 0.3 ? t.amount / i : t.amount;190  };191  const med = (xs: number[]) => {192    const s = [...xs].sort((x, y) => x - y);193    const m = Math.floor(s.length / 2);194    return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;195  };196  // --- rôle indexé : ratio médian vente/rôle par municipalité×type (n≥12)197  const groups = new Map<string, number[]>();198  for (const t of txs) {199    if (!t.valeur_role || t.valeur_role < 10000) continue;200    const r = adj(t) / t.valeur_role;201    if (r < 0.3 || r > 4) continue;202    const bucket = indexType(t.type_prop);203    for (const k of [`${t.municipalite ?? "?"}|${bucket}`, `*|${bucket}`]) {204      if (!groups.has(k)) groups.set(k, []);205      groups.get(k)!.push(r);206    }207  }208  const roleRatio = new Map<string, number>();209  for (const [k, rs] of groups) if (rs.length >= 12) roleRatio.set(k, med(rs));210211  // --- coût : terrain × r* + résiduel médian $/m² par type × tranche d'âge212  const landFactor = new Map<string, number>();213  for (const bucket of ["unifamilial", "plex", "condo"]) {214    const r = roleRatio.get(`*|${bucket}`);215    if (r) landFactor.set(bucket, r);216  }217  const resid = new Map<string, number[]>();218  for (const t of txs) {219    if (!t.valeur_terrain || t.valeur_terrain <= 1000) continue;220    if (!t.aire_etages_m2 || t.aire_etages_m2 <= 20) continue;221    const bucket = indexType(t.type_prop);222    const lf = landFactor.get(bucket);223    if (!lf) continue;224    const rpm2 = (adj(t) - lf * t.valeur_terrain) / t.aire_etages_m2;225    if (rpm2 < -1000 || rpm2 > 10000) continue;226    const band = ageBand(t.annee_construction && t.annee_construction > 1500 ? nowYear - t.annee_construction : null);227    for (const k of [`${bucket}|${band}`, `${bucket}|*`]) {228      if (!resid.has(k)) resid.set(k, []);229      resid.get(k)!.push(rpm2);230    }231  }232  const costUnit = new Map<string, number>();233  for (const [k, rs] of resid) if (rs.length >= 150) costUnit.set(k, med(rs));234  console.log(235    `[shard ${SHARD}] calibration : ${txs.length} ventes 18 mois · coût ${[...costUnit.entries()]236      .filter(([k]) => k.endsWith("|*"))237      .map(([k, v]) => `${k.split("|")[0]} ${v.toFixed(0)}$/m²`)238      .join(" · ")} (${costUnit.size} cellules) · ${roleRatio.size} ratios rôle`239  );240  return { costUnit, landFactor, roleRatio };241})();242243function costEstimate(u: UnitRow): number | null {244  const bucket = indexType(u.type_prop);245  const lf = calib.landFactor.get(bucket);246  if (!lf || !u.valeur_terrain || u.valeur_terrain <= 1000) return null;247  if (!u.aire_etages_m2 || u.aire_etages_m2 <= 20) return null;248  const age = u.annee_construction && u.annee_construction > 1500 ? nowYear - u.annee_construction : null;249  const cpm2 = calib.costUnit.get(`${bucket}|${ageBand(age)}`) ?? calib.costUnit.get(`${bucket}|*`);250  if (cpm2 == null) return null;251  const v = lf * u.valeur_terrain + cpm2 * u.aire_etages_m2;252  return v > 0 ? Math.round(v / 100) * 100 : null;253}254function roleEstimate(u: UnitRow): number | null {255  if (!u.valeur_role || u.valeur_role < 10000) return null;256  const bucket = indexType(u.type_prop);257  const r = calib.roleRatio.get(`${u.municipalite ?? "?"}|${bucket}`) ?? calib.roleRatio.get(`*|${bucket}`);258  return r ? Math.round((u.valeur_role * r) / 100) * 100 : null;259}260const medianOf = (xs: number[]) => {261  const s = [...xs].sort((a, b) => a - b);262  const m = Math.floor(s.length / 2);263  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;264};265266/* ---------------------------------- lot ---------------------------------- */267const immo = new Database(IMMOKA_DB, { readonly: true, fileMustExist: true });268const rows = immo269  .prepare(270    `SELECT uid, price, lat, lng, property_type, area_sqft, year_built FROM listings271     WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0272       AND price > 0 AND lat IS NOT NULL AND lng IS NOT NULL273       AND (rowid % ?) = ?274     ${LIMIT ? "LIMIT " + LIMIT : ""}`275  )276  .all(SHARDS, SHARD) as ListingRow[];277278fs.mkdirSync(OUT_DIR, { recursive: true });279const outPath = path.join(OUT_DIR, `shard-${SHARD}.jsonl`);280const out = fs.createWriteStream(outPath);281282let done = 0;283let noUnit = 0;284const t0 = Date.now();285for (const r of rows) {286  const m = matchUnit(r);287  if (!m) {288    noUnit++;289    continue;290  }291  const u = m.u;292  const subject: Subject = {293    lat: u.lat,294    lng: u.lng,295    typeProp: u.type_prop,296    floorArea: u.aire_etages_m2,297    yearBuilt: u.annee_construction,298    landArea: u.superficie_terrain_m2,299    modelEstimate: u.est_2026,300    modelP10: u.p10,301    modelP90: u.p90,302  };303  const res = estimate(subject, toComps(candidatesAround(u.lat, u.lng)), idxFor(u.type_prop), nowISO);304  // méthodes complémentaires (la mesure principale reste l'hybride 65/35)305  const cost = costEstimate(u);306  const ridx = roleEstimate(u);307  const parts = [res.estimate, cost, ridx].filter((v): v is number => v != null && v > 0);308  const ens = parts.length ? Math.round(medianOf(parts)) : null;309  out.write(310    JSON.stringify({311      uid: r.uid,312      unit_id: u.id_provinc,313      match_m: Math.round(m.dist * 10) / 10,314      price: r.price,315      est: res.estimate,316      low: res.low,317      high: res.high,318      cpct: res.confidencePct,319      clevel: res.confidenceLevel,320      model: res.modelEstimate,321      comps: res.compsEstimate,322      mw: res.modelWeight,323      ncomps: res.nCompsUsed,324      cost,325      ridx,326      ens,327      role: u.valeur_role,328      type: u.type_prop,329      muni: u.municipalite,330    }) + "\n"331  );332  done++;333  if (done % 2000 === 0) {334    const rate = done / ((Date.now() - t0) / 1000);335    console.log(`[shard ${SHARD}] ${done}/${rows.length} (${rate.toFixed(0)}/s)`);336  }337}338out.end();339console.log(340  `[shard ${SHARD}] terminé : ${done} évaluées, ${noUnit} sans unité à ≤250 m, ` +341    `${((Date.now() - t0) / 1000).toFixed(0)} s → ${outPath}`342);343