Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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