// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * scripts/eval-immoka.ts — évalue en lot les annonces actives d'Immo-Ka avec * le moteur Vrai-Prix (même chemin que estimateByUnitId, sans le contexte * marché, inutile pour les agrégats). * * Jumelage 100 % spatial : chaque annonce (lat/lng géocodés par Immo-Ka) est * appariée à l'unité du rôle MAMH la plus proche (bbox sur idx_units_geo, * haversine ≤ 75 m puis repli ≤ 250 m), départagée par compatibilité de type, * superficie habitable et année de construction — indispensable pour les * condos/plex où plusieurs unités partagent les mêmes coordonnées. * * Sharding par processus (14 cœurs M4M36) : * SHARDS=6 SHARD=0..5 npx tsx scripts/eval-immoka.ts * Chaque shard écrit tmp/eval-shards/shard-N.jsonl ; la fusion + les agrégats * sont faits par scripts/build-marche-stats.mjs. * * Env : IMMOKA_DB (défaut data/immoka.db), VRAIPRIX_DB (défaut data/vraiprix.db), * LIMIT (test), SHARD/SHARDS. */ import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; import { getCandidates, getDb, getMarketIndex, type TxRow, type UnitRow } from "../src/lib/db"; import { estimate, haversineM, type CompInput, type Subject } from "../src/lib/engine"; import { indexType } from "../src/lib/estimator"; const SHARD = Number(process.env.SHARD ?? 0); const SHARDS = Number(process.env.SHARDS ?? 1); const LIMIT = Number(process.env.LIMIT ?? 0); const IMMOKA_DB = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db"); const OUT_DIR = path.join(process.cwd(), "tmp", "eval-shards"); // --- répliques des helpers privés d'estimator.ts (même logique exactement) --- function toComps(rows: TxRow[]): CompInput[] { return rows.map((r) => ({ id: r.id, date: r.date, amount: r.amount, lat: r.lat, lng: r.lng, propertyType: r.property_type, yearBuilt: r.year_built, floorArea: r.floor_area, street: r.street, city: r.city, })); } function since(months: number): string { const d = new Date(); d.setMonth(d.getMonth() - months); return d.toISOString().slice(0, 10); } function candidatesAround(lat: number, lng: number): TxRow[] { for (const halfDeg of [0.02, 0.1]) { const rows = getCandidates(lat, lng, halfDeg, since(30), 600); if (rows.length >= 30) return rows; } return getCandidates(lat, lng, 0.25, since(36), 600); } const nowISO = new Date().toISOString().slice(0, 10); // L'indice de marché ne dépend que du type (3 valeurs) — cache évident en lot. const idxCache = new Map(); function idxFor(typeProp: string) { const k = indexType(typeProp); let v = idxCache.get(k); if (!v) { v = getMarketIndex(k); idxCache.set(k, v); } return v; } /* ------------------------- jumelage spatial lat/lng ------------------------- */ // types d'unités plausibles selon le type d'annonce Immo-Ka (clé pliée) function typeBucket(pt: string | null): Set | null { const t = (pt ?? "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase(); if (!t) return null; // inconnu → aucune contrainte if (t.includes("condo")) return new Set(["condo_ou_multi", "unifamilial", "plex"]); if (/(du|tri|multi|quadru)plex/.test(t) || t.includes("plex")) return new Set(["plex", "condo_ou_multi"]); if (t.includes("terrain") || t.includes("lot")) return new Set(["terrain"]); if (t.includes("commerc") || t.includes("industri") || t.includes("bureau")) return new Set(["autre"]); if (t.includes("chalet")) return new Set(["chalet", "unifamilial"]); if (t.includes("ferme")) return new Set(["unifamilial", "autre", "terrain"]); if (t.includes("mobile")) return new Set(["maison_mobile", "unifamilial"]); // maison, jumelé, détachée, maison de ville, propriétés résidentielles… return new Set(["unifamilial", "chalet", "maison_mobile"]); } const unitsInBox = getDb().prepare( `SELECT * FROM units WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?` ); interface ListingRow { uid: string; price: number; lat: number; lng: number; property_type: string | null; area_sqft: number | null; year_built: number | null; } /** Unité la plus plausible pour l'annonce : distance + type + superficie + année. */ function matchUnit(l: ListingRow): { u: UnitRow; dist: number } | null { const bucket = typeBucket(l.property_type); const areaM2 = l.area_sqft && l.area_sqft > 100 ? l.area_sqft * 0.092903 : null; for (const [halfDeg, capM] of [ [0.001, 75], [0.003, 250], ] as const) { const cands = unitsInBox.all( l.lat - halfDeg, l.lat + halfDeg, l.lng - halfDeg, l.lng + halfDeg ) as UnitRow[]; let best: { u: UnitRow; dist: number; score: number } | null = null; for (const u of cands) { if (u.lat == null || u.lng == null) continue; const dist = haversineM(l.lat, l.lng, u.lat, u.lng); if (dist > capM) continue; let score = dist; if (bucket && !bucket.has(u.type_prop)) score += 120; if (areaM2 && u.aire_etages_m2 && u.aire_etages_m2 > 10) score += Math.min(1.5, Math.abs(areaM2 - u.aire_etages_m2) / u.aire_etages_m2) * 150; if (l.year_built && u.annee_construction && u.annee_construction > 1500) score += Math.min(50, Math.abs(l.year_built - u.annee_construction)) * 1.5; if (!best || score < best.score) best = { u, dist, score }; } if (best) return { u: best.u, dist: best.dist }; } return null; } /* --------------- méthodes additionnelles : coût & rôle indexé --------------- */ /** * Méthode du coût, calibrée sur le marché par la technique du * résiduel : terrain au marché = valeur_terrain(rôle) × ratio de vente médian * du type ; bâtiment = aire d'étages × coût unitaire net médian $/m², lu dans * les ventes des 18 derniers mois (résiduel = prix ajusté au marché − terrain) * par type × tranche d'âge — la dépréciation est donc observée, pas imposée. * Méthode du rôle indexé (étude de ratios IAAO) : valeur = valeur_role × * ratio médian vente/rôle de la municipalité (repli : province), par type. */ const AGE_BANDS = [5, 15, 30, 50, 75, Infinity]; const ageBand = (age: number | null): string => { if (age == null || age < 0) return "?"; return String(AGE_BANDS.find((b) => age <= b)); }; interface CalibTx { amount: number; date: string; type_prop: string; municipalite: string | null; valeur_terrain: number | null; aire_etages_m2: number | null; annee_construction: number | null; valeur_role: number | null; } const nowYear = new Date().getFullYear(); const idxByMonth = (bucket: string) => { const m = new Map(); for (const p of getMarketIndex(bucket)) m.set(p.month, p.idx); return m; }; const calib = (() => { const txs = getDb() .prepare( `SELECT t.amount, t.date, u.type_prop, u.municipalite, u.valeur_terrain, u.aire_etages_m2, u.annee_construction, u.valeur_role FROM transactions t JOIN units u ON u.id_provinc = t.id_provinc WHERE t.date >= ? AND t.amount BETWEEN 50000 AND 5000000` ) .all(since(18)) as CalibTx[]; const idx: Record> = { unifamilial: idxByMonth("unifamilial"), plex: idxByMonth("plex"), condo: idxByMonth("condo"), }; const adj = (t: CalibTx) => { const i = idx[indexType(t.type_prop)]?.get(t.date.slice(0, 7)); return i && i > 0.3 ? t.amount / i : t.amount; }; const med = (xs: number[]) => { const s = [...xs].sort((x, y) => x - y); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; // --- rôle indexé : ratio médian vente/rôle par municipalité×type (n≥12) const groups = new Map(); for (const t of txs) { if (!t.valeur_role || t.valeur_role < 10000) continue; const r = adj(t) / t.valeur_role; if (r < 0.3 || r > 4) continue; const bucket = indexType(t.type_prop); for (const k of [`${t.municipalite ?? "?"}|${bucket}`, `*|${bucket}`]) { if (!groups.has(k)) groups.set(k, []); groups.get(k)!.push(r); } } const roleRatio = new Map(); for (const [k, rs] of groups) if (rs.length >= 12) roleRatio.set(k, med(rs)); // --- coût : terrain × r* + résiduel médian $/m² par type × tranche d'âge const landFactor = new Map(); for (const bucket of ["unifamilial", "plex", "condo"]) { const r = roleRatio.get(`*|${bucket}`); if (r) landFactor.set(bucket, r); } const resid = new Map(); for (const t of txs) { if (!t.valeur_terrain || t.valeur_terrain <= 1000) continue; if (!t.aire_etages_m2 || t.aire_etages_m2 <= 20) continue; const bucket = indexType(t.type_prop); const lf = landFactor.get(bucket); if (!lf) continue; const rpm2 = (adj(t) - lf * t.valeur_terrain) / t.aire_etages_m2; if (rpm2 < -1000 || rpm2 > 10000) continue; const band = ageBand(t.annee_construction && t.annee_construction > 1500 ? nowYear - t.annee_construction : null); for (const k of [`${bucket}|${band}`, `${bucket}|*`]) { if (!resid.has(k)) resid.set(k, []); resid.get(k)!.push(rpm2); } } const costUnit = new Map(); for (const [k, rs] of resid) if (rs.length >= 150) costUnit.set(k, med(rs)); console.log( `[shard ${SHARD}] calibration : ${txs.length} ventes 18 mois · coût ${[...costUnit.entries()] .filter(([k]) => k.endsWith("|*")) .map(([k, v]) => `${k.split("|")[0]} ${v.toFixed(0)}$/m²`) .join(" · ")} (${costUnit.size} cellules) · ${roleRatio.size} ratios rôle` ); return { costUnit, landFactor, roleRatio }; })(); function costEstimate(u: UnitRow): number | null { const bucket = indexType(u.type_prop); const lf = calib.landFactor.get(bucket); if (!lf || !u.valeur_terrain || u.valeur_terrain <= 1000) return null; if (!u.aire_etages_m2 || u.aire_etages_m2 <= 20) return null; const age = u.annee_construction && u.annee_construction > 1500 ? nowYear - u.annee_construction : null; const cpm2 = calib.costUnit.get(`${bucket}|${ageBand(age)}`) ?? calib.costUnit.get(`${bucket}|*`); if (cpm2 == null) return null; const v = lf * u.valeur_terrain + cpm2 * u.aire_etages_m2; return v > 0 ? Math.round(v / 100) * 100 : null; } function roleEstimate(u: UnitRow): number | null { if (!u.valeur_role || u.valeur_role < 10000) return null; const bucket = indexType(u.type_prop); const r = calib.roleRatio.get(`${u.municipalite ?? "?"}|${bucket}`) ?? calib.roleRatio.get(`*|${bucket}`); return r ? Math.round((u.valeur_role * r) / 100) * 100 : null; } const medianOf = (xs: number[]) => { 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; }; /* ---------------------------------- lot ---------------------------------- */ const immo = new Database(IMMOKA_DB, { readonly: true, fileMustExist: true }); const rows = immo .prepare( `SELECT uid, price, lat, lng, property_type, area_sqft, year_built 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 AND (rowid % ?) = ? ${LIMIT ? "LIMIT " + LIMIT : ""}` ) .all(SHARDS, SHARD) as ListingRow[]; fs.mkdirSync(OUT_DIR, { recursive: true }); const outPath = path.join(OUT_DIR, `shard-${SHARD}.jsonl`); const out = fs.createWriteStream(outPath); let done = 0; let noUnit = 0; const t0 = Date.now(); for (const r of rows) { const m = matchUnit(r); if (!m) { noUnit++; continue; } const u = m.u; const subject: Subject = { lat: u.lat, lng: u.lng, typeProp: u.type_prop, floorArea: u.aire_etages_m2, yearBuilt: u.annee_construction, landArea: u.superficie_terrain_m2, modelEstimate: u.est_2026, modelP10: u.p10, modelP90: u.p90, }; const res = estimate(subject, toComps(candidatesAround(u.lat, u.lng)), idxFor(u.type_prop), nowISO); // méthodes complémentaires (la mesure principale reste l'hybride 65/35) const cost = costEstimate(u); const ridx = roleEstimate(u); const parts = [res.estimate, cost, ridx].filter((v): v is number => v != null && v > 0); const ens = parts.length ? Math.round(medianOf(parts)) : null; out.write( JSON.stringify({ uid: r.uid, unit_id: u.id_provinc, match_m: Math.round(m.dist * 10) / 10, price: r.price, est: res.estimate, low: res.low, high: res.high, cpct: res.confidencePct, clevel: res.confidenceLevel, model: res.modelEstimate, comps: res.compsEstimate, mw: res.modelWeight, ncomps: res.nCompsUsed, cost, ridx, ens, role: u.valeur_role, type: u.type_prop, muni: u.municipalite, }) + "\n" ); done++; if (done % 2000 === 0) { const rate = done / ((Date.now() - t0) / 1000); console.log(`[shard ${SHARD}] ${done}/${rows.length} (${rate.toFixed(0)}/s)`); } } out.end(); console.log( `[shard ${SHARD}] terminé : ${done} évaluées, ${noUnit} sans unité à ≤250 m, ` + `${((Date.now() - t0) / 1000).toFixed(0)} s → ${outPath}` );