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