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 * /stats/marche — la mesure à l'épreuve du marché actif.4 * Compare l'estimation Vrai-Prix au prix demandé de ~49 000 annonces5 * réelles (copie de la base Immo-Ka, merge table vp_eval), et montre —6 * démarche — ce que l'écart mesure vraiment : l'erreur du7 * modèle PLUS la stratégie d'affichage du vendeur.8 */9"use client";10import { locNum } from "./MetricViz";11import { useLang } from "./LangContext";1213/* ---------------------------------- types --------------------------------- */14interface Slice {15 key: string;16 n: number;17 medianRatio: number | null;18 mdape: number | null;19 within10: number | null;20 within20: number | null;21 askAbove: number | null;22 inP10P90: number | null;23 medianAsk: number | null;24 medianEst: number | null;25 medianDiff: number | null;26 medianDiffPct: number | null;27 p25Diff: number | null;28 p75Diff: number | null;29}30interface MethodRow {31 key: string;32 main: boolean;33 n: number;34 coverage: number | null;35 medianRatio: number | null;36 mdape: number | null;37 within10: number | null;38 within20: number | null;39 medianDiff: number | null;40}41export interface MarcheStats {42 generated: string;43 source: {44 app: string;45 copiedAt: string;46 totalListings: number;47 geolocated: number;48 evaluated: number;49 valid: number;50 matchMedianM: number | null;51 };52 global: Omit<Slice, "key">;53 methods: MethodRow[];54 byConfidence: Slice[];55 byType: Slice[];56 byRegion: Slice[];57 byCity: Slice[];58 byBracket: Slice[];59 histogram: { below: number; above: number; bins: { lo: number; hi: number; n: number }[] };60 scatter: { p: number; e: number; l: string }[];61}6263/* -------------------------------- helpers -------------------------------- */64const money = (v: number | null, lang: string) =>65 v == null66 ? "—"67 : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {68 style: "currency",69 currency: "CAD",70 maximumFractionDigits: 0,71 }).format(v);72const pct = (v: number | null, lang: string, frac = 1) =>73 v == null ? "—" : `${(v * 100).toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: frac })} %`;7475function SectionTitle({ kicker, title }: { kicker: string; title: string }) {76 return (77 <div className="mb-4 mt-12">78 <span className="kicker">{kicker}</span>79 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{title}</h2>80 </div>81 );82}8384function Kpi({ label, value, hint }: { label: string; value: string; hint?: string }) {85 return (86 <div className="vp-card p-4">87 <div className="text-[11px] uppercase tracking-[0.08em] text-ink-3">{label}</div>88 <div className="vp-display mt-1 text-[26px] font-bold leading-none">{value}</div>89 {hint ? <div className="mt-2 text-[12px] leading-snug text-ink-2">{hint}</div> : null}90 </div>91 );92}9394/* ------------------------- histogramme du ratio ------------------------- */95function RatioHistogram({ s, fr }: { s: MarcheStats; fr: boolean }) {96 const bins = [97 { lo: 0, hi: 0.5, n: s.histogram.below },98 ...s.histogram.bins,99 { lo: 1.5, hi: Infinity, n: s.histogram.above },100 ];101 const max = Math.max(...bins.map((b) => b.n));102 const W = 720;103 const H = 210;104 const pad = { l: 8, r: 8, t: 14, b: 26 };105 const bw = (W - pad.l - pad.r) / bins.length;106 // repère x du ratio 1,0 : frontière entre les bacs 0,95-1,00 et 1,00-1,05107 const oneIdx = 1 + s.histogram.bins.findIndex((b) => Math.abs(b.lo - 1) < 1e-9);108 const oneX = pad.l + oneIdx * bw;109 return (110 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img"111 aria-label={fr ? "Distribution du ratio estimation / prix demandé" : "Distribution of estimate / asking price ratio"}>112 {bins.map((b, i) => {113 const h = max ? ((H - pad.t - pad.b) * b.n) / max : 0;114 const near1 = b.lo >= 0.9 - 1e-9 && b.hi <= 1.1 + 1e-9;115 return (116 <rect key={i} x={pad.l + i * bw + 1} y={H - pad.b - h} width={bw - 2} height={h}117 fill={near1 ? "var(--accent)" : "var(--ink-3)"} opacity={near1 ? 1 : 0.55} />118 );119 })}120 <line x1={oneX} y1={pad.t - 6} x2={oneX} y2={H - pad.b} stroke="var(--ink)" strokeDasharray="3 3" />121 <text x={oneX + 5} y={pad.t + 4} fontSize="11" fill="var(--ink)" fontFamily="var(--font-mono)">122 1,0 — {fr ? "estimation = prix demandé" : "estimate = asking price"}123 </text>124 {[0.5, 0.75, 1.0, 1.25, 1.5].map((v) => {125 const idx = 1 + (v - 0.5) / 0.05; // frontières des bacs intérieurs126 return (127 <text key={v} x={pad.l + idx * bw} y={H - 8} fontSize="10" textAnchor="middle" fill="var(--ink-3)"128 fontFamily="var(--font-mono)">129 {v.toLocaleString(fr ? "fr-CA" : "en-CA")}130 </text>131 );132 })}133 </svg>134 );135}136137/* --------------------- nuage prix demandé × estimation -------------------- */138function AskScatter({ s, fr }: { s: MarcheStats; fr: boolean }) {139 const CAP = 1_500_000;140 const pts = s.scatter.filter((d) => d.p <= CAP && d.e <= CAP);141 const W = 360;142 const H = 360;143 const pad = 34;144 const sc = (v: number) => pad + ((W - 2 * pad) * v) / CAP;145 return (146 <svg viewBox={`0 0 ${W} ${H}`} className="w-full max-w-[420px]" role="img"147 aria-label={fr ? "Nuage prix demandé vs estimation" : "Asking price vs estimate scatter"}>148 {[500000, 1000000, 1500000].map((v) => (149 <g key={v}>150 <line x1={sc(v)} y1={H - pad} x2={sc(v)} y2={pad} stroke="var(--line-soft)" />151 <line x1={pad} y1={H - sc(v)} x2={W - pad} y2={H - sc(v)} stroke="var(--line-soft)" />152 <text x={sc(v)} y={H - pad + 14} fontSize="9" textAnchor="middle" fill="var(--ink-3)" fontFamily="var(--font-mono)">153 {v / 1000000 >= 1 ? `${v / 1000000}M` : `${v / 1000}k`}154 </text>155 <text x={pad - 6} y={H - sc(v) + 3} fontSize="9" textAnchor="end" fill="var(--ink-3)" fontFamily="var(--font-mono)">156 {v / 1000000 >= 1 ? `${v / 1000000}M` : `${v / 1000}k`}157 </text>158 </g>159 ))}160 <line x1={sc(0)} y1={H - sc(0)} x2={sc(CAP)} y2={H - sc(CAP)} stroke="var(--ink)" strokeDasharray="4 3" />161 {pts.map((d, i) => (162 <circle key={i} cx={sc(d.p)} cy={H - sc(d.e)} r={1.6}163 fill={d.l === "A" ? "var(--accent)" : d.l === "B" ? "var(--accent-bright)" : "var(--ink-3)"} opacity={0.5} />164 ))}165 <text x={W / 2} y={H - 4} fontSize="10" textAnchor="middle" fill="var(--ink-2)">166 {fr ? "Prix demandé" : "Asking price"}167 </text>168 <text x={10} y={H / 2} fontSize="10" textAnchor="middle" fill="var(--ink-2)" transform={`rotate(-90 10 ${H / 2})`}>169 {fr ? "Estimation Vrai-Prix" : "Vrai-Prix estimate"}170 </text>171 </svg>172 );173}174175/* ------------------------------ tableau générique ------------------------------ */176function SliceTable({ rows, label, fr, keyHeader }: { rows: Slice[]; label?: string; fr: boolean; keyHeader: string }) {177 return (178 <div className="vp-card overflow-x-auto p-0">179 {label ? <div className="border-b border-[var(--line)] px-4 py-2 text-[12px] font-semibold uppercase tracking-[0.06em]">{label}</div> : null}180 <table className="w-full text-[13px]">181 <thead>182 <tr className="border-b border-[var(--line)] text-left text-[11px] uppercase tracking-[0.06em] text-ink-3">183 <th className="px-4 py-2">{keyHeader}</th>184 <th className="px-2 py-2 text-right">n</th>185 <th className="px-2 py-2 text-right">{fr ? "Ratio méd." : "Med. ratio"}</th>186 <th className="px-2 py-2 text-right">MdAPE</th>187 <th className="px-2 py-2 text-right">±20 %</th>188 <th className="px-2 py-2 text-right">{fr ? "Écart méd." : "Med. gap"}</th>189 <th className="px-4 py-2 text-right">{fr ? "Prix méd." : "Med. ask"}</th>190 </tr>191 </thead>192 <tbody>193 {rows.map((r) => (194 <tr key={r.key} className="border-b border-[var(--line-soft)] last:border-0">195 <td className="px-4 py-1.5 font-medium">{r.key}</td>196 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{locNum(r.n, fr ? "fr" : "en", { maxFrac: 0 })}</td>197 <td className="px-2 py-1.5 text-right font-mono text-[12px]">198 {r.medianRatio == null ? "—" : r.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3, maximumFractionDigits: 3 })}199 </td>200 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(r.mdape, fr ? "fr" : "en")}</td>201 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(r.within20, fr ? "fr" : "en", 0)}</td>202 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{money(r.medianDiff, fr ? "fr" : "en")}</td>203 <td className="px-4 py-1.5 text-right font-mono text-[12px]">{money(r.medianAsk, fr ? "fr" : "en")}</td>204 </tr>205 ))}206 </tbody>207 </table>208 </div>209 );210}211212/* ---------------------------------- vue ---------------------------------- */213export default function MarcheView({ s }: { s: MarcheStats }) {214 const { lang } = useLang();215 const fr = lang === "fr";216 const g = s.global;217 const copied = new Date(s.source.copiedAt + "T12:00:00").toLocaleDateString(fr ? "fr-CA" : "en-CA", {218 year: "numeric", month: "long", day: "numeric",219 });220221 return (222 <div className="mx-auto max-w-5xl px-4 pt-10 sm:pt-14">223 {/* ---- manchette ---- */}224 <section>225 <span className="kicker">{fr ? "Validation externe" : "External validation"}</span>226 <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">227 {fr ? "La mesure à l'épreuve du marché" : "The measure vs. the market"}228 </h1>229 <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">230 {fr ? (231 <>232 Nous avons évalué avec le moteur Vrai-Prix{" "}233 <b>{locNum(s.source.evaluated, "fr", { maxFrac: 0 })} annonces réellement à vendre</b> au Québec234 (base de l'agrégateur Immo-Ka, copiée le {copied}) et comparé chaque estimation au{" "}235 <b>prix demandé</b> par le vendeur. C'est un test grandeur nature — et une leçon d'évaluation :236 l'écart observé mélange l'erreur du modèle <i>et</i> la stratégie d'affichage.237 </>238 ) : (239 <>240 We ran the Vrai-Prix engine on{" "}241 <b>{locNum(s.source.evaluated, "en", { maxFrac: 0 })} properties actually for sale</b> in Québec242 (Immo-Ka aggregator database, copied on {copied}) and compared each estimate to the seller's{" "}243 <b>asking price</b>. A full-scale test — and an appraisal lesson: the observed gap mixes model244 error <i>and</i> pricing strategy.245 </>246 )}247 </p>248 <div className="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-3">249 <Kpi250 label={fr ? "Annonces évaluées" : "Listings appraised"}251 value={locNum(s.source.valid, fr ? "fr" : "en", { maxFrac: 0 })}252 hint={fr ? `sur ${locNum(s.source.totalListings, "fr", { maxFrac: 0 })} annonces actives publiées` : `of ${locNum(s.source.totalListings, "en", { maxFrac: 0 })} active published listings`}253 />254 <Kpi255 label={fr ? "Ratio médian estimation / prix demandé" : "Median estimate / asking ratio"}256 value={g.medianRatio == null ? "—" : g.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3 })}257 hint={258 fr259 ? `les vendeurs affichent en médiane ${pct(g.medianDiffPct, "fr")} au-dessus de notre estimation`260 : `sellers list a median ${pct(g.medianDiffPct, "en")} above our estimate`261 }262 />263 <Kpi264 label={fr ? "Prix demandés dans P10-P90" : "Asking prices within P10-P90"}265 value={pct(g.inP10P90, fr ? "fr" : "en")}266 hint={fr ? "part des prix demandés couverts par notre intervalle de confiance" : "share of asking prices covered by our confidence interval"}267 />268 </div>269 </section>270271 {/* ---- avertissement ---- */}272 <div className="vp-card mt-8 border-l-4 border-l-[var(--accent)] p-4 text-[13.5px] leading-relaxed text-ink-2">273 {fr ? (274 <>275 <b className="text-ink">Prix demandé ≠ valeur marchande.</b> Le moteur est calibré sur des{" "}276 <b>transactions réelles</b> (MdAPE 11,0 % contre les prix de vente). Face aux prix <i>demandés</i>, l'écart277 médian monte à {pct(g.mdape, "fr")} : la différence contient la marge de négociation, la surenchère278 d'affichage et les propriétés atypiques — pas seulement l'erreur du modèle.{" "}279 {pct(g.askAbove, "fr", 0)} des annonces sont affichées <b>au-dessus</b> de notre estimation, un biais280 attendu et documenté en évaluation.281 </>282 ) : (283 <>284 <b className="text-ink">Asking price ≠ market value.</b> The engine is calibrated on{" "}285 <b>real transactions</b> (11.0% MdAPE against sale prices). Against <i>asking</i> prices the median gap286 rises to {pct(g.mdape, "en")}: the difference contains negotiation margin, listing premium and atypical287 properties — not just model error. {pct(g.askAbove, "en", 0)} of listings are priced <b>above</b> our288 estimate, an expected and well-documented bias.289 </>290 )}291 </div>292293 {/* ---- indicateurs ---- */}294 <SectionTitle kicker={fr ? "Indicateurs" : "Indicators"} title={fr ? "Performance globale" : "Overall performance"} />295 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">296 <Kpi label={fr ? "MdAPE vs prix demandé" : "MdAPE vs asking"} value={pct(g.mdape, fr ? "fr" : "en")} />297 <Kpi label={fr ? "Écart ≤ 10 %" : "Within 10%"} value={pct(g.within10, fr ? "fr" : "en")} />298 <Kpi label={fr ? "Écart ≤ 20 %" : "Within 20%"} value={pct(g.within20, fr ? "fr" : "en")} />299 <Kpi label={fr ? "Affichées au-dessus de l'estimation" : "Listed above estimate"} value={pct(g.askAbove, fr ? "fr" : "en")} />300 <Kpi label={fr ? "Prix demandé médian" : "Median asking price"} value={money(g.medianAsk, fr ? "fr" : "en")} />301 <Kpi label={fr ? "Estimation médiane" : "Median estimate"} value={money(g.medianEst, fr ? "fr" : "en")} />302 </div>303304 {/* ---- l'écart en dollars ---- */}305 <SectionTitle kicker={fr ? "L'écart" : "The gap"} title={fr ? "Prix demandé − valeur estimée" : "Asking price − estimated value"} />306 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">307 <Kpi308 label={fr ? "Écart médian" : "Median gap"}309 value={money(g.medianDiff, fr ? "fr" : "en")}310 hint={fr ? "en médiane, le vendeur demande ce montant de plus que notre estimation" : "median amount sellers ask above our estimate"}311 />312 <Kpi313 label={fr ? "Écart médian (%)" : "Median gap (%)"}314 value={pct(g.medianDiffPct, fr ? "fr" : "en")}315 hint={fr ? "(prix demandé − estimation) / estimation" : "(asking − estimate) / estimate"}316 />317 <Kpi318 label={fr ? "Écart interquartile" : "Interquartile gap"}319 value={`${money(g.p25Diff, fr ? "fr" : "en")} … ${money(g.p75Diff, fr ? "fr" : "en")}`}320 hint={fr ? "la moitié des annonces se situe dans cette fourchette d'écart (P25-P75)" : "half of listings fall in this gap range (P25-P75)"}321 />322 </div>323324 {/* ---- distribution ---- */}325 <SectionTitle kicker={fr ? "Distribution" : "Distribution"} title={fr ? "Ratio estimation / prix demandé" : "Estimate / asking price ratio"} />326 <div className="vp-card p-4">327 <RatioHistogram s={s} fr={fr} />328 <p className="mt-2 text-[12.5px] text-ink-2">329 {fr330 ? "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."331 : "Blue: listings where the estimate is within ±10% of asking. Mass left of 1.0 = sellers asking more than our estimated value."}332 </p>333 </div>334335 {/* ---- nuage + confiance ---- */}336 <div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">337 <div className="vp-card p-4">338 <div className="mb-2 text-[12px] font-semibold uppercase tracking-[0.06em]">339 {fr ? "Prix demandé × estimation (échantillon)" : "Asking × estimate (sample)"}340 </div>341 <AskScatter s={s} fr={fr} />342 <p className="mt-1 text-[12px] text-ink-2">343 {fr344 ? "Diagonale = accord parfait. Bleu foncé : confiance A · turquoise : B · gris : C-D."345 : "Diagonal = perfect agreement. Dark blue: confidence A · turquoise: B · grey: C-D."}346 </p>347 </div>348 <div>349 <SliceTable350 rows={s.byConfidence.filter((r) => r.n > 0)}351 fr={fr}352 keyHeader={fr ? "Indice de confiance" : "Confidence index"}353 label={fr ? "L'indice de confiance tient sa promesse" : "The confidence index keeps its promise"}354 />355 <p className="mt-2 px-1 text-[12.5px] leading-relaxed text-ink-2">356 {fr357 ? "Point clé : l'erreur croît exactement comme l'indice l'annonce — de "358 : "Key point: error grows exactly as the index predicts — from "}359 {pct(s.byConfidence[0]?.mdape ?? null, fr ? "fr" : "en")} (A){fr ? " à " : " to "}360 {pct(s.byConfidence[2]?.mdape ?? null, fr ? "fr" : "en")} (C).{" "}361 {fr362 ? "En confiance A, le ratio médian est de 1,000 : aucune sur- ni sous-évaluation systématique."363 : "At confidence A the median ratio is 1.000: no systematic over- or under-valuation."}364 </p>365 </div>366 </div>367368 {/* ---- duel des méthodes ---- */}369 <SectionTitle kicker={fr ? "Trois méthodes" : "Three approaches"} title={fr ? "Le duel des méthodes" : "The method duel"} />370 <p className="mb-4 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">371 {fr372 ? "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."373 : "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."}374 </p>375 <div className="vp-card overflow-x-auto p-0">376 <table className="w-full text-[13px]">377 <thead>378 <tr className="border-b border-[var(--line)] text-left text-[11px] uppercase tracking-[0.06em] text-ink-3">379 <th className="px-4 py-2">{fr ? "Méthode" : "Method"}</th>380 <th className="px-2 py-2 text-right">n</th>381 <th className="px-2 py-2 text-right">{fr ? "Ratio méd." : "Med. ratio"}</th>382 <th className="px-2 py-2 text-right">MdAPE</th>383 <th className="px-2 py-2 text-right">±10 %</th>384 <th className="px-2 py-2 text-right">±20 %</th>385 <th className="px-4 py-2 text-right">{fr ? "Écart méd." : "Med. gap"}</th>386 </tr>387 </thead>388 <tbody>389 {s.methods.map((m) => {390 const labels: Record<string, [string, string]> = {391 hybride: ["Hybride 65/35 — la mesure Vrai-Prix", "65/35 hybrid — the Vrai-Prix measure"],392 hedonique: ["Modèle hédonique seul (LightGBM)", "Hedonic model alone (LightGBM)"],393 comparables: ["Comparables ajustés seuls", "Adjusted comparables alone"],394 cout: ["Méthode du coût (calibrée marché)", "Cost approach (market-calibrated)"],395 role_indexe: ["Rôle indexé (ratios de vente IAAO)", "Indexed roll (IAAO sales ratios)"],396 ensemble: ["Ensemble — médiane hybride·coût·rôle", "Ensemble — median of hybrid·cost·roll"],397 };398 const lbl = labels[m.key]?.[fr ? 0 : 1] ?? m.key;399 return (400 <tr key={m.key}401 className={`border-b border-[var(--line-soft)] last:border-0 ${m.main ? "bg-[var(--accent-soft)] font-semibold" : ""}`}>402 <td className="px-4 py-1.5">{m.main ? "★ " : ""}{lbl}</td>403 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{locNum(m.n, fr ? "fr" : "en", { maxFrac: 0 })}</td>404 <td className="px-2 py-1.5 text-right font-mono text-[12px]">405 {m.medianRatio == null ? "—" : m.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3, maximumFractionDigits: 3 })}406 </td>407 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(m.mdape, fr ? "fr" : "en")}</td>408 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(m.within10, fr ? "fr" : "en", 0)}</td>409 <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(m.within20, fr ? "fr" : "en", 0)}</td>410 <td className="px-4 py-1.5 text-right font-mono text-[12px]">{money(m.medianDiff, fr ? "fr" : "en")}</td>411 </tr>412 );413 })}414 </tbody>415 </table>416 </div>417 <p className="mt-2 max-w-3xl text-[12.5px] leading-relaxed text-ink-2">418 {fr419 ? "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."420 : "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."}421 </p>422423 {/* ---- segments ---- */}424 <SectionTitle kicker={fr ? "Segments" : "Segments"} title={fr ? "Où la mesure excelle — et où elle peine" : "Where the measure excels — and struggles"} />425 <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">426 <SliceTable rows={s.byType} fr={fr} keyHeader={fr ? "Type d'annonce" : "Listing type"} label={fr ? "Par type de propriété" : "By property type"} />427 <SliceTable rows={s.byBracket} fr={fr} keyHeader={fr ? "Tranche de prix" : "Price bracket"} label={fr ? "Par tranche de prix demandé" : "By asking price bracket"} />428 <SliceTable rows={s.byRegion} fr={fr} keyHeader={fr ? "Région" : "Region"} label={fr ? "Par région" : "By region"} />429 <SliceTable rows={s.byCity} fr={fr} keyHeader={fr ? "Ville" : "City"} label={fr ? "Principales villes" : "Top cities"} />430 </div>431432 {/* ---- méthode ---- */}433 <SectionTitle kicker={fr ? "Méthode" : "Method"} title={fr ? "D'où viennent ces chiffres" : "Where these numbers come from"} />434 <div className="vp-card p-4 text-[13.5px] leading-relaxed text-ink-2">435 {fr ? (436 <>437 Copie intégrale de la base d'annonces <b>Immo-Ka</b> ({copied}) :{" "}438 {locNum(s.source.totalListings, "fr", { maxFrac: 0 })} annonces actives publiées, dont{" "}439 {locNum(s.source.geolocated, "fr", { maxFrac: 0 })} géolocalisées. Chaque annonce est{" "}440 <b>jumelée par coordonnées GPS</b> à l'unité du rôle d'évaluation MAMH la plus proche441 (rayon 75 m puis 250 m, départage par type, superficie habitable et année — distance médiane442 de jumelage : {locNum(s.source.matchMedianM, "fr", { maxFrac: 1, unit: "m" })}) ;{" "}443 {locNum(s.source.evaluated, "fr", { maxFrac: 0 })} ont ainsi été évaluées, et l'analyse retient444 les {locNum(s.source.valid, "fr", { maxFrac: 0 })} à prix demandé ≥ 50 000 $ — le même seuil que les445 ventes retenues par le moteur. Le merge complet annonce ↔ estimations (hybride, coût, rôle indexé)446 est conservé dans la table <code className="font-mono text-[12px]">vp_eval</code> et chaque annonce447 passe par <i>exactement le même calcul</i> que le formulaire public : modèle hédonique + comparables448 ajustés, pondération 65 / 35. Ratio médian et MdAPE sont des médianes — robustes aux annonces449 extrêmes.450 </>451 ) : (452 <>453 Full copy of the <b>Immo-Ka</b> listings database ({copied}):{" "}454 {locNum(s.source.totalListings, "en", { maxFrac: 0 })} active published listings,{" "}455 {locNum(s.source.geolocated, "en", { maxFrac: 0 })} geolocated. Each listing is{" "}456 <b>matched by GPS coordinates</b> to the nearest MAMH assessment-roll unit (75 m then 250 m radius,457 tie-broken by type, living area and year — median match distance:{" "}458 {locNum(s.source.matchMedianM, "en", { maxFrac: 1, unit: "m" })});{" "}459 {locNum(s.source.evaluated, "en", { maxFrac: 0 })} were appraised, and the analysis keeps the{" "}460 {locNum(s.source.valid, "en", { maxFrac: 0 })} listings asking ≥ $50,000 — the same floor as the461 sales the engine uses. The full listing ↔ estimates merge (hybrid, cost, indexed roll) lives in the{" "}462 <code className="font-mono text-[12px]">vp_eval</code> table, and every listing goes through{" "}463 <i>exactly the same computation</i> as the public form: hedonic model + adjusted comparables, 65/35464 weighting. Median ratio and MdAPE are medians — robust to extreme listings.465 </>466 )}467 </div>468 <p className="mb-4 mt-6 text-[11.5px] text-ink-3">469 {fr470 ? `Généré le ${new Date(s.generated).toLocaleDateString("fr-CA")} · données Immo-Ka + rôles MAMH · à titre indicatif.`471 : `Generated ${new Date(s.generated).toLocaleDateString("en-CA")} · Immo-Ka data + MAMH rolls · for teaching purposes.`}472 </p>473 </div>474 );475}476