SPB Git

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%
8.8 KB · 280 lines typescript
Raw Blame History
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