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%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Moteur d'estimation ValoPlex — 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; // toujours « plex » dans ValoPlex18 doors?: number | null; // nombre de logements (portes)19 floorArea?: number | null;20 yearBuilt?: number | null;21 landArea?: number | null;22 modelEstimate?: number | null;23 modelP10?: number | null;24 modelP90?: number | null;25}2627export interface CompInput {28 id: string;29 date: string; // YYYY-MM-DD30 amount: number;31 lat: number;32 lng: number;33 propertyType: string | null;34 doors: number | null;35 yearBuilt: number | null;36 floorArea: number | null;37 street: string | null;38 city: string | null;39}4041export interface AdjustedComp extends CompInput {42 distanceM: number;43 monthsAgo: number;44 adjTime: number;45 adjArea: number;46 adjAge: number;47 adjDoors: number;48 adjustedPrice: number;49 weight: number;50}5152export interface MarketIndexPoint {53 month: string; // YYYY-MM54 idx: number; // 1.0 = niveau actuel55}5657export interface EstimateResult {58 estimate: number;59 low: number;60 high: number;61 confidencePct: number;62 confidenceLevel: "A" | "B" | "C" | "D";63 modelEstimate: number | null;64 compsEstimate: number | null;65 modelWeight: number;66 comps: AdjustedComp[];67 nCompsUsed: number;68 compsDispersionPct: number | null;69}7071/** Correspondance type d'unité (rôle) -> types de transactions comparables. */72export const TYPE_MATCH: Record<string, string[]> = {73 unifamilial: ["unifamilial"],74 plex: ["plex"],75 condo_ou_multi: ["condo", "plex"],76 chalet: ["unifamilial", "indéterminé"],77 maison_mobile: ["unifamilial", "indéterminé"],78 terrain: ["indéterminé"],79 autre: ["unifamilial", "condo", "plex", "indéterminé"],80};8182export function haversineM(83 lat1: number,84 lng1: number,85 lat2: number,86 lng2: number87): number {88 const R = 6371000;89 const toRad = (d: number) => (d * Math.PI) / 180;90 const dLat = toRad(lat2 - lat1);91 const dLng = toRad(lng2 - lng1);92 const a =93 Math.sin(dLat / 2) ** 2 +94 Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;95 return 2 * R * Math.asin(Math.sqrt(a));96}9798export function monthsBetween(fromISO: string, toISO: string): number {99 const a = new Date(fromISO);100 const b = new Date(toISO);101 return (b.getTime() - a.getTime()) / (1000 * 3600 * 24 * 30.44);102}103104/** Facteur marché entre le mois de vente et aujourd'hui (idx courant = dernier). */105export function timeFactor(106 saleMonth: string,107 index: MarketIndexPoint[]108): number {109 if (index.length === 0) return 1;110 const last = index[index.length - 1].idx;111 let saleIdx: number | null = null;112 for (const p of index) {113 if (p.month <= saleMonth) saleIdx = p.idx;114 }115 if (saleIdx === null) saleIdx = index[0].idx;116 if (saleIdx <= 0) return 1;117 return last / saleIdx;118}119120/** Ajustement superficie : 50 % du $/m² marginal du comparable. */121export function areaAdjustment(122 subjectArea: number | null | undefined,123 comp: { floorArea: number | null; amount: number }124): number {125 if (!subjectArea || !comp.floorArea || comp.floorArea <= 0) return 0;126 const ppm2 = comp.amount / comp.floorArea;127 const adj = (subjectArea - comp.floorArea) * 0.5 * ppm2;128 // borné à ±25 % du prix du comparable129 const cap = 0.25 * comp.amount;130 return Math.max(-cap, Math.min(cap, adj));131}132133/** Ajustement âge : 0,5 %/an d'écart, borné à ±10 %. */134export function ageAdjustment(135 subjectYear: number | null | undefined,136 comp: { yearBuilt: number | null; amount: number }137): number {138 if (!subjectYear || !comp.yearBuilt) return 0;139 const pct = Math.max(-0.1, Math.min(0.1, (subjectYear - comp.yearBuilt) * 0.005));140 return pct * comp.amount;141}142143/** Ajustement portes (plex) : 50 % du prix par porte du comparable, borné à ±30 %. */144export function doorsAdjustment(145 subjectDoors: number | null | undefined,146 comp: { doors: number | null; amount: number }147): number {148 if (!subjectDoors || !comp.doors || comp.doors <= 0) return 0;149 const perDoor = comp.amount / comp.doors;150 const adj = (subjectDoors - comp.doors) * 0.5 * perDoor;151 const cap = 0.3 * comp.amount;152 return Math.max(-cap, Math.min(cap, adj));153}154155export function weightedMedian(values: number[], weights: number[]): number {156 const order = values157 .map((v, i) => ({ v, w: weights[i] }))158 .sort((a, b) => a.v - b.v);159 const total = order.reduce((s, o) => s + o.w, 0);160 if (total <= 0) return NaN;161 let acc = 0;162 for (const o of order) {163 acc += o.w;164 if (acc >= total / 2) return o.v;165 }166 return order[order.length - 1].v;167}168169export function adjustComps(170 subject: Subject,171 candidates: CompInput[],172 index: MarketIndexPoint[],173 nowISO: string174): AdjustedComp[] {175 // tous les candidats sont des plex ; on filtre par similarité de portes176 let pool = candidates;177 if (subject.doors) {178 for (const tol of [1, 3]) {179 const filtered = candidates.filter(180 (c) => c.doors != null && Math.abs(c.doors - subject.doors!) <= tol181 );182 if (filtered.length >= 6) {183 pool = filtered;184 break;185 }186 }187 }188189 // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu190 if (subject.floorArea) {191 for (const tol of [0.2, 0.4]) {192 const filtered = pool.filter(193 (c) =>194 c.floorArea != null &&195 Math.abs(c.floorArea - subject.floorArea!) <=196 tol * subject.floorArea!197 );198 if (filtered.length >= 6) {199 pool = filtered;200 break;201 }202 }203 }204205 const comps = pool.map((c) => {206 const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng);207 const monthsAgo = monthsBetween(c.date, nowISO);208 const tf = timeFactor(c.date.slice(0, 7), index);209 const adjTime = c.amount * (tf - 1);210 const adjArea = areaAdjustment(subject.floorArea, c);211 const adjAge = ageAdjustment(subject.yearBuilt, c);212 const adjDoors = doorsAdjustment(subject.doors, c);213 const adjustedPrice = c.amount + adjTime + adjArea + adjAge + adjDoors;214 const areaDiffPct =215 subject.floorArea && c.floorArea216 ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea217 : 0.15;218 const doorsDiff =219 subject.doors && c.doors ? Math.abs(c.doors - subject.doors) : 0.5;220 const weight =221 Math.exp(-((distanceM / 1500) ** 2)) *222 Math.exp(-((monthsAgo / 24) ** 2)) *223 Math.exp(-((areaDiffPct / 0.25) ** 2)) *224 Math.exp(-((doorsDiff / 2.5) ** 2));225 return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjDoors, adjustedPrice, weight };226 });227228 return comps229 .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4)230 .sort((a, b) => b.weight - a.weight)231 .slice(0, 12);232}233234function pctDispersion(comps: AdjustedComp[], center: number): number | null {235 if (comps.length < 3 || center <= 0) return null;236 const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center);237 dev.sort((a, b) => a - b);238 return dev[Math.floor(dev.length / 2)] * 100;239}240241export function estimate(242 subject: Subject,243 candidates: CompInput[],244 index: MarketIndexPoint[],245 nowISO: string246): EstimateResult {247 const comps = adjustComps(subject, candidates, index, nowISO);248 const compsEstimate =249 comps.length >= 3250 ? weightedMedian(251 comps.map((c) => c.adjustedPrice),252 comps.map((c) => c.weight)253 )254 : null;255256 const model = subject.modelEstimate ?? null;257 let modelWeight = 0;258 let estimateValue: number;259 if (model !== null && compsEstimate !== null) {260 // pondération dynamique : plus les comparables s'éloignent du sujet en261 // nombre de portes, plus le modèle spécialisé pèse lourd262 const doorGap =263 subject.doors && comps.length264 ? comps.reduce((a, c) => a + Math.abs((c.doors ?? subject.doors!) - subject.doors!), 0) / comps.length265 : 0;266 modelWeight = doorGap > 3 ? 0.85 : doorGap > 1.5 ? 0.75 : 0.65;267 estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate;268 } else if (model !== null) {269 modelWeight = 1;270 estimateValue = model;271 } else if (compsEstimate !== null) {272 estimateValue = compsEstimate;273 } else {274 estimateValue = NaN;275 }276277 // fourchette : intervalle du modèle recentré, sinon dispersion des comparables278 let low: number, high: number;279 if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) {280 low = estimateValue * (subject.modelP10 / model);281 high = estimateValue * (subject.modelP90 / model);282 } else {283 const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25;284 low = estimateValue * (1 - disp / 100);285 high = estimateValue * (1 + disp / 100);286 }287288 const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue);289 // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle290 let score = 35;291 score += Math.min(25, comps.length * 2.5);292 if (dispersion !== null) score += Math.max(0, 20 - dispersion);293 if (model !== null) score += 15;294 const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100;295 score -= Math.max(0, (widthPct - 30) / 3);296 score = Math.max(5, Math.min(97, score));297 const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D";298299 return {300 estimate: Math.round(estimateValue / 100) * 100,301 low: Math.round(low / 100) * 100,302 high: Math.round(high / 100) * 100,303 confidencePct: Math.round(score),304 confidenceLevel: level,305 modelEstimate: model,306 compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null,307 modelWeight,308 comps,309 nCompsUsed: comps.length,310 compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null,311 };312}313