import { clamp, daysBetween, ewMean, mad, median, quantile, round, stddev, trimmedMean, weightedMean } from '@rareindex/shared'; export interface SaleInput { id: string; priceUsd: number; date: Date; /** 0–1 source trust (§143) */ trust?: number; /** 0–1 identification confidence */ confidence?: number; grader?: string | null; grade?: string | null; quantity?: number; isBundle?: boolean; status?: 'valid' | 'flagged' | 'excluded'; } export interface ObservationInput { priceUsd: number; date: Date; priceKind: string; trust?: number; } export interface CompInput { /** sale from a different grade/variant, adjusted to the target variant via a premium ratio */ priceUsd: number; date: Date; /** multiplier target/source, e.g. PSA10/PSA9 = 2.3 */ adjustment: number; trust?: number; } export interface ValuationInput { sales: SaleInput[]; observations?: ObservationInput[]; comps?: CompInput[]; now?: Date; /** default 365; extended automatically when evidence is thin */ windowDays?: number; categorySlug?: string; } export type ConfidenceLabel = 'high' | 'medium' | 'low' | 'insufficient'; export interface ValuationOutput { riv: number | null; low: number | null; high: number | null; confidence: number; label: ConfidenceLabel; sampleSize: number; observationsUsed: number; windowDays: number; basis: 'transactions' | 'comps' | 'guide' | 'none'; methods: { latest: number | null; median5: number | null; median10: number | null; median20: number | null; vwap: number | null; trimmedMean: number | null; ewMean: number | null; compsAdjusted: number | null; guide: number | null; }; distribution: { min: number | null; p25: number | null; median: number | null; p75: number | null; max: number | null }; salesUsed: string[]; notes: string[]; } export function confidenceLabel(c: number): ConfidenceLabel { if (c >= 0.75) return 'high'; if (c >= 0.5) return 'medium'; if (c > 0.2) return 'low'; return 'insufficient'; } const HALF_LIFE_DAYS = 90; /** * RareIndex Valuation (§115). Ensemble of robust estimators over recent verified transactions; * falls back to grade-adjusted comps, then to guide observations (capped confidence). Never an * arithmetic mean alone; never a number without sample size and confidence. */ export function computeValuation(input: ValuationInput): ValuationOutput { const now = input.now ?? new Date(); const notes: string[] = []; let windowDays = input.windowDays ?? 365; const usable = input.sales.filter((s) => s.priceUsd > 0 && (s.status ?? 'valid') === 'valid' && !s.isBundle && (s.quantity ?? 1) === 1).sort((a, b) => b.date.getTime() - a.date.getTime()); let inWindow = usable.filter((s) => daysBetween(s.date, now) <= windowDays); if (inWindow.length < 5 && usable.length > inWindow.length) { windowDays = 3 * 365; inWindow = usable.filter((s) => daysBetween(s.date, now) <= windowDays); notes.push('window extended to 3y (thin recent evidence)'); } const empty: ValuationOutput['methods'] = { latest: null, median5: null, median10: null, median20: null, vwap: null, trimmedMean: null, ewMean: null, compsAdjusted: null, guide: null }; const prices = inWindow.map((s) => s.priceUsd); const methods = { ...empty }; methods.latest = inWindow[0]?.priceUsd ?? null; methods.median5 = median(prices.slice(0, 5)); methods.median10 = median(prices.slice(0, 10)); methods.median20 = median(prices.slice(0, 20)); methods.trimmedMean = prices.length >= 5 ? trimmedMean(prices, 0.1) : null; methods.ewMean = ewMean(inWindow.map((s) => ({ value: s.priceUsd, ageDays: daysBetween(s.date, now) })), HALF_LIFE_DAYS); // volume-weighted: weight = trust × confidence × recency (each sale is one unit of volume, weighted by price share) methods.vwap = weightedMean(inWindow.map((s) => ({ value: s.priceUsd, weight: (s.trust ?? 0.6) * (s.confidence ?? 0.8) * Math.pow(0.5, daysBetween(s.date, now) / HALF_LIFE_DAYS) }))); const comps = (input.comps ?? []).filter((c) => c.priceUsd > 0 && c.adjustment > 0 && daysBetween(c.date, now) <= windowDays); if (comps.length >= 3) methods.compsAdjusted = median(comps.map((c) => c.priceUsd * c.adjustment)); const obs = (input.observations ?? []).filter((o) => o.priceUsd > 0 && daysBetween(o.date, now) <= 60).sort((a, b) => b.date.getTime() - a.date.getTime()); if (obs.length) { // prefer market/mid kinds, most recent per kind const preferred = obs.filter((o) => ['market', 'mid', 'trend', 'average_7d', 'average_30d', 'guide_value', 'last_sale_reported'].includes(o.priceKind)); const pool = preferred.length ? preferred : obs; methods.guide = median(pool.slice(0, 5).map((o) => o.priceUsd)); } const n = inWindow.length; let riv: number | null = null; let basis: ValuationOutput['basis'] = 'none'; let confidence = 0; if (n >= 3) { basis = 'transactions'; const core = [methods.ewMean, methods.median10 ?? methods.median5, methods.vwap, methods.trimmedMean ?? methods.median5].filter((x): x is number => x !== null); riv = median(core); // confidence: sample size, dispersion, recency, trust const logs = prices.map((p) => Math.log(p)); const disp = (mad(logs) ?? 0) * 1.4826; // robust sigma of log prices const sizeScore = clamp(Math.log2(n + 1) / Math.log2(41), 0, 1); // 40 sales → 1 const dispScore = clamp(1 - disp / 0.6, 0, 1); // 60% log-dispersion → 0 const ageDays = daysBetween(inWindow[0]!.date, now); const recencyScore = clamp(1 - ageDays / 365, 0, 1); const trustScore = weightedMean(inWindow.map((s) => ({ value: (s.trust ?? 0.6) * (s.confidence ?? 0.8), weight: 1 })))!; confidence = clamp(0.35 * sizeScore + 0.3 * dispScore + 0.2 * recencyScore + 0.15 * trustScore, 0, 1); if (windowDays > 365) confidence = Math.min(confidence, 0.7); } else if (n > 0 && (methods.compsAdjusted !== null || methods.guide !== null)) { basis = methods.compsAdjusted !== null ? 'comps' : 'guide'; const blend = [methods.latest!, methods.compsAdjusted ?? methods.guide!]; riv = median(blend); confidence = methods.compsAdjusted !== null ? 0.45 : 0.4; notes.push(`${n} transaction(s) blended with ${basis} evidence`); } else if (n > 0) { basis = 'transactions'; riv = n === 2 ? median(prices) : methods.latest; confidence = n === 2 ? 0.3 : 0.2; notes.push(`only ${n} transaction(s) in window`); } else if (methods.compsAdjusted !== null) { basis = 'comps'; riv = methods.compsAdjusted; confidence = clamp(0.25 + 0.03 * comps.length, 0, 0.5); notes.push('grade-adjusted comparables only'); } else if (methods.guide !== null) { basis = 'guide'; riv = methods.guide; confidence = clamp(0.2 + 0.05 * Math.min(obs.length, 4), 0, 0.45); notes.push('guide-based estimate (no observed transactions)'); } // low / high band let low: number | null = null; let high: number | null = null; if (riv !== null) { if (n >= 5) { low = quantile(prices, 0.25); high = quantile(prices, 0.75); // keep the band around RIV, at least ±5% low = Math.min(low!, riv * 0.95); high = Math.max(high!, riv * 1.05); } else if (n >= 2) { const sd = stddev(prices.map((p) => Math.log(p))) ?? 0.25; low = riv * Math.exp(-Math.max(sd, 0.1)); high = riv * Math.exp(Math.max(sd, 0.1)); } else { const spread = basis === 'guide' ? 0.3 : 0.2; low = riv * (1 - spread); high = riv * (1 + spread); } } const sorted = [...prices].sort((a, b) => a - b); return { riv: riv === null ? null : round(riv, 2), low: low === null ? null : round(low, 2), high: high === null ? null : round(high, 2), confidence: round(confidence, 3), label: confidenceLabel(confidence), sampleSize: n, observationsUsed: obs.length, windowDays, basis, methods: Object.fromEntries(Object.entries(methods).map(([k, v]) => [k, v === null ? null : round(v, 2)])) as ValuationOutput['methods'], distribution: { min: sorted[0] ?? null, p25: quantile(sorted, 0.25), median: median(sorted), p75: quantile(sorted, 0.75), max: sorted[sorted.length - 1] ?? null }, salesUsed: inWindow.map((s) => s.id), notes, }; }