TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { clamp, daysBetween, ewMean, mad, median, quantile, round, stddev, trimmedMean, weightedMean } from '@rareindex/shared';23export interface SaleInput {4 id: string;5 priceUsd: number;6 date: Date;7 /** 0–1 source trust (§143) */8 trust?: number;9 /** 0–1 identification confidence */10 confidence?: number;11 grader?: string | null;12 grade?: string | null;13 quantity?: number;14 isBundle?: boolean;15 status?: 'valid' | 'flagged' | 'excluded';16}1718export interface ObservationInput {19 priceUsd: number;20 date: Date;21 priceKind: string;22 trust?: number;23}2425export interface CompInput {26 /** sale from a different grade/variant, adjusted to the target variant via a premium ratio */27 priceUsd: number;28 date: Date;29 /** multiplier target/source, e.g. PSA10/PSA9 = 2.3 */30 adjustment: number;31 trust?: number;32}3334export interface ValuationInput {35 sales: SaleInput[];36 observations?: ObservationInput[];37 comps?: CompInput[];38 now?: Date;39 /** default 365; extended automatically when evidence is thin */40 windowDays?: number;41 categorySlug?: string;42}4344export type ConfidenceLabel = 'high' | 'medium' | 'low' | 'insufficient';4546export interface ValuationOutput {47 riv: number | null;48 low: number | null;49 high: number | null;50 confidence: number;51 label: ConfidenceLabel;52 sampleSize: number;53 observationsUsed: number;54 windowDays: number;55 basis: 'transactions' | 'comps' | 'guide' | 'none';56 methods: {57 latest: number | null;58 median5: number | null;59 median10: number | null;60 median20: number | null;61 vwap: number | null;62 trimmedMean: number | null;63 ewMean: number | null;64 compsAdjusted: number | null;65 guide: number | null;66 };67 distribution: { min: number | null; p25: number | null; median: number | null; p75: number | null; max: number | null };68 salesUsed: string[];69 notes: string[];70}7172export function confidenceLabel(c: number): ConfidenceLabel {73 if (c >= 0.75) return 'high';74 if (c >= 0.5) return 'medium';75 if (c > 0.2) return 'low';76 return 'insufficient';77}7879const HALF_LIFE_DAYS = 90;8081/**82 * RareIndex Valuation (§115). Ensemble of robust estimators over recent verified transactions;83 * falls back to grade-adjusted comps, then to guide observations (capped confidence). Never an84 * arithmetic mean alone; never a number without sample size and confidence.85 */86export function computeValuation(input: ValuationInput): ValuationOutput {87 const now = input.now ?? new Date();88 const notes: string[] = [];89 let windowDays = input.windowDays ?? 365;90 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());91 let inWindow = usable.filter((s) => daysBetween(s.date, now) <= windowDays);92 if (inWindow.length < 5 && usable.length > inWindow.length) {93 windowDays = 3 * 365;94 inWindow = usable.filter((s) => daysBetween(s.date, now) <= windowDays);95 notes.push('window extended to 3y (thin recent evidence)');96 }97 const empty: ValuationOutput['methods'] = { latest: null, median5: null, median10: null, median20: null, vwap: null, trimmedMean: null, ewMean: null, compsAdjusted: null, guide: null };9899 const prices = inWindow.map((s) => s.priceUsd);100 const methods = { ...empty };101 methods.latest = inWindow[0]?.priceUsd ?? null;102 methods.median5 = median(prices.slice(0, 5));103 methods.median10 = median(prices.slice(0, 10));104 methods.median20 = median(prices.slice(0, 20));105 methods.trimmedMean = prices.length >= 5 ? trimmedMean(prices, 0.1) : null;106 methods.ewMean = ewMean(inWindow.map((s) => ({ value: s.priceUsd, ageDays: daysBetween(s.date, now) })), HALF_LIFE_DAYS);107 // volume-weighted: weight = trust × confidence × recency (each sale is one unit of volume, weighted by price share)108 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) })));109110 const comps = (input.comps ?? []).filter((c) => c.priceUsd > 0 && c.adjustment > 0 && daysBetween(c.date, now) <= windowDays);111 if (comps.length >= 3) methods.compsAdjusted = median(comps.map((c) => c.priceUsd * c.adjustment));112113 const obs = (input.observations ?? []).filter((o) => o.priceUsd > 0 && daysBetween(o.date, now) <= 60).sort((a, b) => b.date.getTime() - a.date.getTime());114 if (obs.length) {115 // prefer market/mid kinds, most recent per kind116 const preferred = obs.filter((o) => ['market', 'mid', 'trend', 'average_7d', 'average_30d', 'guide_value', 'last_sale_reported'].includes(o.priceKind));117 const pool = preferred.length ? preferred : obs;118 methods.guide = median(pool.slice(0, 5).map((o) => o.priceUsd));119 }120121 const n = inWindow.length;122 let riv: number | null = null;123 let basis: ValuationOutput['basis'] = 'none';124 let confidence = 0;125126 if (n >= 3) {127 basis = 'transactions';128 const core = [methods.ewMean, methods.median10 ?? methods.median5, methods.vwap, methods.trimmedMean ?? methods.median5].filter((x): x is number => x !== null);129 riv = median(core);130 // confidence: sample size, dispersion, recency, trust131 const logs = prices.map((p) => Math.log(p));132 const disp = (mad(logs) ?? 0) * 1.4826; // robust sigma of log prices133 const sizeScore = clamp(Math.log2(n + 1) / Math.log2(41), 0, 1); // 40 sales → 1134 const dispScore = clamp(1 - disp / 0.6, 0, 1); // 60% log-dispersion → 0135 const ageDays = daysBetween(inWindow[0]!.date, now);136 const recencyScore = clamp(1 - ageDays / 365, 0, 1);137 const trustScore = weightedMean(inWindow.map((s) => ({ value: (s.trust ?? 0.6) * (s.confidence ?? 0.8), weight: 1 })))!;138 confidence = clamp(0.35 * sizeScore + 0.3 * dispScore + 0.2 * recencyScore + 0.15 * trustScore, 0, 1);139 if (windowDays > 365) confidence = Math.min(confidence, 0.7);140 } else if (n > 0 && (methods.compsAdjusted !== null || methods.guide !== null)) {141 basis = methods.compsAdjusted !== null ? 'comps' : 'guide';142 const blend = [methods.latest!, methods.compsAdjusted ?? methods.guide!];143 riv = median(blend);144 confidence = methods.compsAdjusted !== null ? 0.45 : 0.4;145 notes.push(`${n} transaction(s) blended with ${basis} evidence`);146 } else if (n > 0) {147 basis = 'transactions';148 riv = n === 2 ? median(prices) : methods.latest;149 confidence = n === 2 ? 0.3 : 0.2;150 notes.push(`only ${n} transaction(s) in window`);151 } else if (methods.compsAdjusted !== null) {152 basis = 'comps';153 riv = methods.compsAdjusted;154 confidence = clamp(0.25 + 0.03 * comps.length, 0, 0.5);155 notes.push('grade-adjusted comparables only');156 } else if (methods.guide !== null) {157 basis = 'guide';158 riv = methods.guide;159 confidence = clamp(0.2 + 0.05 * Math.min(obs.length, 4), 0, 0.45);160 notes.push('guide-based estimate (no observed transactions)');161 }162163 // low / high band164 let low: number | null = null;165 let high: number | null = null;166 if (riv !== null) {167 if (n >= 5) {168 low = quantile(prices, 0.25);169 high = quantile(prices, 0.75);170 // keep the band around RIV, at least ±5%171 low = Math.min(low!, riv * 0.95);172 high = Math.max(high!, riv * 1.05);173 } else if (n >= 2) {174 const sd = stddev(prices.map((p) => Math.log(p))) ?? 0.25;175 low = riv * Math.exp(-Math.max(sd, 0.1));176 high = riv * Math.exp(Math.max(sd, 0.1));177 } else {178 const spread = basis === 'guide' ? 0.3 : 0.2;179 low = riv * (1 - spread);180 high = riv * (1 + spread);181 }182 }183184 const sorted = [...prices].sort((a, b) => a - b);185 return {186 riv: riv === null ? null : round(riv, 2),187 low: low === null ? null : round(low, 2),188 high: high === null ? null : round(high, 2),189 confidence: round(confidence, 3),190 label: confidenceLabel(confidence),191 sampleSize: n,192 observationsUsed: obs.length,193 windowDays,194 basis,195 methods: Object.fromEntries(Object.entries(methods).map(([k, v]) => [k, v === null ? null : round(v, 2)])) as ValuationOutput['methods'],196 distribution: { min: sorted[0] ?? null, p25: quantile(sorted, 0.25), median: median(sorted), p75: quantile(sorted, 0.75), max: sorted[sorted.length - 1] ?? null },197 salesUsed: inWindow.map((s) => s.id),198 notes,199 };200}201