Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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