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/**3 * Moteur d'estimation Vrai-Prix — transparent par conception.4 *5 * Deux sources combinées :6 * 1. Modèle hédonique (LightGBM, entraîné sur ~690 k ventes QC 2021-2026,7 * pré-calculé pour chaque unité d'évaluation) — colonne est_2026 + P10/P90.8 * 2. Comparables : ventes réelles proches, ajustées (marché, superficie, âge),9 * médiane pondérée (distance, récence, similarité).10 *11 * Chaque ajustement est retourné en dollars pour affichage — aucune boîte noire.12 */1314export interface Subject {15 lat: number;16 lng: number;17 typeProp: string; // unifamilial | plex | condo_ou_multi | chalet | maison_mobile | terrain | autre18 floorArea?: number | null;19 yearBuilt?: number | null;20 landArea?: number | null;21 modelEstimate?: number | null;22 modelP10?: number | null;23 modelP90?: number | null;24}2526export interface CompInput {27 id: string;28 date: string; // YYYY-MM-DD29 amount: number;30 lat: number;31 lng: number;32 propertyType: string | null;33 yearBuilt: number | null;34 floorArea: number | null;35 street: string | null;36 city: string | null;37}3839export interface AdjustedComp extends CompInput {40 distanceM: number;41 monthsAgo: number;42 adjTime: number;43 adjArea: number;44 adjAge: number;45 adjustedPrice: number;46 weight: number;47}4849export interface MarketIndexPoint {50 month: string; // YYYY-MM51 idx: number; // 1.0 = niveau actuel52}5354export interface EstimateResult {55 estimate: number;56 low: number;57 high: number;58 confidencePct: number;59 confidenceLevel: "A" | "B" | "C" | "D";60 modelEstimate: number | null;61 compsEstimate: number | null;62 modelWeight: number;63 comps: AdjustedComp[];64 nCompsUsed: number;65 compsDispersionPct: number | null;66}6768/** Correspondance type d'unité (rôle) -> types de transactions comparables. */69export const TYPE_MATCH: Record<string, string[]> = {70 unifamilial: ["unifamilial"],71 plex: ["plex"],72 condo_ou_multi: ["condo", "plex"],73 chalet: ["unifamilial", "indéterminé"],74 maison_mobile: ["unifamilial", "indéterminé"],75 terrain: ["indéterminé"],76 autre: ["unifamilial", "condo", "plex", "indéterminé"],77};7879export function haversineM(80 lat1: number,81 lng1: number,82 lat2: number,83 lng2: number84): number {85 const R = 6371000;86 const toRad = (d: number) => (d * Math.PI) / 180;87 const dLat = toRad(lat2 - lat1);88 const dLng = toRad(lng2 - lng1);89 const a =90 Math.sin(dLat / 2) ** 2 +91 Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;92 return 2 * R * Math.asin(Math.sqrt(a));93}9495export function monthsBetween(fromISO: string, toISO: string): number {96 const a = new Date(fromISO);97 const b = new Date(toISO);98 return (b.getTime() - a.getTime()) / (1000 * 3600 * 24 * 30.44);99}100101/** Facteur marché entre le mois de vente et aujourd'hui (idx courant = dernier). */102export function timeFactor(103 saleMonth: string,104 index: MarketIndexPoint[]105): number {106 if (index.length === 0) return 1;107 const last = index[index.length - 1].idx;108 let saleIdx: number | null = null;109 for (const p of index) {110 if (p.month <= saleMonth) saleIdx = p.idx;111 }112 if (saleIdx === null) saleIdx = index[0].idx;113 if (saleIdx <= 0) return 1;114 return last / saleIdx;115}116117/** Ajustement superficie : 50 % du $/m² marginal du comparable. */118export function areaAdjustment(119 subjectArea: number | null | undefined,120 comp: { floorArea: number | null; amount: number }121): number {122 if (!subjectArea || !comp.floorArea || comp.floorArea <= 0) return 0;123 const ppm2 = comp.amount / comp.floorArea;124 const adj = (subjectArea - comp.floorArea) * 0.5 * ppm2;125 // borné à ±25 % du prix du comparable126 const cap = 0.25 * comp.amount;127 return Math.max(-cap, Math.min(cap, adj));128}129130/** Ajustement âge : 0,5 %/an d'écart, borné à ±10 %. */131export function ageAdjustment(132 subjectYear: number | null | undefined,133 comp: { yearBuilt: number | null; amount: number }134): number {135 if (!subjectYear || !comp.yearBuilt) return 0;136 const pct = Math.max(-0.1, Math.min(0.1, (subjectYear - comp.yearBuilt) * 0.005));137 return pct * comp.amount;138}139140export function weightedMedian(values: number[], weights: number[]): number {141 const order = values142 .map((v, i) => ({ v, w: weights[i] }))143 .sort((a, b) => a.v - b.v);144 const total = order.reduce((s, o) => s + o.w, 0);145 if (total <= 0) return NaN;146 let acc = 0;147 for (const o of order) {148 acc += o.w;149 if (acc >= total / 2) return o.v;150 }151 return order[order.length - 1].v;152}153154export function adjustComps(155 subject: Subject,156 candidates: CompInput[],157 index: MarketIndexPoint[],158 nowISO: string159): AdjustedComp[] {160 const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre;161 let pool = candidates.filter(162 (c) => c.propertyType !== null && types.includes(c.propertyType)163 );164 if (pool.length < 6) pool = candidates; // relâche le filtre de type si marché mince165166 // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu167 if (subject.floorArea) {168 for (const tol of [0.2, 0.4]) {169 const filtered = pool.filter(170 (c) =>171 c.floorArea != null &&172 Math.abs(c.floorArea - subject.floorArea!) <=173 tol * subject.floorArea!174 );175 if (filtered.length >= 6) {176 pool = filtered;177 break;178 }179 }180 }181182 const comps = pool.map((c) => {183 const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng);184 const monthsAgo = monthsBetween(c.date, nowISO);185 const tf = timeFactor(c.date.slice(0, 7), index);186 const adjTime = c.amount * (tf - 1);187 const adjArea = areaAdjustment(subject.floorArea, c);188 const adjAge = ageAdjustment(subject.yearBuilt, c);189 const adjustedPrice = c.amount + adjTime + adjArea + adjAge;190 const areaDiffPct =191 subject.floorArea && c.floorArea192 ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea193 : 0.15;194 const weight =195 Math.exp(-((distanceM / 1500) ** 2)) *196 Math.exp(-((monthsAgo / 24) ** 2)) *197 Math.exp(-((areaDiffPct / 0.25) ** 2));198 return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjustedPrice, weight };199 });200201 return comps202 .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4)203 .sort((a, b) => b.weight - a.weight)204 .slice(0, 12);205}206207function pctDispersion(comps: AdjustedComp[], center: number): number | null {208 if (comps.length < 3 || center <= 0) return null;209 const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center);210 dev.sort((a, b) => a - b);211 return dev[Math.floor(dev.length / 2)] * 100;212}213214export function estimate(215 subject: Subject,216 candidates: CompInput[],217 index: MarketIndexPoint[],218 nowISO: string219): EstimateResult {220 const comps = adjustComps(subject, candidates, index, nowISO);221 const compsEstimate =222 comps.length >= 3223 ? weightedMedian(224 comps.map((c) => c.adjustedPrice),225 comps.map((c) => c.weight)226 )227 : null;228229 const model = subject.modelEstimate ?? null;230 let modelWeight = 0;231 let estimateValue: number;232 if (model !== null && compsEstimate !== null) {233 modelWeight = 0.65;234 estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate;235 } else if (model !== null) {236 modelWeight = 1;237 estimateValue = model;238 } else if (compsEstimate !== null) {239 estimateValue = compsEstimate;240 } else {241 estimateValue = NaN;242 }243244 // fourchette : intervalle du modèle recentré, sinon dispersion des comparables245 let low: number, high: number;246 if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) {247 low = estimateValue * (subject.modelP10 / model);248 high = estimateValue * (subject.modelP90 / model);249 } else {250 const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25;251 low = estimateValue * (1 - disp / 100);252 high = estimateValue * (1 + disp / 100);253 }254255 const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue);256 // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle257 let score = 35;258 score += Math.min(25, comps.length * 2.5);259 if (dispersion !== null) score += Math.max(0, 20 - dispersion);260 if (model !== null) score += 15;261 const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100;262 score -= Math.max(0, (widthPct - 30) / 3);263 score = Math.max(5, Math.min(97, score));264 const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D";265266 return {267 estimate: Math.round(estimateValue / 100) * 100,268 low: Math.round(low / 100) * 100,269 high: Math.round(high / 100) * 100,270 confidencePct: Math.round(score),271 confidenceLevel: level,272 modelEstimate: model,273 compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null,274 modelWeight,275 comps,276 nCompsUsed: comps.length,277 compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null,278 };279}280