SPB Git

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