TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1// Copié du dépôt Vrai-Prix (gitsrv:srv/git/vrai-prix.git, src/lib/engine.ts, HEAD e4a1c0c) le 2026-10-17,2// avec l'accord du propriétaire (même auteur). Fonctions pures, inchangées : toute évolution se fait d'abord dans Vrai-Prix.3// Toveli affiche chaque ajustement en dollars (transparence) et ne présente jamais le résultat comme une évaluation agréée.4// Auteur : Simon-Pierre Boucher — contact@spboucher.ai5/**6 * Moteur d'estimation Vrai-Prix — transparent par conception.7 *8 * Deux sources combinées :9 * 1. Modèle hédonique (LightGBM, entraîné sur ~690 k ventes QC 2021-2026,10 * pré-calculé pour chaque unité d'évaluation) — colonne est_2026 + P10/P90.11 * 2. Comparables : ventes réelles proches, ajustées (marché, superficie, âge),12 * médiane pondérée (distance, récence, similarité).13 *14 * Chaque ajustement est retourné en dollars pour affichage — aucune boîte noire.15 */1617export interface Subject {18 lat: number;19 lng: number;20 typeProp: string; // unifamilial | plex | condo_ou_multi | chalet | maison_mobile | terrain | autre21 floorArea?: number | null;22 yearBuilt?: number | null;23 landArea?: number | null;24 modelEstimate?: number | null;25 modelP10?: number | null;26 modelP90?: number | null;27}2829export interface CompInput {30 id: string;31 date: string; // YYYY-MM-DD32 amount: number;33 lat: number;34 lng: number;35 propertyType: string | null;36 yearBuilt: number | null;37 floorArea: number | null;38 street: string | null;39 city: string | null;40}4142export interface AdjustedComp extends CompInput {43 distanceM: number;44 monthsAgo: number;45 adjTime: number;46 adjArea: number;47 adjAge: 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}142143export function weightedMedian(values: number[], weights: number[]): number {144 const order = values145 .map((v, i) => ({ v, w: weights[i] }))146 .sort((a, b) => a.v - b.v);147 const total = order.reduce((s, o) => s + o.w, 0);148 if (total <= 0) return NaN;149 let acc = 0;150 for (const o of order) {151 acc += o.w;152 if (acc >= total / 2) return o.v;153 }154 return order[order.length - 1].v;155}156157export function adjustComps(158 subject: Subject,159 candidates: CompInput[],160 index: MarketIndexPoint[],161 nowISO: string162): AdjustedComp[] {163 const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre;164 let pool = candidates.filter(165 (c) => c.propertyType !== null && types.includes(c.propertyType)166 );167 if (pool.length < 6) pool = candidates; // relâche le filtre de type si marché mince168169 // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu170 if (subject.floorArea) {171 for (const tol of [0.2, 0.4]) {172 const filtered = pool.filter(173 (c) =>174 c.floorArea != null &&175 Math.abs(c.floorArea - subject.floorArea!) <=176 tol * subject.floorArea!177 );178 if (filtered.length >= 6) {179 pool = filtered;180 break;181 }182 }183 }184185 const comps = pool.map((c) => {186 const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng);187 const monthsAgo = monthsBetween(c.date, nowISO);188 const tf = timeFactor(c.date.slice(0, 7), index);189 const adjTime = c.amount * (tf - 1);190 const adjArea = areaAdjustment(subject.floorArea, c);191 const adjAge = ageAdjustment(subject.yearBuilt, c);192 const adjustedPrice = c.amount + adjTime + adjArea + adjAge;193 const areaDiffPct =194 subject.floorArea && c.floorArea195 ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea196 : 0.15;197 const weight =198 Math.exp(-((distanceM / 1500) ** 2)) *199 Math.exp(-((monthsAgo / 24) ** 2)) *200 Math.exp(-((areaDiffPct / 0.25) ** 2));201 return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjustedPrice, weight };202 });203204 return comps205 .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4)206 .sort((a, b) => b.weight - a.weight)207 .slice(0, 12);208}209210function pctDispersion(comps: AdjustedComp[], center: number): number | null {211 if (comps.length < 3 || center <= 0) return null;212 const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center);213 dev.sort((a, b) => a - b);214 return dev[Math.floor(dev.length / 2)] * 100;215}216217export function estimate(218 subject: Subject,219 candidates: CompInput[],220 index: MarketIndexPoint[],221 nowISO: string222): EstimateResult {223 const comps = adjustComps(subject, candidates, index, nowISO);224 const compsEstimate =225 comps.length >= 3226 ? weightedMedian(227 comps.map((c) => c.adjustedPrice),228 comps.map((c) => c.weight)229 )230 : null;231232 const model = subject.modelEstimate ?? null;233 let modelWeight = 0;234 let estimateValue: number;235 if (model !== null && compsEstimate !== null) {236 modelWeight = 0.65;237 estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate;238 } else if (model !== null) {239 modelWeight = 1;240 estimateValue = model;241 } else if (compsEstimate !== null) {242 estimateValue = compsEstimate;243 } else {244 estimateValue = NaN;245 }246247 // fourchette : intervalle du modèle recentré, sinon dispersion des comparables248 let low: number, high: number;249 if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) {250 low = estimateValue * (subject.modelP10 / model);251 high = estimateValue * (subject.modelP90 / model);252 } else {253 const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25;254 low = estimateValue * (1 - disp / 100);255 high = estimateValue * (1 + disp / 100);256 }257258 const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue);259 // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle260 let score = 35;261 score += Math.min(25, comps.length * 2.5);262 if (dispersion !== null) score += Math.max(0, 20 - dispersion);263 if (model !== null) score += 15;264 const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100;265 score -= Math.max(0, (widthPct - 30) / 3);266 score = Math.max(5, Math.min(97, score));267 const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D";268269 return {270 estimate: Math.round(estimateValue / 100) * 100,271 low: Math.round(low / 100) * 100,272 high: Math.round(high / 100) * 100,273 confidencePct: Math.round(score),274 confidenceLevel: level,275 modelEstimate: model,276 compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null,277 modelWeight,278 comps,279 nCompsUsed: comps.length,280 compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null,281 };282}283