SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
9.2 KB · 283 lines typescript
Raw Blame History
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