SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
8.1 KB · 201 lines typescript
Raw Blame History
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