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%
26.4 KB · 476 lines tsx
Raw Blame History
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&apos;agrégateur Immo-Ka, copiée le {copied}) et comparé chaque estimation au{" "}235              <b>prix demandé</b> par le vendeur. C&apos;est un test grandeur nature — et une leçon d&apos;évaluation :236              l&apos;écart observé mélange l&apos;erreur du modèle <i>et</i> la stratégie d&apos;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&apos;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&apos;écart277            médian monte à {pct(g.mdape, "fr")} : la différence contient la marge de négociation, la surenchère278            d&apos;affichage et les propriétés atypiques — pas seulement l&apos;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&apos;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&apos;unité du rôle d&apos;é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&apos;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