SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
8.9 KB · 272 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Atelier « mon évaluation » d'une propriété à vendre : construit le bassin de4 * comparables (ventes réelles autour du sujet, + annonces actives voisines) à5 * partir duquel l'utilisateur choisit, ajuste et réconcilie SA valeur — puis la6 * compare à la mesure Vrai-Prix déjà calculée (table vp_eval).7 *8 * Chaque candidat porte les ajustements du moteur (marché, superficie, âge) et9 * son poids gaussien, en dollars, pour servir de point de départ transparent.10 */11import { getCandidates, getMarketIndex, getUnit, type UnitRow } from "./db";12import {13  adjustComps,14  ageAdjustment,15  areaAdjustment,16  estimate,17  haversineM,18  monthsBetween,19  timeFactor,20  type CompInput,21  type Subject,22} from "./engine";23import { candidatesAround, indexType, toComps } from "./estimator";24import { getListing, groupToTypeProp, listingsNear, type ListingDetail } from "./immoka";2526export interface CompCandidate {27  id: string;28  kind: "sale" | "listing";29  date: string; // vente : date de l'acte ; annonce : aujourd'hui (prix demandé courant)30  amount: number;31  lat: number;32  lng: number;33  distanceM: number;34  monthsAgo: number;35  street: string | null;36  city: string | null;37  propertyType: string | null;38  yearBuilt: number | null;39  floorArea: number | null; // m²40  landArea: number | null; // m²41  valeurRole: number | null;42  adjTime: number;43  adjArea: number;44  adjAge: number;45  adjustedPrice: number;46  weight: number;47  engineUsed: boolean; // fait partie des 12 comparables retenus par le moteur48  idProvinc?: string | null;49  uid?: string; // annonce Immo-Ka50  image?: string | null;51}5253export interface CompsSubject {54  uid: string;55  address: string | null;56  city: string | null;57  lat: number;58  lng: number;59  typeProp: string;60  floorArea: number | null;61  yearBuilt: number | null;62  landArea: number | null;63  price: number;64  unitId: string | null;65  fromUnit: boolean; // caractéristiques lues au rôle (unité jumelée) vs à l'annonce66}6768export interface CompsPayload {69  subject: CompsSubject;70  engine: {71    estimate: number | null;72    low: number | null;73    high: number | null;74    modelEstimate: number | null;75    compsEstimate: number | null;76    modelWeight: number;77    nComps: number;78    compIds: string[];79  };80  comps: CompCandidate[];81  index: { month: string; idx: number }[];82  nowISO: string;83  params: { months: number; radiusKm: number; includeListings: boolean; limit: number };84}8586function since(months: number): string {87  const d = new Date();88  d.setMonth(d.getMonth() - months);89  return d.toISOString().slice(0, 10);90}9192const SQFT = 0.092903;9394/** Sujet du calcul : l'unité du rôle jumelée si disponible, sinon l'annonce. */95export function subjectFor(det: ListingDetail, unit: UnitRow | null): CompsSubject | null {96  const l = det.listing;97  if (unit) {98    return {99      uid: l.uid,100      address: l.address,101      city: l.city,102      lat: unit.lat,103      lng: unit.lng,104      typeProp: unit.type_prop,105      floorArea: unit.aire_etages_m2,106      yearBuilt: unit.annee_construction,107      landArea: unit.superficie_terrain_m2,108      price: l.price,109      unitId: unit.id_provinc,110      fromUnit: true,111    };112  }113  if (l.lat == null || l.lng == null) return null;114  const area = l.area_sqft && l.area_sqft > 100 && l.area_sqft < 20000 ? l.area_sqft * SQFT : null;115  return {116    uid: l.uid,117    address: l.address,118    city: l.city,119    lat: l.lat,120    lng: l.lng,121    typeProp: groupToTypeProp(det.group),122    floorArea: area,123    yearBuilt: l.year_built && l.year_built > 1600 ? l.year_built : null,124    landArea: l.lot_sqft && l.lot_sqft > 50 ? l.lot_sqft * SQFT : null,125    price: l.price,126    unitId: null,127    fromUnit: false,128  };129}130131function weightOf(distanceM: number, monthsAgo: number, subjectArea: number | null, compArea: number | null): number {132  const areaDiffPct = subjectArea && compArea ? Math.abs(compArea - subjectArea) / subjectArea : 0.15;133  return (134    Math.exp(-((distanceM / 1500) ** 2)) *135    Math.exp(-((monthsAgo / 24) ** 2)) *136    Math.exp(-((areaDiffPct / 0.25) ** 2))137  );138}139140export function buildComps(141  uid: string,142  opts: { months?: number; radiusKm?: number; includeListings?: boolean; limit?: number } = {}143): CompsPayload | null {144  const det = getListing(uid);145  if (!det) return null;146  const unit = det.eval?.unit_id ? (getUnit(det.eval.unit_id) ?? null) : null;147  const subj = subjectFor(det, unit);148  if (!subj) return null;149150  const months = Math.min(Math.max(opts.months ?? 24, 6), 60);151  const radiusKm = Math.min(Math.max(opts.radiusKm ?? 3, 0.5), 25);152  const includeListings = opts.includeListings ?? true;153  const limit = Math.min(Math.max(opts.limit ?? 60, 10), 150);154  const nowISO = new Date().toISOString().slice(0, 10);155  const index = getMarketIndex(indexType(subj.typeProp));156157  const subject: Subject = {158    lat: subj.lat,159    lng: subj.lng,160    typeProp: subj.typeProp,161    floorArea: subj.floorArea,162    yearBuilt: subj.yearBuilt,163    landArea: subj.landArea,164    modelEstimate: unit?.est_2026 ?? null,165    modelP10: unit?.p10 ?? null,166    modelP90: unit?.p90 ?? null,167  };168169  // ce que le moteur retient lui-même (mêmes règles que la fiche /estimation)170  const engineRes = estimate(subject, toComps(candidatesAround(subj.lat, subj.lng)), index, nowISO);171  const engineIds = new Set(engineRes.comps.map((c) => c.id));172173  // bassin élargi de ventes : rayon et fenêtre choisis par l'utilisateur174  const halfDeg = (radiusKm / 111) * 1.05;175  const rows = getCandidates(subj.lat, subj.lng, halfDeg, since(months), 4000);176  const sales: CompCandidate[] = [];177  for (const r of rows) {178    const distanceM = haversineM(subj.lat, subj.lng, r.lat, r.lng);179    if (distanceM > radiusKm * 1000) continue;180    const monthsAgo = monthsBetween(r.date, nowISO);181    const tf = timeFactor(r.date.slice(0, 7), index);182    const adjTime = r.amount * (tf - 1);183    const adjArea = areaAdjustment(subj.floorArea, { floorArea: r.floor_area, amount: r.amount });184    const adjAge = ageAdjustment(subj.yearBuilt, { yearBuilt: r.year_built, amount: r.amount });185    sales.push({186      id: r.id,187      kind: "sale",188      date: r.date,189      amount: r.amount,190      lat: r.lat,191      lng: r.lng,192      distanceM: Math.round(distanceM),193      monthsAgo: Math.round(monthsAgo * 10) / 10,194      street: r.street,195      city: r.city,196      propertyType: r.property_type,197      yearBuilt: r.year_built,198      floorArea: r.floor_area,199      landArea: r.land_area,200      valeurRole: r.valeur_role,201      adjTime: Math.round(adjTime),202      adjArea: Math.round(adjArea),203      adjAge: Math.round(adjAge),204      adjustedPrice: Math.round(r.amount + adjTime + adjArea + adjAge),205      weight: weightOf(distanceM, monthsAgo, subj.floorArea, r.floor_area),206      engineUsed: engineIds.has(r.id),207      idProvinc: r.id_provinc,208    });209  }210  // les comparables du moteur d'abord, puis par poids décroissant211  sales.sort((a, b) => Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight);212  const kept = sales.slice(0, limit);213214  const comps: CompCandidate[] = [...kept];215  if (includeListings) {216    const near = listingsNear(subj.lat, subj.lng, radiusKm, { group: det.group, excludeUid: uid, limit: 30 });217    for (const c of near) {218      if (c.lat == null || c.lng == null) continue;219      const area = c.areaSqft && c.areaSqft > 100 && c.areaSqft < 20000 ? c.areaSqft * SQFT : null;220      const adjArea = areaAdjustment(subj.floorArea, { floorArea: area, amount: c.price });221      const adjAge = ageAdjustment(subj.yearBuilt, { yearBuilt: c.yearBuilt, amount: c.price });222      comps.push({223        id: `listing:${c.uid}`,224        kind: "listing",225        date: nowISO,226        amount: c.price,227        lat: c.lat,228        lng: c.lng,229        distanceM: c.distanceM ?? Math.round(haversineM(subj.lat, subj.lng, c.lat, c.lng)),230        monthsAgo: 0,231        street: c.address,232        city: c.city,233        propertyType: c.propertyType,234        yearBuilt: c.yearBuilt,235        floorArea: area,236        landArea: c.lotSqft && c.lotSqft > 50 ? c.lotSqft * SQFT : null,237        valeurRole: null,238        adjTime: 0,239        adjArea: Math.round(adjArea),240        adjAge: Math.round(adjAge),241        adjustedPrice: Math.round(c.price + adjArea + adjAge),242        weight: weightOf(c.distanceM ?? 0, 0, subj.floorArea, area),243        engineUsed: false,244        uid: c.uid,245        image: c.image,246      });247    }248  }249250  return {251    subject: subj,252    engine: {253      estimate: Number.isFinite(engineRes.estimate) ? engineRes.estimate : null,254      low: Number.isFinite(engineRes.low) ? engineRes.low : null,255      high: Number.isFinite(engineRes.high) ? engineRes.high : null,256      modelEstimate: engineRes.modelEstimate,257      compsEstimate: engineRes.compsEstimate,258      modelWeight: engineRes.modelWeight,259      nComps: engineRes.nCompsUsed,260      compIds: [...engineIds],261    },262    comps,263    index,264    nowISO,265    params: { months, radiusKm, includeListings, limit },266  };267}268269// réexport pratique pour les routes270export type { CompInput };271export { adjustComps };272