// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Agrégation des prix : observations multi-sources → prix canonique robuste. * Module PUR (les fonctions reçoivent des listes d'observations déjà lues). * * Règles (§52-55) : préférer le prix régulier au prix promotionnel ; médiane * robuste ; détection d'aberrations (MAD / IQR / règles métier) — jamais de * suppression silencieuse (outlier = true) ; score de confiance par article. */ import type { PriceKind } from "./types"; export interface PriceObservation { source: string; sourceQuality: number; // 0-1 price: number; // dans l'unité canonique observedAt: string; // YYYY-MM-DD url: string | null; sourceUnit: string; conversionFactor: number; regular: boolean; kind: PriceKind; locationMatch: boolean; // observation dans la région d'analyse } export interface CanonicalPrice { price: number; low: number; high: number; kind: PriceKind; sourceCount: number; observedAt: string | null; confidence: number; used: PriceObservation[]; outliers: { obs: PriceObservation; reason: string }[]; dispersionPct: number; // (high-low)/price } export function median(xs: number[]): number { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; } /** Déviation absolue médiane (échelle robuste). */ export function mad(xs: number[]): number { const m = median(xs); return median(xs.map((x) => Math.abs(x - m))); } export function daysBetween(a: string, b: string): number { return Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86400000); } /** * Détecte les aberrations : écart > 3,5 MAD (si ≥ 4 obs) ou ratio > 2,5× / < 0,4× * la médiane (règle métier, si ≥ 2 obs). Retourne les indices aberrants. */ export function detectOutliers(prices: number[]): { idx: number; reason: string }[] { const out: { idx: number; reason: string }[] = []; if (prices.length < 2) return out; const m = median(prices); const s = mad(prices) * 1.4826; // ≈ σ robuste prices.forEach((p, i) => { if (m > 0 && (p > m * 2.5 || p < m * 0.4)) out.push({ idx: i, reason: `ratio ${(p / m).toFixed(2)}× la médiane` }); 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)} σ)` }); }); return out; } /** * Prix canonique d'un article à une date donnée. * - garde la plus récente observation par source (fenêtre `windowDays`) ; * - préfère le prix régulier ; les promos ne comptent que s'il n'y a rien d'autre ; * - médiane robuste ; low/high = min/max des observations conservées ; * - confiance : fraîcheur 25 · nb de sources 25 · qualité 25 · dispersion 15 · localisation 10. */ export function canonicalPrice(obs: PriceObservation[], asOf: string, windowDays = 120): CanonicalPrice | null { const inWindow = obs.filter((o) => o.observedAt <= asOf && daysBetween(o.observedAt, asOf) <= windowDays && o.price > 0); if (!inWindow.length) return null; // plus récente par source (et préférence régulier) const bySource = new Map(); for (const o of inWindow.sort((a, b) => (a.observedAt < b.observedAt ? 1 : -1))) { const cur = bySource.get(o.source); if (!cur || (!cur.regular && o.regular)) bySource.set(o.source, o); } let used = [...bySource.values()]; const regulars = used.filter((o) => o.regular); if (regulars.length) used = regulars; const prices = used.map((o) => o.price); const outIdx = detectOutliers(prices); const outliers = outIdx.map((o) => ({ obs: used[o.idx], reason: o.reason })); const kept = used.filter((_, i) => !outIdx.some((o) => o.idx === i)); const final = kept.length ? kept : used; const fp = final.map((o) => o.price); const price = median(fp); const low = Math.min(...fp); const high = Math.max(...fp); const observedAt = final.map((o) => o.observedAt).sort().at(-1) ?? null; const ageDays = observedAt ? daysBetween(observedAt, asOf) : 999; const freshness = 25 * Math.max(0, 1 - ageDays / windowDays); const nSources = 25 * Math.min(1, final.length / 3); const quality = 25 * (final.reduce((s, o) => s + o.sourceQuality, 0) / final.length); const dispersionPct = price > 0 ? (high - low) / price : 1; const dispersion = 15 * Math.max(0, 1 - dispersionPct / 0.5); const loc = 10 * (final.some((o) => o.locationMatch) ? 1 : 0.5); const kinds = new Set(final.map((o) => o.kind)); const kind: PriceKind = kinds.size === 1 ? final[0].kind : kinds.has("observed") ? "observed" : "derived"; return { price, low, high, kind, sourceCount: final.length, observedAt, confidence: Math.round(freshness + nSources + quality + dispersion + loc), used: final, outliers, dispersionPct, }; } /** * Actualisation par indice : Cost_t1 = Cost_t0 × Index_t1 / Index_t0. * `series` triée par période croissante ; on prend la dernière valeur ≤ date. */ export function indexFactor(series: { period: string; value: number }[], fromDate: string, toDate: string): { factor: number; from: { period: string; value: number }; to: { period: string; value: number } } | null { const at = (d: string) => [...series].filter((p) => p.period <= d).at(-1) ?? series[0]; if (!series.length) return null; const a = at(fromDate); const b = at(toDate); if (!a || !b || a.value <= 0) return null; return { factor: b.value / a.value, from: a, to: b }; }