// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * /stats/marche — la mesure à l'épreuve du marché actif. * Compare l'estimation Vrai-Prix au prix demandé de ~49 000 annonces * réelles (copie de la base Immo-Ka, merge table vp_eval), et montre — * démarche — ce que l'écart mesure vraiment : l'erreur du * modèle PLUS la stratégie d'affichage du vendeur. */ "use client"; import { locNum } from "./MetricViz"; import { useLang } from "./LangContext"; /* ---------------------------------- types --------------------------------- */ interface Slice { key: string; n: number; medianRatio: number | null; mdape: number | null; within10: number | null; within20: number | null; askAbove: number | null; inP10P90: number | null; medianAsk: number | null; medianEst: number | null; medianDiff: number | null; medianDiffPct: number | null; p25Diff: number | null; p75Diff: number | null; } interface MethodRow { key: string; main: boolean; n: number; coverage: number | null; medianRatio: number | null; mdape: number | null; within10: number | null; within20: number | null; medianDiff: number | null; } export interface MarcheStats { generated: string; source: { app: string; copiedAt: string; totalListings: number; geolocated: number; evaluated: number; valid: number; matchMedianM: number | null; }; global: Omit; methods: MethodRow[]; byConfidence: Slice[]; byType: Slice[]; byRegion: Slice[]; byCity: Slice[]; byBracket: Slice[]; histogram: { below: number; above: number; bins: { lo: number; hi: number; n: number }[] }; scatter: { p: number; e: number; l: string }[]; } /* -------------------------------- helpers -------------------------------- */ const money = (v: number | null, lang: string) => v == null ? "—" : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(v); const pct = (v: number | null, lang: string, frac = 1) => v == null ? "—" : `${(v * 100).toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: frac })} %`; function SectionTitle({ kicker, title }: { kicker: string; title: string }) { return (
{kicker}

