// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Moteur d'estimation ValoPlex — transparent par conception. * * Deux sources combinées : * 1. Modèle hédonique (LightGBM, entraîné sur ~690 k ventes QC 2021-2026, * pré-calculé pour chaque unité d'évaluation) — colonne est_2026 + P10/P90. * 2. Comparables : ventes réelles proches, ajustées (marché, superficie, âge), * médiane pondérée (distance, récence, similarité). * * Chaque ajustement est retourné en dollars pour affichage — aucune boîte noire. */ export interface Subject { lat: number; lng: number; typeProp: string; // toujours « plex » dans ValoPlex doors?: number | null; // nombre de logements (portes) floorArea?: number | null; yearBuilt?: number | null; landArea?: number | null; modelEstimate?: number | null; modelP10?: number | null; modelP90?: number | null; } export interface CompInput { id: string; date: string; // YYYY-MM-DD amount: number; lat: number; lng: number; propertyType: string | null; doors: number | null; yearBuilt: number | null; floorArea: number | null; street: string | null; city: string | null; } export interface AdjustedComp extends CompInput { distanceM: number; monthsAgo: number; adjTime: number; adjArea: number; adjAge: number; adjDoors: number; adjustedPrice: number; weight: number; } export interface MarketIndexPoint { month: string; // YYYY-MM idx: number; // 1.0 = niveau actuel } export interface EstimateResult { estimate: number; low: number; high: number; confidencePct: number; confidenceLevel: "A" | "B" | "C" | "D"; modelEstimate: number | null; compsEstimate: number | null; modelWeight: number; comps: AdjustedComp[]; nCompsUsed: number; compsDispersionPct: number | null; } /** Correspondance type d'unité (rôle) -> types de transactions comparables. */ export const TYPE_MATCH: Record = { unifamilial: ["unifamilial"], plex: ["plex"], condo_ou_multi: ["condo", "plex"], chalet: ["unifamilial", "indéterminé"], maison_mobile: ["unifamilial", "indéterminé"], terrain: ["indéterminé"], autre: ["unifamilial", "condo", "plex", "indéterminé"], }; export function haversineM( lat1: number, lng1: number, lat2: number, lng2: number ): number { const R = 6371000; const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } export function monthsBetween(fromISO: string, toISO: string): number { const a = new Date(fromISO); const b = new Date(toISO); return (b.getTime() - a.getTime()) / (1000 * 3600 * 24 * 30.44); } /** Facteur marché entre le mois de vente et aujourd'hui (idx courant = dernier). */ export function timeFactor( saleMonth: string, index: MarketIndexPoint[] ): number { if (index.length === 0) return 1; const last = index[index.length - 1].idx; let saleIdx: number | null = null; for (const p of index) { if (p.month <= saleMonth) saleIdx = p.idx; } if (saleIdx === null) saleIdx = index[0].idx; if (saleIdx <= 0) return 1; return last / saleIdx; } /** Ajustement superficie : 50 % du $/m² marginal du comparable. */ export function areaAdjustment( subjectArea: number | null | undefined, comp: { floorArea: number | null; amount: number } ): number { if (!subjectArea || !comp.floorArea || comp.floorArea <= 0) return 0; const ppm2 = comp.amount / comp.floorArea; const adj = (subjectArea - comp.floorArea) * 0.5 * ppm2; // borné à ±25 % du prix du comparable const cap = 0.25 * comp.amount; return Math.max(-cap, Math.min(cap, adj)); } /** Ajustement âge : 0,5 %/an d'écart, borné à ±10 %. */ export function ageAdjustment( subjectYear: number | null | undefined, comp: { yearBuilt: number | null; amount: number } ): number { if (!subjectYear || !comp.yearBuilt) return 0; const pct = Math.max(-0.1, Math.min(0.1, (subjectYear - comp.yearBuilt) * 0.005)); return pct * comp.amount; } /** Ajustement portes (plex) : 50 % du prix par porte du comparable, borné à ±30 %. */ export function doorsAdjustment( subjectDoors: number | null | undefined, comp: { doors: number | null; amount: number } ): number { if (!subjectDoors || !comp.doors || comp.doors <= 0) return 0; const perDoor = comp.amount / comp.doors; const adj = (subjectDoors - comp.doors) * 0.5 * perDoor; const cap = 0.3 * comp.amount; return Math.max(-cap, Math.min(cap, adj)); } export function weightedMedian(values: number[], weights: number[]): number { const order = values .map((v, i) => ({ v, w: weights[i] })) .sort((a, b) => a.v - b.v); const total = order.reduce((s, o) => s + o.w, 0); if (total <= 0) return NaN; let acc = 0; for (const o of order) { acc += o.w; if (acc >= total / 2) return o.v; } return order[order.length - 1].v; } export function adjustComps( subject: Subject, candidates: CompInput[], index: MarketIndexPoint[], nowISO: string ): AdjustedComp[] { // tous les candidats sont des plex ; on filtre par similarité de portes let pool = candidates; if (subject.doors) { for (const tol of [1, 3]) { const filtered = candidates.filter( (c) => c.doors != null && Math.abs(c.doors - subject.doors!) <= tol ); if (filtered.length >= 6) { pool = filtered; break; } } } // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu if (subject.floorArea) { for (const tol of [0.2, 0.4]) { const filtered = pool.filter( (c) => c.floorArea != null && Math.abs(c.floorArea - subject.floorArea!) <= tol * subject.floorArea! ); if (filtered.length >= 6) { pool = filtered; break; } } } const comps = pool.map((c) => { const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng); const monthsAgo = monthsBetween(c.date, nowISO); const tf = timeFactor(c.date.slice(0, 7), index); const adjTime = c.amount * (tf - 1); const adjArea = areaAdjustment(subject.floorArea, c); const adjAge = ageAdjustment(subject.yearBuilt, c); const adjDoors = doorsAdjustment(subject.doors, c); const adjustedPrice = c.amount + adjTime + adjArea + adjAge + adjDoors; const areaDiffPct = subject.floorArea && c.floorArea ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea : 0.15; const doorsDiff = subject.doors && c.doors ? Math.abs(c.doors - subject.doors) : 0.5; const weight = Math.exp(-((distanceM / 1500) ** 2)) * Math.exp(-((monthsAgo / 24) ** 2)) * Math.exp(-((areaDiffPct / 0.25) ** 2)) * Math.exp(-((doorsDiff / 2.5) ** 2)); return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjDoors, adjustedPrice, weight }; }); return comps .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4) .sort((a, b) => b.weight - a.weight) .slice(0, 12); } function pctDispersion(comps: AdjustedComp[], center: number): number | null { if (comps.length < 3 || center <= 0) return null; const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center); dev.sort((a, b) => a - b); return dev[Math.floor(dev.length / 2)] * 100; } export function estimate( subject: Subject, candidates: CompInput[], index: MarketIndexPoint[], nowISO: string ): EstimateResult { const comps = adjustComps(subject, candidates, index, nowISO); const compsEstimate = comps.length >= 3 ? weightedMedian( comps.map((c) => c.adjustedPrice), comps.map((c) => c.weight) ) : null; const model = subject.modelEstimate ?? null; let modelWeight = 0; let estimateValue: number; if (model !== null && compsEstimate !== null) { // pondération dynamique : plus les comparables s'éloignent du sujet en // nombre de portes, plus le modèle spécialisé pèse lourd const doorGap = subject.doors && comps.length ? comps.reduce((a, c) => a + Math.abs((c.doors ?? subject.doors!) - subject.doors!), 0) / comps.length : 0; modelWeight = doorGap > 3 ? 0.85 : doorGap > 1.5 ? 0.75 : 0.65; estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate; } else if (model !== null) { modelWeight = 1; estimateValue = model; } else if (compsEstimate !== null) { estimateValue = compsEstimate; } else { estimateValue = NaN; } // fourchette : intervalle du modèle recentré, sinon dispersion des comparables let low: number, high: number; if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) { low = estimateValue * (subject.modelP10 / model); high = estimateValue * (subject.modelP90 / model); } else { const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25; low = estimateValue * (1 - disp / 100); high = estimateValue * (1 + disp / 100); } const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue); // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle let score = 35; score += Math.min(25, comps.length * 2.5); if (dispersion !== null) score += Math.max(0, 20 - dispersion); if (model !== null) score += 15; const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100; score -= Math.max(0, (widthPct - 30) / 3); score = Math.max(5, Math.min(97, score)); const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D"; return { estimate: Math.round(estimateValue / 100) * 100, low: Math.round(low / 100) * 100, high: Math.round(high / 100) * 100, confidencePct: Math.round(score), confidenceLevel: level, modelEstimate: model, compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null, modelWeight, comps, nCompsUsed: comps.length, compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null, }; }