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 * Agrégation des prix : observations multi-sources → prix canonique robuste.4 * Module PUR (les fonctions reçoivent des listes d'observations déjà lues).5 *6 * Règles (§52-55) : préférer le prix régulier au prix promotionnel ; médiane7 * robuste ; détection d'aberrations (MAD / IQR / règles métier) — jamais de8 * suppression silencieuse (outlier = true) ; score de confiance par article.9 */10import type { PriceKind } from "./types";1112export interface PriceObservation {13 source: string;14 sourceQuality: number; // 0-115 price: number; // dans l'unité canonique16 observedAt: string; // YYYY-MM-DD17 url: string | null;18 sourceUnit: string;19 conversionFactor: number;20 regular: boolean;21 kind: PriceKind;22 locationMatch: boolean; // observation dans la région d'analyse23}2425export interface CanonicalPrice {26 price: number;27 low: number;28 high: number;29 kind: PriceKind;30 sourceCount: number;31 observedAt: string | null;32 confidence: number;33 used: PriceObservation[];34 outliers: { obs: PriceObservation; reason: string }[];35 dispersionPct: number; // (high-low)/price36}3738export function median(xs: number[]): number {39 if (!xs.length) return NaN;40 const s = [...xs].sort((a, b) => a - b);41 const m = Math.floor(s.length / 2);42 return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;43}4445/** Déviation absolue médiane (échelle robuste). */46export function mad(xs: number[]): number {47 const m = median(xs);48 return median(xs.map((x) => Math.abs(x - m)));49}5051export function daysBetween(a: string, b: string): number {52 return Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86400000);53}5455/**56 * Détecte les aberrations : écart > 3,5 MAD (si ≥ 4 obs) ou ratio > 2,5× / < 0,4×57 * la médiane (règle métier, si ≥ 2 obs). Retourne les indices aberrants.58 */59export function detectOutliers(prices: number[]): { idx: number; reason: string }[] {60 const out: { idx: number; reason: string }[] = [];61 if (prices.length < 2) return out;62 const m = median(prices);63 const s = mad(prices) * 1.4826; // ≈ σ robuste64 prices.forEach((p, i) => {65 if (m > 0 && (p > m * 2.5 || p < m * 0.4)) out.push({ idx: i, reason: `ratio ${(p / m).toFixed(2)}× la médiane` });66 else if (prices.length >= 4 && s > 0 && Math.abs(p - m) / s > 3.5) out.push({ idx: i, reason: `> 3,5 MAD (${((p - m) / s).toFixed(1)} σ)` });67 });68 return out;69}7071/**72 * Prix canonique d'un article à une date donnée.73 * - garde la plus récente observation par source (fenêtre `windowDays`) ;74 * - préfère le prix régulier ; les promos ne comptent que s'il n'y a rien d'autre ;75 * - médiane robuste ; low/high = min/max des observations conservées ;76 * - confiance : fraîcheur 25 · nb de sources 25 · qualité 25 · dispersion 15 · localisation 10.77 */78export function canonicalPrice(obs: PriceObservation[], asOf: string, windowDays = 120): CanonicalPrice | null {79 const inWindow = obs.filter((o) => o.observedAt <= asOf && daysBetween(o.observedAt, asOf) <= windowDays && o.price > 0);80 if (!inWindow.length) return null;81 // plus récente par source (et préférence régulier)82 const bySource = new Map<string, PriceObservation>();83 for (const o of inWindow.sort((a, b) => (a.observedAt < b.observedAt ? 1 : -1))) {84 const cur = bySource.get(o.source);85 if (!cur || (!cur.regular && o.regular)) bySource.set(o.source, o);86 }87 let used = [...bySource.values()];88 const regulars = used.filter((o) => o.regular);89 if (regulars.length) used = regulars;90 const prices = used.map((o) => o.price);91 const outIdx = detectOutliers(prices);92 const outliers = outIdx.map((o) => ({ obs: used[o.idx], reason: o.reason }));93 const kept = used.filter((_, i) => !outIdx.some((o) => o.idx === i));94 const final = kept.length ? kept : used;95 const fp = final.map((o) => o.price);96 const price = median(fp);97 const low = Math.min(...fp);98 const high = Math.max(...fp);99 const observedAt = final.map((o) => o.observedAt).sort().at(-1) ?? null;100 const ageDays = observedAt ? daysBetween(observedAt, asOf) : 999;101 const freshness = 25 * Math.max(0, 1 - ageDays / windowDays);102 const nSources = 25 * Math.min(1, final.length / 3);103 const quality = 25 * (final.reduce((s, o) => s + o.sourceQuality, 0) / final.length);104 const dispersionPct = price > 0 ? (high - low) / price : 1;105 const dispersion = 15 * Math.max(0, 1 - dispersionPct / 0.5);106 const loc = 10 * (final.some((o) => o.locationMatch) ? 1 : 0.5);107 const kinds = new Set(final.map((o) => o.kind));108 const kind: PriceKind = kinds.size === 1 ? final[0].kind : kinds.has("observed") ? "observed" : "derived";109 return {110 price, low, high, kind, sourceCount: final.length, observedAt,111 confidence: Math.round(freshness + nSources + quality + dispersion + loc),112 used: final, outliers, dispersionPct,113 };114}115116/**117 * Actualisation par indice : Cost_t1 = Cost_t0 × Index_t1 / Index_t0.118 * `series` triée par période croissante ; on prend la dernière valeur ≤ date.119 */120export function indexFactor(series: { period: string; value: number }[], fromDate: string, toDate: string): { factor: number; from: { period: string; value: number }; to: { period: string; value: number } } | null {121 const at = (d: string) => [...series].filter((p) => p.period <= d).at(-1) ?? series[0];122 if (!series.length) return null;123 const a = at(fromDate);124 const b = at(toDate);125 if (!a || !b || a.value <= 0) return null;126 return { factor: b.value / a.value, from: a, to: b };127}128