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%
16.5 KB · 415 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Contrat commun /api/stats/dashboard v2 du Groupe KA (voir src/ka/stats/SPEC.md),4 * construit depuis src/data/stats.json (agrégats historiques du rôle) et5 * src/data/stats-v2.json (agrégats enrichis : distributions, transactions6 * mensuelles/quotidiennes, indice de marché — générés par7 * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles).8 *9 * Particularité Vrai-Prix : le rôle d'évaluation est un INSTANTANÉ (millésime10 * 2026) — `period` est accepté (contrat commun) mais non applicable ; les11 * vraies séries temporelles sont les millésimes 2021-2026, le corpus de12 * ventes réelles (mensuel, 2021-01 → 2026-07) et l'indice de marché mensuel.13 * Les delta_pct sont calculés (millésime vs millésime, 12 mois vs 12 mois14 * précédents) et arrondis à 1 décimale — jamais inventés.15 */16import stats from "@/data/stats.json";17import v2 from "@/data/stats-v2.json";18import type { ProvStats } from "@/components/StatsView";1920/** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */21export const TRANSACTIONS = 745119;2223export const PERIOD_LABEL = "instantané du rôle 2026";2425export const TYPE_FR: Record<string, string> = {26  unifamilial: "Unifamiliale",27  plex: "Plex (2-5 log.)",28  condo_ou_multi: "Condo / multi",29  chalet: "Chalet",30  maison_mobile: "Maison mobile",31  terrain: "Terrain",32  autre: "Autre",33};3435const money = (v: number) =>36  new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);37const compact = (v: number) => {38  if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;39  if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;40  if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;41  return money(v);42};43const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`;44const r1 = (v: number) => Math.round(v * 10) / 10;45const delta = (cur: number, prev: number): number | null =>46  prev > 0 ? r1((cur / prev - 1) * 100) : null;47const MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juill.", "août", "sept.", "oct.", "nov.", "déc."];48const moisLabel = (m: string) => `${MOIS_FR[parseInt(m.slice(5, 7), 10) - 1]} ${m.slice(0, 4)}`;4950/* ---------- types du contrat (SPEC §2) ---------- */51export type Point = { t: string; v: number };52export interface KaKpi {53  id: string; label: string; value: number | string; unit?: string;54  delta_pct?: number | null; direction?: "up" | "down"; spark?: Point[]; help?: string;55}56export interface KaSerie {57  id: string; title: string; unit?: string; kind: "line" | "bar" | "area";58  points: Point[]; compare?: Point[];59}60export interface KaRecord { label: string; value: string; date?: string }6162interface V2Data {63  generated: string;64  units: {65    total: number; geoloc: number; with_role: number; with_est2026: number;66    with_year: number; with_area: number; avg_est2026: number; avg_role: number;67    sum_role: number; logements: number;68  };69  valeur_bins: { label: string; value: number }[];70  construction: { label: string; n: number; total: number; moyenne: number | null }[];71  rec: {72    max_role: { municipalite: string; v: number; cubf_libelle: string | null };73    max_est: { municipalite: string; v: number };74  };75  tx: {76    monthly: { m: string; n: number; total: number; median: number }[];77    monthly_by_type: { keys: string[]; points: { t: string; values: number[] }[] };78    amount_bins: { label: string; value: number }[];79    daily: { date: string; n: number }[];80    max_date: string;81    max: { amount: number; city: string | null; date: string };82    best_month: { m: string; n: number };83    top_villes: { city: string; n: number; moyenne: number }[];84  };85  market: {86    idx_by_type: { label: string; points: Point[] }[];87    ppm2_by_type: { label: string; points: Point[] }[];88  };89}90const V = v2 as unknown as V2Data;9192/** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */93export function buildRecords(s: ProvStats): KaRecord[] {94  const out: KaRecord[] = [];95  const topVille = s.par_ville[0];96  if (topVille) {97    out.push({98      label: "Municipalité la plus valorisée",99      value: `${topVille.ville} — ${compact(topVille.total)}`,100    });101  }102  const topMed = s.par_ville.reduce((a, b) => ((b.mediane ?? 0) > (a.mediane ?? 0) ? b : a), s.par_ville[0]);103  if (topMed?.mediane) {104    out.push({105      label: "Valeur médiane la plus élevée (top 200)",106      value: `${topMed.ville} — ${money(topMed.mediane)}`,107    });108  }109  let bestYoy: { year: number; yoy: number } | null = null;110  for (let i = 1; i < s.totaux_annee.length; i++) {111    const yoy = (s.totaux_annee[i].total / s.totaux_annee[i - 1].total - 1) * 100;112    if (!bestYoy || yoy > bestYoy.yoy) bestYoy = { year: s.totaux_annee[i].year, yoy };113  }114  if (bestYoy) {115    out.push({116      label: "Plus forte hausse annuelle du parc",117      value: pct(bestYoy.yoy),118      date: String(bestYoy.year),119    });120  }121  const topType = s.par_type.reduce((a, b) => (b.total > a.total ? b : a), s.par_type[0]);122  if (topType) {123    const share = (topType.total / s.valeur_totale_2026) * 100;124    out.push({125      label: "Type de bien dominant",126      value: `${TYPE_FR[topType.type] ?? topType.type} — ${compact(topType.total)} (${share.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %)`,127    });128  }129  out.push({130    label: "Croissance 2021→2026 (périmètre constant)",131    value: pct(s.croissance_2021_2026_pct),132  });133  // records v2 — rôle & corpus de ventes (stats-v2.json, données réelles)134  if (V.rec?.max_role) {135    out.push({136      label: "Plus grosse évaluation au rôle",137      value: `${V.rec.max_role.cubf_libelle ?? "Immeuble"} (${V.rec.max_role.municipalite}) — ${compact(V.rec.max_role.v)}`,138    });139  }140  if (V.rec?.max_est) {141    out.push({142      label: "Estimation Vrai-Prix la plus élevée",143      value: `${V.rec.max_est.municipalite} — ${compact(V.rec.max_est.v)}`,144    });145  }146  if (V.tx?.max) {147    out.push({148      label: "Plus grosse vente du corpus",149      value: `${compact(V.tx.max.amount)}${V.tx.max.city ? ` — ${V.tx.max.city}` : ""}`,150      date: V.tx.max.date,151    });152  }153  if (V.tx?.best_month) {154    out.push({155      label: "Mois record de ventes",156      value: `${V.tx.best_month.n.toLocaleString("fr-CA")} ventes`,157      date: moisLabel(V.tx.best_month.m),158    });159  }160  const topTx = V.tx?.top_villes?.[0];161  if (topTx) {162    out.push({163      label: "Ville la plus active en ventes (2021-2026)",164      value: `${topTx.city} — ${topTx.n.toLocaleString("fr-CA")} ventes`,165    });166  }167  return out;168}169170function buildDashboard() {171  const s = stats as ProvStats;172  const u = V.units;173  const gpct = (a: number, b: number) => r1((100 * a) / (b || 1));174175  /* --- millésimes (2021-2026) : deltas millésime vs millésime précédent --- */176  const ta = s.totaux_annee;177  const last = ta[ta.length - 1];178  const prev = ta[ta.length - 2];179  const avgPrev = prev ? prev.total / prev.n : 0;180181  /* --- corpus de ventes : 12 derniers mois vs 12 mois précédents (réel) --- */182  const m = V.tx.monthly;183  const last12 = m.slice(-12);184  const prev12 = m.slice(-24, -12);185  const sum = (a: { n: number; total: number }[], k: "n" | "total") => a.reduce((x, y) => x + y[k], 0);186  const lastMonth = m[m.length - 1];187  const sameMonthN1 = m[m.length - 13];188189  const kpis: KaKpi[] = [190    {191      id: "unites", label: "Propriétés couvertes (rôle 2026)", value: s.unites,192      delta_pct: prev ? delta(last.n, prev.n) : null, direction: "up",193      spark: ta.map((a) => ({ t: String(a.year), v: a.n })),194      help: "Unités d'évaluation estimées au millésime 2026 — variation vs millésime 2025.",195    },196    {197      id: "valeur_totale", label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026),198      delta_pct: prev ? delta(last.total, prev.total) : null, direction: "up",199      spark: ta.map((a) => ({ t: String(a.year), v: a.total })),200      help: "Somme des estimations Vrai-Prix — variation vs millésime 2025.",201    },202    {203      id: "valeur_moyenne", label: "Valeur moyenne (2026)", value: money(u.avg_est2026),204      delta_pct: avgPrev ? delta(u.avg_est2026, avgPrev) : null, direction: "up",205      spark: ta.map((a) => ({ t: String(a.year), v: Math.round(a.total / a.n) })),206    },207    { id: "valeur_mediane", label: "Valeur médiane (2026)", value: money(s.valeur_mediane_2026) },208    { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites },209    { id: "logements", label: "Logements au rôle", value: u.logements },210    {211      id: "ventes_12m", label: "Ventes réelles — 12 derniers mois", value: sum(last12, "n"),212      delta_pct: delta(sum(last12, "n"), sum(prev12, "n")),213      direction: sum(last12, "n") >= sum(prev12, "n") ? "up" : "down",214      spark: m.slice(-24).map((x) => ({ t: x.m, v: x.n })),215      help: "Corpus de ventes publiées — 12 derniers mois vs les 12 mois précédents.",216    },217    {218      id: "volume_12m", label: "Volume des ventes — 12 derniers mois", value: compact(sum(last12, "total")),219      delta_pct: delta(sum(last12, "total"), sum(prev12, "total")),220      direction: sum(last12, "total") >= sum(prev12, "total") ? "up" : "down",221      spark: m.slice(-24).map((x) => ({ t: x.m, v: x.total })),222    },223    {224      id: "prix_vente_median", label: `Prix de vente médian (${moisLabel(lastMonth.m)})`, value: money(lastMonth.median),225      delta_pct: sameMonthN1 ? delta(lastMonth.median, sameMonthN1.median) : null,226      direction: sameMonthN1 && lastMonth.median >= sameMonthN1.median ? "up" : "down",227      spark: m.slice(-24).map((x) => ({ t: x.m, v: x.median })),228      help: "Médiane des ventes du dernier mois complet du corpus — variation vs le même mois un an plus tôt.",229    },230    {231      id: "transactions", label: "Ventes réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS,232      help: "Toutes les ventes publiées intégrées au modèle de comparables.",233    },234  ];235236  const gauges = [237    {238      id: "geoloc", label: "Unités géolocalisées", value: gpct(u.geoloc, u.total), max: 100, unit: "%",239      help: "Unités du rôle avec coordonnées lat/lng valides.",240    },241    {242      id: "role_connu", label: "Valeur au rôle connue", value: gpct(u.with_role, u.total), max: 100, unit: "%",243      help: "Unités avec évaluation municipale renseignée.",244    },245    {246      id: "annee_connue", label: "Année de construction connue", value: gpct(u.with_year, u.total), max: 100, unit: "%",247    },248    {249      id: "aire_connue", label: "Superficie de plancher connue", value: gpct(u.with_area, u.total), max: 100, unit: "%",250    },251  ];252253  const series: KaSerie[] = [254    {255      id: "valeur_millesime", title: "Valeur provinciale par millésime (2021-2026)", unit: "$",256      kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.total })),257    },258    {259      id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités",260      kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.n })),261    },262    {263      id: "ventes_mois", title: "Ventes réelles par mois (corpus 2021-2026)", unit: "ventes",264      kind: "bar", points: m.map((x) => ({ t: x.m, v: x.n })),265    },266    {267      id: "volume_mois", title: "Volume mensuel des ventes ($)", unit: "$",268      kind: "area", points: m.map((x) => ({ t: x.m, v: x.total })),269    },270    {271      id: "prix_median_mois", title: "Prix de vente médian par mois", unit: "$",272      kind: "line", points: m.map((x) => ({ t: x.m, v: x.median })),273    },274  ];275276  const multiseries = [277    {278      id: "indice_marche", title: "Indice de marché par type (base 1,0)", unit: "",279      series: V.market.idx_by_type,280    },281    {282      id: "ppm2", title: "Prix au m² des ventes, par type", unit: "$/m²",283      series: V.market.ppm2_by_type,284    },285  ];286287  const stacked = [288    {289      id: "ventes_type", title: "Ventes mensuelles par type de propriété", unit: "ventes",290      keys: V.tx.monthly_by_type.keys, points: V.tx.monthly_by_type.points,291    },292  ];293294  const breakdowns = [295    {296      id: "types", title: "Répartition de la valeur par type de bien", kind: "donut" as const,297      items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })),298    },299    {300      id: "types_n", title: "Unités par type de bien", kind: "bars" as const,301      items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })),302    },303    {304      id: "construction", title: "Valeur par tranche d'année de construction", kind: "bars" as const,305      items: V.construction.map((c) => ({ label: c.label, value: c.total })),306    },307  ];308309  const distributions = [310    {311      id: "valeurs", title: "Distribution des valeurs estimées (2026)", unit: "unités",312      bins: V.valeur_bins,313    },314    {315      id: "montants_vente", title: "Distribution des montants de vente (2021-2026)", unit: "ventes",316      bins: V.tx.amount_bins,317    },318  ];319320  const geo = {321    title: "Top municipalités par valeur totale estimée",322    items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })),323  };324325  const heatmap = {326    title: `Ventes réelles par jour (corpus, jusqu'au ${V.tx.max_date})`,327    cells: V.tx.daily.map((d) => ({ date: d.date, value: d.n })),328  };329330  const topMediane = [...s.par_ville]331    .filter((v) => v.mediane != null)332    .sort((a, b) => (b.mediane ?? 0) - (a.mediane ?? 0))333    .slice(0, 20);334335  const idxEnsemble = V.market.idx_by_type.find((x) => x.label === "Ensemble");336  const ppm2 = (label: string, t: string) =>337    V.market.ppm2_by_type.find((x) => x.label === label)?.points.find((p) => p.t === t)?.v ?? null;338  const last12Months = (idxEnsemble?.points ?? []).slice(-12);339340  const tables = [341    {342      id: "top_municipalites",343      title: "Palmarès des 200 plus grandes municipalités (millésime 2026)",344      columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"],345      rows: s.par_ville.map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]),346    },347    {348      id: "top_medianes",349      title: "Top 20 municipalités par valeur médiane (parmi le top 200)",350      columns: ["Rang", "Municipalité", "Valeur médiane ($)", "Propriétés", "Valeur totale ($)"],351      rows: topMediane.map((v, i) => [i + 1, v.ville, v.mediane, v.n, v.total] as (string | number | null)[]),352    },353    {354      id: "par_type",355      title: "Répartition par type de propriété",356      columns: ["Type", "Unités", "Valeur totale ($)", "Valeur médiane ($)"],357      rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, t.n, t.total, t.mediane] as (string | number | null)[]),358    },359    {360      id: "construction",361      title: "Parc par tranche d'année de construction",362      columns: ["Tranche", "Unités", "Valeur totale ($)", "Valeur moyenne ($)"],363      rows: V.construction.map((c) => [c.label, c.n, c.total, c.moyenne] as (string | number | null)[]),364    },365    {366      id: "indice_marche",367      title: "Indice de marché — 12 derniers mois",368      columns: ["Mois", "Indice (ensemble)", "$/m² unifamiliale", "$/m² condo", "$/m² plex"],369      rows: last12Months.map((p) => [370        p.t, p.v, ppm2("Unifamiliale", p.t), ppm2("Condo", p.t), ppm2("Plex", p.t),371      ] as (string | number | null)[]),372    },373    {374      id: "top_villes_ventes",375      title: "Top 20 villes par nombre de ventes (corpus 2021-2026)",376      columns: ["Rang", "Ville", "Ventes", "Montant moyen ($)"],377      rows: V.tx.top_villes.map((v, i) => [i + 1, v.city, v.n, v.moyenne] as (string | number | null)[]),378    },379  ];380381  return {382    updated: V.generated,383    period: {384      from: null as string | null,385      to: null as string | null,386      label: PERIOD_LABEL,387      applicable: false,388      note:389        "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " +390        "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " +391        "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.",392    },393    kpis,394    gauges,395    series,396    multiseries,397    stacked,398    breakdowns,399    distributions,400    geo,401    heatmap,402    tables,403    records: buildRecords(s),404  };405}406407export type StatsDashboard = ReturnType<typeof buildDashboard>;408409/** Cache mémoire : les données sont des imports statiques, on ne construit qu'une fois. */410let cache: StatsDashboard | null = null;411export function getDashboard(): StatsDashboard {412  if (!cache) cache = buildDashboard();413  return cache;414}415