SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.1%
8.7 KB · 295 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Service d'estimation : relie la base SQLite au moteur (engine.ts).3import {4  getCandidates,5  getMarketIndex,6  getUnit,7  municipalityCenter,8  type TxRow,9  type UnitRow,10} from "./db";11import { estimate, type CompInput, type EstimateResult, type Subject } from "./engine";1213export interface UnitSpecs {14  cubf: number | null;15  cubfLibelle: string | null;16  anneeEstimee: string | null;17  frontTerrainM: number | null;18  nbEtages: number | null;19  nbLocauxNonResid: number | null;20  nbChambresLocatives: number | null;21  lienPhysique: string | null;22  genreConstruction: string | null;23  matricule: string | null;24  uniteVoisinage: string | null;25  nAdresses: number | null;26  datCondMarche: string | null;27  valeurTerrain: number | null;28  valeurBatiment: number | null;29  valeurAnterieure: number | null;30  arrond: string | null;31  apt: string | null;32}3334export interface UnitEstimate {35  unit: {36    id: string;37    adresse: string | null;38    municipalite: string | null;39    typeProp: string;40    cubfLibelle: string | null;41    anneeConstruction: number | null;42    aireEtagesM2: number | null;43    superficieTerrainM2: number | null;44    nbLogements: number | null;45    valeurRole: number | null;46    lat: number;47    lng: number;48    history: { year: number; value: number | null }[];49    specs: UnitSpecs;50  } | null;51  result: EstimateResult;52}5354/** Type de comparable attendu pour le type de tx du marché de l'indice. */55export function indexType(typeProp: string): string {56  if (typeProp === "condo_ou_multi") return "condo";57  if (typeProp === "plex") return "plex";58  return "unifamilial";59}6061function toComps(rows: TxRow[]): CompInput[] {62  return rows.map((r) => ({63    id: r.id,64    date: r.date,65    amount: r.amount,66    lat: r.lat,67    lng: r.lng,68    propertyType: r.property_type,69    yearBuilt: r.year_built,70    floorArea: r.floor_area,71    street: r.street,72    city: r.city,73  }));74}7576function nowISO(): string {77  return new Date().toISOString().slice(0, 10);78}7980function since(months: number): string {81  const d = new Date();82  d.setMonth(d.getMonth() - months);83  return d.toISOString().slice(0, 10);84}8586function candidatesAround(lat: number, lng: number): TxRow[] {87  // ~2,2 km puis élargit à ~11 km si marché mince88  for (const halfDeg of [0.02, 0.1]) {89    const rows = getCandidates(lat, lng, halfDeg, since(30), 600);90    if (rows.length >= 30) return rows;91  }92  return getCandidates(lat, lng, 0.25, since(36), 600);93}9495export function estimateByUnitId(id: string): UnitEstimate | null {96  const u = getUnit(id);97  if (!u) return null;98  const subject: Subject = {99    lat: u.lat,100    lng: u.lng,101    typeProp: u.type_prop,102    floorArea: u.aire_etages_m2,103    yearBuilt: u.annee_construction,104    landArea: u.superficie_terrain_m2,105    modelEstimate: u.est_2026,106    modelP10: u.p10,107    modelP90: u.p90,108  };109  const result = estimate(110    subject,111    toComps(candidatesAround(u.lat, u.lng)),112    getMarketIndex(indexType(u.type_prop)),113    nowISO()114  );115  return {116    unit: {117      id: u.id_provinc,118      adresse: u.adresse,119      municipalite: u.municipalite,120      typeProp: u.type_prop,121      cubfLibelle: u.cubf_libelle,122      anneeConstruction: u.annee_construction,123      aireEtagesM2: u.aire_etages_m2,124      superficieTerrainM2: u.superficie_terrain_m2,125      nbLogements: u.nb_logements,126      valeurRole: u.valeur_role,127      lat: u.lat,128      lng: u.lng,129      history: ([2021, 2022, 2023, 2024, 2025, 2026] as const).map((y) => ({130        year: y,131        value: u[`est_${y}` as keyof UnitRow] as number | null,132      })),133      specs: {134        cubf: u.cubf,135        cubfLibelle: u.cubf_libelle,136        anneeEstimee: u.annee_estimee,137        frontTerrainM: u.front_terrain_m,138        nbEtages: u.nb_etages,139        nbLocauxNonResid: u.nb_locaux_non_resid,140        nbChambresLocatives: u.nb_chambres_locatives,141        lienPhysique: u.lien_physique,142        genreConstruction: u.genre_construction,143        matricule: u.matricule,144        uniteVoisinage: u.unite_voisinage,145        nAdresses: u.n_adresses,146        datCondMarche: u.dat_cond_marche,147        valeurTerrain: u.valeur_terrain,148        valeurBatiment: u.valeur_batiment,149        valeurAnterieure: u.valeur_anterieure,150        arrond: u.arrond,151        apt: u.apt,152      },153    },154    result,155  };156}157158export interface PortfolioAggregates {159  count: number;160  totalEstimate: number;161  totalLow: number;162  totalHigh: number;163  totalRole: number;164  ecartRolePct: number | null;165  confidencePct: number; // moyenne pondérée par valeur166  confidenceLevel: "A" | "B" | "C" | "D";167  totalFloorArea: number;168  totalLandArea: number;169  totalDwellings: number;170  avgYearBuilt: number | null;171  municipalities: { name: string; count: number; total: number }[];172  types: { type: string; count: number; total: number }[];173  history: { year: number; total: number; nCovered: number }[];174  growthPct: number | null; // croissance du parc 2021→2026 (unités couvertes)175}176177export interface PortfolioResult {178  items: UnitEstimate[];179  aggregates: PortfolioAggregates;180}181182export function estimatePortfolio(ids: string[]): PortfolioResult {183  const items = ids184    .slice(0, 40)185    .map((id) => estimateByUnitId(id))186    .filter((x): x is UnitEstimate => x !== null && x.unit !== null);187188  const val = (n: number | null | undefined) => n ?? 0;189  const totalEstimate = items.reduce((s, i) => s + val(i.result.estimate), 0);190  const totalRole = items.reduce((s, i) => s + val(i.unit!.valeurRole), 0);191  const wConf =192    totalEstimate > 0193      ? items.reduce((s, i) => s + i.result.confidencePct * val(i.result.estimate), 0) /194        totalEstimate195      : 0;196  const level = wConf >= 75 ? "A" : wConf >= 60 ? "B" : wConf >= 45 ? "C" : "D";197198  const byKey = (key: (i: UnitEstimate) => string) => {199    const m = new Map<string, { count: number; total: number }>();200    for (const i of items) {201      const k = key(i);202      const e = m.get(k) ?? { count: 0, total: 0 };203      e.count += 1;204      e.total += val(i.result.estimate);205      m.set(k, e);206    }207    return [...m.entries()]208      .map(([name, v]) => ({ name, ...v }))209      .sort((a, b) => b.total - a.total);210  };211212  const years = [2021, 2022, 2023, 2024, 2025, 2026] as const;213  const history = years.map((y) => {214    let total = 0;215    let n = 0;216    for (const i of items) {217      const h = i.unit!.history.find((hh) => hh.year === y);218      if (h?.value != null) {219        total += h.value;220        n += 1;221      }222    }223    return { year: y, total, nCovered: n };224  });225  // croissance : somme 2026 vs 2021 sur les unités couvertes aux deux bornes226  let g21 = 0;227  let g26 = 0;228  for (const i of items) {229    const a = i.unit!.history.find((h) => h.year === 2021)?.value;230    const b = i.unit!.history.find((h) => h.year === 2026)?.value;231    if (a != null && b != null) {232      g21 += a;233      g26 += b;234    }235  }236237  const yb = items238    .map((i) => i.unit!.anneeConstruction)239    .filter((v): v is number => v != null && v > 1600);240241  return {242    items,243    aggregates: {244      count: items.length,245      totalEstimate,246      totalLow: items.reduce((s, i) => s + val(i.result.low), 0),247      totalHigh: items.reduce((s, i) => s + val(i.result.high), 0),248      totalRole,249      ecartRolePct: totalRole > 0 ? (totalEstimate / totalRole - 1) * 100 : null,250      confidencePct: Math.round(wConf),251      confidenceLevel: level,252      totalFloorArea: Math.round(items.reduce((s, i) => s + val(i.unit!.aireEtagesM2), 0)),253      totalLandArea: Math.round(items.reduce((s, i) => s + val(i.unit!.superficieTerrainM2), 0)),254      totalDwellings: items.reduce((s, i) => s + val(i.unit!.nbLogements), 0),255      avgYearBuilt: yb.length ? Math.round(yb.reduce((s, v) => s + v, 0) / yb.length) : null,256      municipalities: byKey((i) => i.unit!.municipalite ?? "—"),257      types: byKey((i) => i.unit!.typeProp).map(({ name, ...v }) => ({ type: name, ...v })),258      history,259      growthPct: g21 > 0 ? (g26 / g21 - 1) * 100 : null,260    },261  };262}263264export interface ManualParams {265  municipality: string;266  typeProp: string;267  floorArea?: number;268  yearBuilt?: number;269  landArea?: number;270}271272export function estimateManual(p: ManualParams): UnitEstimate | null {273  const center = municipalityCenter(p.municipality);274  if (!center || !center.n) return null;275  const subject: Subject = {276    lat: center.lat,277    lng: center.lng,278    typeProp: p.typeProp,279    floorArea: p.floorArea ?? null,280    yearBuilt: p.yearBuilt ?? null,281    landArea: p.landArea ?? null,282    modelEstimate: null,283    modelP10: null,284    modelP90: null,285  };286  const result = estimate(287    subject,288    toComps(candidatesAround(center.lat, center.lng)),289    getMarketIndex(indexType(p.typeProp)),290    nowISO()291  );292  if (!Number.isFinite(result.estimate)) return null;293  return { unit: null, result };294}295