{title}

); } function Kpi({ label, value, hint }: { label: string; value: string; hint?: string }) { return (
{label}
{value}
{hint ?
{hint}
: null}
); } /* ------------------------- histogramme du ratio ------------------------- */ function RatioHistogram({ s, fr }: { s: MarcheStats; fr: boolean }) { const bins = [ { lo: 0, hi: 0.5, n: s.histogram.below }, ...s.histogram.bins, { lo: 1.5, hi: Infinity, n: s.histogram.above }, ]; const max = Math.max(...bins.map((b) => b.n)); const W = 720; const H = 210; const pad = { l: 8, r: 8, t: 14, b: 26 }; const bw = (W - pad.l - pad.r) / bins.length; // repère x du ratio 1,0 : frontière entre les bacs 0,95-1,00 et 1,00-1,05 const oneIdx = 1 + s.histogram.bins.findIndex((b) => Math.abs(b.lo - 1) < 1e-9); const oneX = pad.l + oneIdx * bw; return ( {bins.map((b, i) => { const h = max ? ((H - pad.t - pad.b) * b.n) / max : 0; const near1 = b.lo >= 0.9 - 1e-9 && b.hi <= 1.1 + 1e-9; return ( ); })} 1,0 — {fr ? "estimation = prix demandé" : "estimate = asking price"} {[0.5, 0.75, 1.0, 1.25, 1.5].map((v) => { const idx = 1 + (v - 0.5) / 0.05; // frontières des bacs intérieurs return ( {v.toLocaleString(fr ? "fr-CA" : "en-CA")} ); })} ); } /* --------------------- nuage prix demandé × estimation -------------------- */ function AskScatter({ s, fr }: { s: MarcheStats; fr: boolean }) { const CAP = 1_500_000; const pts = s.scatter.filter((d) => d.p <= CAP && d.e <= CAP); const W = 360; const H = 360; const pad = 34; const sc = (v: number) => pad + ((W - 2 * pad) * v) / CAP; return ( {[500000, 1000000, 1500000].map((v) => ( {v / 1000000 >= 1 ? `${v / 1000000}M` : `${v / 1000}k`} {v / 1000000 >= 1 ? `${v / 1000000}M` : `${v / 1000}k`} ))} {pts.map((d, i) => ( ))} {fr ? "Prix demandé" : "Asking price"} {fr ? "Estimation Vrai-Prix" : "Vrai-Prix estimate"} ); } /* ------------------------------ tableau générique ------------------------------ */ function SliceTable({ rows, label, fr, keyHeader }: { rows: Slice[]; label?: string; fr: boolean; keyHeader: string }) { return (
{label ?
{label}
: null} {rows.map((r) => ( ))}
{keyHeader} n {fr ? "Ratio méd." : "Med. ratio"} MdAPE ±20 % {fr ? "Écart méd." : "Med. gap"} {fr ? "Prix méd." : "Med. ask"}
{r.key} {locNum(r.n, fr ? "fr" : "en", { maxFrac: 0 })} {r.medianRatio == null ? "—" : r.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3, maximumFractionDigits: 3 })} {pct(r.mdape, fr ? "fr" : "en")} {pct(r.within20, fr ? "fr" : "en", 0)} {money(r.medianDiff, fr ? "fr" : "en")} {money(r.medianAsk, fr ? "fr" : "en")}
); } /* ---------------------------------- vue ---------------------------------- */ export default function MarcheView({ s }: { s: MarcheStats }) { const { lang } = useLang(); const fr = lang === "fr"; const g = s.global; const copied = new Date(s.source.copiedAt + "T12:00:00").toLocaleDateString(fr ? "fr-CA" : "en-CA", { year: "numeric", month: "long", day: "numeric", }); return (
{/* ---- manchette ---- */}
{fr ? "Validation externe" : "External validation"}

{fr ? "La mesure à l'épreuve du marché" : "The measure vs. the market"}

{fr ? ( <> Nous avons évalué avec le moteur Vrai-Prix{" "} {locNum(s.source.evaluated, "fr", { maxFrac: 0 })} annonces réellement à vendre au Québec (base de l'agrégateur Immo-Ka, copiée le {copied}) et comparé chaque estimation au{" "} prix demandé par le vendeur. C'est un test grandeur nature — et une leçon d'évaluation : l'écart observé mélange l'erreur du modèle et la stratégie d'affichage. ) : ( <> We ran the Vrai-Prix engine on{" "} {locNum(s.source.evaluated, "en", { maxFrac: 0 })} properties actually for sale in Québec (Immo-Ka aggregator database, copied on {copied}) and compared each estimate to the seller's{" "} asking price. A full-scale test — and an appraisal lesson: the observed gap mixes model error and pricing strategy. )}

{/* ---- avertissement ---- */}
{fr ? ( <> Prix demandé ≠ valeur marchande. Le moteur est calibré sur des{" "} transactions réelles (MdAPE 11,0 % contre les prix de vente). Face aux prix demandés, l'écart médian monte à {pct(g.mdape, "fr")} : la différence contient la marge de négociation, la surenchère d'affichage et les propriétés atypiques — pas seulement l'erreur du modèle.{" "} {pct(g.askAbove, "fr", 0)} des annonces sont affichées au-dessus de notre estimation, un biais attendu et documenté en évaluation. ) : ( <> Asking price ≠ market value. The engine is calibrated on{" "} real transactions (11.0% MdAPE against sale prices). Against asking prices the median gap rises to {pct(g.mdape, "en")}: the difference contains negotiation margin, listing premium and atypical properties — not just model error. {pct(g.askAbove, "en", 0)} of listings are priced above our estimate, an expected and well-documented bias. )}
{/* ---- indicateurs ---- */}
{/* ---- l'écart en dollars ---- */}
{/* ---- distribution ---- */}

{fr ? "En bleu : annonces où l'estimation est à ±10 % du prix demandé. La masse à gauche de 1,0 = vendeurs qui demandent plus que notre valeur estimée." : "Blue: listings where the estimate is within ±10% of asking. Mass left of 1.0 = sellers asking more than our estimated value."}

{/* ---- nuage + confiance ---- */}
{fr ? "Prix demandé × estimation (échantillon)" : "Asking × estimate (sample)"}

{fr ? "Diagonale = accord parfait. Bleu foncé : confiance A · turquoise : B · gris : C-D." : "Diagonal = perfect agreement. Dark blue: confidence A · turquoise: B · grey: C-D."}

r.n > 0)} fr={fr} keyHeader={fr ? "Indice de confiance" : "Confidence index"} label={fr ? "L'indice de confiance tient sa promesse" : "The confidence index keeps its promise"} />

{fr ? "Point clé : l'erreur croît exactement comme l'indice l'annonce — de " : "Key point: error grows exactly as the index predicts — from "} {pct(s.byConfidence[0]?.mdape ?? null, fr ? "fr" : "en")} (A){fr ? " à " : " to "} {pct(s.byConfidence[2]?.mdape ?? null, fr ? "fr" : "en")} (C).{" "} {fr ? "En confiance A, le ratio médian est de 1,000 : aucune sur- ni sous-évaluation systématique." : "At confidence A the median ratio is 1.000: no systematic over- or under-valuation."}

{/* ---- duel des méthodes ---- */}

{fr ? "Chaque annonce est aussi évaluée par les méthodes classiques de l'évaluation : la méthode du coût (terrain au marché + coût unitaire net du bâtiment, calibrés sur les ventes des 18 derniers mois grâce à la composition de chaque immeuble au rôle — aire d'étages, terrain, année, valeurs terrain/bâtiment) et le rôle indexé (étude de ratios de vente, IAAO). La mesure officielle Vrai-Prix reste l'hybride 65/35 — les autres servent de contre-expertise." : "Each listing is also appraised with the classic appraisal methods: the cost approach (market land + net unit building cost, calibrated on the last 18 months of sales using each building's composition in the roll — floor area, lot, year, land/building values) and the indexed roll (sales-ratio study, IAAO). The official Vrai-Prix measure remains the 65/35 hybrid — the others serve as cross-checks."}

{s.methods.map((m) => { const labels: Record = { hybride: ["Hybride 65/35 — la mesure Vrai-Prix", "65/35 hybrid — the Vrai-Prix measure"], hedonique: ["Modèle hédonique seul (LightGBM)", "Hedonic model alone (LightGBM)"], comparables: ["Comparables ajustés seuls", "Adjusted comparables alone"], cout: ["Méthode du coût (calibrée marché)", "Cost approach (market-calibrated)"], role_indexe: ["Rôle indexé (ratios de vente IAAO)", "Indexed roll (IAAO sales ratios)"], ensemble: ["Ensemble — médiane hybride·coût·rôle", "Ensemble — median of hybrid·cost·roll"], }; const lbl = labels[m.key]?.[fr ? 0 : 1] ?? m.key; return ( ); })}
{fr ? "Méthode" : "Method"} n {fr ? "Ratio méd." : "Med. ratio"} MdAPE ±10 % ±20 % {fr ? "Écart méd." : "Med. gap"}
{m.main ? "★ " : ""}{lbl} {locNum(m.n, fr ? "fr" : "en", { maxFrac: 0 })} {m.medianRatio == null ? "—" : m.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3, maximumFractionDigits: 3 })} {pct(m.mdape, fr ? "fr" : "en")} {pct(m.within10, fr ? "fr" : "en", 0)} {pct(m.within20, fr ? "fr" : "en", 0)} {money(m.medianDiff, fr ? "fr" : "en")}

{fr ? "Leçon du duel : aucune méthode ne domine partout — le coût peine sur les condos (quote-part de terrain au rôle) et les propriétés atypiques, les comparables seuls dérapent en marché mince, et c'est la combinaison qui stabilise la mesure." : "Duel takeaway: no method dominates everywhere — cost struggles with condos (land share in the roll) and atypical properties, comparables alone drift in thin markets, and the combination is what stabilizes the measure."}

{/* ---- segments ---- */}
{/* ---- méthode ---- */}
{fr ? ( <> Copie intégrale de la base d'annonces Immo-Ka ({copied}) :{" "} {locNum(s.source.totalListings, "fr", { maxFrac: 0 })} annonces actives publiées, dont{" "} {locNum(s.source.geolocated, "fr", { maxFrac: 0 })} géolocalisées. Chaque annonce est{" "} jumelée par coordonnées GPS à l'unité du rôle d'évaluation MAMH la plus proche (rayon 75 m puis 250 m, départage par type, superficie habitable et année — distance médiane de jumelage : {locNum(s.source.matchMedianM, "fr", { maxFrac: 1, unit: "m" })}) ;{" "} {locNum(s.source.evaluated, "fr", { maxFrac: 0 })} ont ainsi été évaluées, et l'analyse retient les {locNum(s.source.valid, "fr", { maxFrac: 0 })} à prix demandé ≥ 50 000 $ — le même seuil que les ventes retenues par le moteur. Le merge complet annonce ↔ estimations (hybride, coût, rôle indexé) est conservé dans la table vp_eval et chaque annonce passe par exactement le même calcul que le formulaire public : modèle hédonique + comparables ajustés, pondération 65 / 35. Ratio médian et MdAPE sont des médianes — robustes aux annonces extrêmes. ) : ( <> Full copy of the Immo-Ka listings database ({copied}):{" "} {locNum(s.source.totalListings, "en", { maxFrac: 0 })} active published listings,{" "} {locNum(s.source.geolocated, "en", { maxFrac: 0 })} geolocated. Each listing is{" "} matched by GPS coordinates to the nearest MAMH assessment-roll unit (75 m then 250 m radius, tie-broken by type, living area and year — median match distance:{" "} {locNum(s.source.matchMedianM, "en", { maxFrac: 1, unit: "m" })});{" "} {locNum(s.source.evaluated, "en", { maxFrac: 0 })} were appraised, and the analysis keeps the{" "} {locNum(s.source.valid, "en", { maxFrac: 0 })} listings asking ≥ $50,000 — the same floor as the sales the engine uses. The full listing ↔ estimates merge (hybrid, cost, indexed roll) lives in the{" "} vp_eval table, and every listing goes through{" "} exactly the same computation as the public form: hedonic model + adjusted comparables, 65/35 weighting. Median ratio and MdAPE are medians — robust to extreme listings. )}

{fr ? `Généré le ${new Date(s.generated).toLocaleDateString("fr-CA")} · données Immo-Ka + rôles MAMH · à titre indicatif.` : `Generated ${new Date(s.generated).toLocaleDateString("en-CA")} · Immo-Ka data + MAMH rolls · for teaching purposes.`}

); }