SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%
8.9 KB · 305 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  portesAdresses: string[];33}3435export interface UnitEstimate {36  unit: {37    id: string;38    adresse: string | null;39    municipalite: string | null;40    typeProp: string;41    cubfLibelle: string | null;42    anneeConstruction: number | null;43    aireEtagesM2: number | null;44    superficieTerrainM2: number | null;45    nbLogements: number | null;46    valeurRole: number | null;47    lat: number;48    lng: number;49    history: { year: number; value: number | null }[];50    specs: UnitSpecs;51  } | null;52  result: EstimateResult;53}5455/** Type de comparable attendu pour le type de tx du marché de l'indice. */56export function indexType(): string {57  return "plex"; // ValoPlex : un seul segment de marché58}5960function toComps(rows: TxRow[]): CompInput[] {61  return rows.map((r) => ({62    id: r.id,63    date: r.date,64    amount: r.amount,65    lat: r.lat,66    lng: r.lng,67    propertyType: r.property_type,68    doors: r.portes,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: "plex",102    doors: u.nb_logements,103    floorArea: u.aire_etages_m2,104    yearBuilt: u.annee_construction,105    landArea: u.superficie_terrain_m2,106    modelEstimate: u.est_2026,107    modelP10: u.p10,108    modelP90: u.p90,109  };110  const result = estimate(111    subject,112    toComps(candidatesAround(u.lat, u.lng)),113    getMarketIndex(indexType()),114    nowISO()115  );116  return {117    unit: {118      id: u.id_provinc,119      adresse: u.adresse,120      municipalite: u.municipalite,121      typeProp: u.type_prop,122      cubfLibelle: u.cubf_libelle,123      anneeConstruction: u.annee_construction,124      aireEtagesM2: u.aire_etages_m2,125      superficieTerrainM2: u.superficie_terrain_m2,126      nbLogements: u.nb_logements,127      valeurRole: u.valeur_role,128      lat: u.lat,129      lng: u.lng,130      history: ([2021, 2022, 2023, 2024, 2025, 2026] as const).map((y) => ({131        year: y,132        value: u[`est_${y}` as keyof UnitRow] as number | null,133      })),134      specs: {135        cubf: u.cubf,136        cubfLibelle: u.cubf_libelle,137        anneeEstimee: u.annee_estimee,138        frontTerrainM: u.front_terrain_m,139        nbEtages: u.nb_etages,140        nbLocauxNonResid: u.nb_locaux_non_resid,141        nbChambresLocatives: u.nb_chambres_locatives,142        lienPhysique: u.lien_physique,143        genreConstruction: u.genre_construction,144        matricule: u.matricule,145        uniteVoisinage: u.unite_voisinage,146        nAdresses: u.n_adresses,147        datCondMarche: u.dat_cond_marche,148        valeurTerrain: u.valeur_terrain,149        valeurBatiment: u.valeur_batiment,150        valeurAnterieure: u.valeur_anterieure,151        arrond: u.arrond,152        apt: u.apt,153        portesAdresses: (() => {154          try {155            return u.adresses_json ? (JSON.parse(u.adresses_json) as string[]) : [];156          } catch {157            return [];158          }159        })(),160      },161    },162    result,163  };164}165166export interface PortfolioAggregates {167  count: number;168  totalEstimate: number;169  totalLow: number;170  totalHigh: number;171  totalRole: number;172  ecartRolePct: number | null;173  confidencePct: number; // moyenne pondérée par valeur174  confidenceLevel: "A" | "B" | "C" | "D";175  totalFloorArea: number;176  totalLandArea: number;177  totalDwellings: number;178  avgYearBuilt: number | null;179  municipalities: { name: string; count: number; total: number }[];180  types: { type: string; count: number; total: number }[];181  history: { year: number; total: number; nCovered: number }[];182  growthPct: number | null; // croissance du parc 2021→2026 (unités couvertes)183}184185export interface PortfolioResult {186  items: UnitEstimate[];187  aggregates: PortfolioAggregates;188}189190export function estimatePortfolio(ids: string[]): PortfolioResult {191  const items = ids192    .slice(0, 40)193    .map((id) => estimateByUnitId(id))194    .filter((x): x is UnitEstimate => x !== null && x.unit !== null);195196  const val = (n: number | null | undefined) => n ?? 0;197  const totalEstimate = items.reduce((s, i) => s + val(i.result.estimate), 0);198  const totalRole = items.reduce((s, i) => s + val(i.unit!.valeurRole), 0);199  const wConf =200    totalEstimate > 0201      ? items.reduce((s, i) => s + i.result.confidencePct * val(i.result.estimate), 0) /202        totalEstimate203      : 0;204  const level = wConf >= 75 ? "A" : wConf >= 60 ? "B" : wConf >= 45 ? "C" : "D";205206  const byKey = (key: (i: UnitEstimate) => string) => {207    const m = new Map<string, { count: number; total: number }>();208    for (const i of items) {209      const k = key(i);210      const e = m.get(k) ?? { count: 0, total: 0 };211      e.count += 1;212      e.total += val(i.result.estimate);213      m.set(k, e);214    }215    return [...m.entries()]216      .map(([name, v]) => ({ name, ...v }))217      .sort((a, b) => b.total - a.total);218  };219220  const years = [2021, 2022, 2023, 2024, 2025, 2026] as const;221  const history = years.map((y) => {222    let total = 0;223    let n = 0;224    for (const i of items) {225      const h = i.unit!.history.find((hh) => hh.year === y);226      if (h?.value != null) {227        total += h.value;228        n += 1;229      }230    }231    return { year: y, total, nCovered: n };232  });233  // croissance : somme 2026 vs 2021 sur les unités couvertes aux deux bornes234  let g21 = 0;235  let g26 = 0;236  for (const i of items) {237    const a = i.unit!.history.find((h) => h.year === 2021)?.value;238    const b = i.unit!.history.find((h) => h.year === 2026)?.value;239    if (a != null && b != null) {240      g21 += a;241      g26 += b;242    }243  }244245  const yb = items246    .map((i) => i.unit!.anneeConstruction)247    .filter((v): v is number => v != null && v > 1600);248249  return {250    items,251    aggregates: {252      count: items.length,253      totalEstimate,254      totalLow: items.reduce((s, i) => s + val(i.result.low), 0),255      totalHigh: items.reduce((s, i) => s + val(i.result.high), 0),256      totalRole,257      ecartRolePct: totalRole > 0 ? (totalEstimate / totalRole - 1) * 100 : null,258      confidencePct: Math.round(wConf),259      confidenceLevel: level,260      totalFloorArea: Math.round(items.reduce((s, i) => s + val(i.unit!.aireEtagesM2), 0)),261      totalLandArea: Math.round(items.reduce((s, i) => s + val(i.unit!.superficieTerrainM2), 0)),262      totalDwellings: items.reduce((s, i) => s + val(i.unit!.nbLogements), 0),263      avgYearBuilt: yb.length ? Math.round(yb.reduce((s, v) => s + v, 0) / yb.length) : null,264      municipalities: byKey((i) => i.unit!.municipalite ?? "—"),265      types: byKey((i) => i.unit!.typeProp).map(({ name, ...v }) => ({ type: name, ...v })),266      history,267      growthPct: g21 > 0 ? (g26 / g21 - 1) * 100 : null,268    },269  };270}271272export interface ManualParams {273  municipality: string;274  typeProp: string;275  portes?: number;276  floorArea?: number;277  yearBuilt?: number;278  landArea?: number;279}280281export function estimateManual(p: ManualParams): UnitEstimate | null {282  const center = municipalityCenter(p.municipality);283  if (!center || !center.n) return null;284  const subject: Subject = {285    lat: center.lat,286    lng: center.lng,287    typeProp: "plex",288    doors: p.portes ?? null,289    floorArea: p.floorArea ?? null,290    yearBuilt: p.yearBuilt ?? null,291    landArea: p.landArea ?? null,292    modelEstimate: null,293    modelP10: null,294    modelP90: null,295  };296  const result = estimate(297    subject,298    toComps(candidatesAround(center.lat, center.lng)),299    getMarketIndex(indexType()),300    nowISO()301  );302  if (!Number.isFinite(result.estimate)) return null;303  return { unit: null, result };304}305