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%
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