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%
9.0 KB · 231 lines typescript
Raw Blame History
1/**2 * Pure portfolio mathematics (§129–§130). No I/O — unit-tested. Values are USD.3 * Valuation source order: variant RIV → asset RIV → member manual value (flagged as such).4 */56export interface PortfolioItemInput {7  id: string;8  assetId: string;9  title: string;10  categorySlug: string;11  familySlug: string;12  quantity: number;13  purchasePriceUsd: number | null;14  acquiredAt: string | null; // YYYY-MM-DD15  grader: string | null;16  grade: string | null;17  variantRivUsd: number | null;18  variantConfidence: number | null;19  assetRivUsd: number | null;20  assetConfidence: number | null;21  manualValueUsd: number | null;22  liquidityScore: number | null;23  rarityScore: number | null;24  change30d: number | null;25  soldPriceUsd?: number | null;26}2728export type ValueSource = 'variant_riv' | 'asset_riv' | 'manual' | 'none';2930export interface ValuedItem extends PortfolioItemInput {31  unitValueUsd: number | null;32  valueUsd: number | null;33  valueSource: ValueSource;34  confidence: number | null;35  costUsd: number | null;36  gainUsd: number | null;37  gainPct: number | null;38  holdingDays: number | null;39  annualizedReturn: number | null;40}4142export function valueItem(it: PortfolioItemInput, now = new Date()): ValuedItem {43  let unit: number | null = null;44  let source: ValueSource = 'none';45  let confidence: number | null = null;46  if (it.variantRivUsd !== null && it.variantRivUsd > 0) {47    unit = it.variantRivUsd;48    source = 'variant_riv';49    confidence = it.variantConfidence;50  } else if (it.assetRivUsd !== null && it.assetRivUsd > 0) {51    unit = it.assetRivUsd;52    source = 'asset_riv';53    confidence = it.assetConfidence;54  } else if (it.manualValueUsd !== null && it.manualValueUsd > 0) {55    unit = it.manualValueUsd;56    source = 'manual';57    confidence = null;58  }59  const qty = Math.max(1, it.quantity || 1);60  const value = unit === null ? null : unit * qty;61  const cost = it.purchasePriceUsd === null ? null : it.purchasePriceUsd * qty;62  const gain = value !== null && cost !== null ? value - cost : null;63  const gainPct = gain !== null && cost && cost > 0 ? gain / cost : null;64  let holdingDays: number | null = null;65  let annualized: number | null = null;66  if (it.acquiredAt) {67    const d = new Date(`${it.acquiredAt}T00:00:00Z`);68    if (!Number.isNaN(d.getTime())) {69      holdingDays = Math.max(0, Math.floor((now.getTime() - d.getTime()) / 86_400_000));70      if (gainPct !== null && holdingDays >= 30 && value !== null && cost && cost > 0) {71        annualized = Math.pow(value / cost, 365 / holdingDays) - 1;72      }73    }74  }75  return { ...it, unitValueUsd: unit, valueUsd: value, valueSource: source, confidence, costUsd: cost, gainUsd: gain, gainPct, holdingDays, annualizedReturn: annualized };76}7778export interface Bucket {79  key: string;80  label: string;81  valueUsd: number;82  count: number;83  share: number;84}8586export interface PortfolioSummary {87  items: ValuedItem[];88  itemCount: number;89  unitCount: number;90  valuedCount: number;91  unvaluedCount: number;92  valueUsd: number;93  costBasisUsd: number;94  costKnownValueUsd: number; // value of items that also have a cost (for the return %)95  gainUsd: number | null;96  returnPct: number | null;97  /** value-weighted average confidence of the valued items (0–1) */98  confidence: number | null;99  allocationByFamily: Bucket[];100  allocationByCategory: Bucket[];101  gradingMix: Bucket[];102  liquidityMix: Bucket[];103  rarityMix: Bucket[];104  concentration: { topItemShare: number | null; top5Share: number | null; hhi: number | null };105  best: ValuedItem[];106  worst: ValuedItem[];107  manualValueUsd: number;108}109110function buckets(items: ValuedItem[], keyFn: (i: ValuedItem) => [string, string] | null, total: number): Bucket[] {111  const m = new Map<string, Bucket>();112  for (const i of items) {113    if (i.valueUsd === null) continue;114    const k = keyFn(i);115    if (!k) continue;116    const b = m.get(k[0]) ?? { key: k[0], label: k[1], valueUsd: 0, count: 0, share: 0 };117    b.valueUsd += i.valueUsd;118    b.count += 1;119    m.set(k[0], b);120  }121  return [...m.values()].map((b) => ({ ...b, share: total > 0 ? b.valueUsd / total : 0 })).sort((a, b) => b.valueUsd - a.valueUsd);122}123124function scoreBand(s: number | null): [string, string] | null {125  if (s === null) return ['unknown', 'Unknown'];126  if (s >= 70) return ['high', 'High'];127  if (s >= 40) return ['medium', 'Medium'];128  return ['low', 'Low'];129}130131export function summarizePortfolio(inputs: PortfolioItemInput[], now = new Date()): PortfolioSummary {132  const items = inputs.map((i) => valueItem(i, now));133  const valued = items.filter((i) => i.valueUsd !== null);134  const valueUsd = valued.reduce((a, i) => a + (i.valueUsd ?? 0), 0);135  const withCost = items.filter((i) => i.costUsd !== null);136  const costBasisUsd = withCost.reduce((a, i) => a + (i.costUsd ?? 0), 0);137  const both = items.filter((i) => i.costUsd !== null && i.valueUsd !== null);138  const costKnownValueUsd = both.reduce((a, i) => a + (i.valueUsd ?? 0), 0);139  const costOfBoth = both.reduce((a, i) => a + (i.costUsd ?? 0), 0);140  const gainUsd = both.length ? costKnownValueUsd - costOfBoth : null;141  const returnPct = both.length && costOfBoth > 0 ? (costKnownValueUsd - costOfBoth) / costOfBoth : null;142  const confPairs = valued.filter((i) => i.confidence !== null);143  const confDen = confPairs.reduce((a, i) => a + (i.valueUsd ?? 0), 0);144  const confidence = confDen > 0 ? confPairs.reduce((a, i) => a + (i.valueUsd ?? 0) * (i.confidence ?? 0), 0) / confDen : null;145  const sorted = [...valued].sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0));146  const shares = sorted.map((i) => (valueUsd > 0 ? (i.valueUsd ?? 0) / valueUsd : 0));147  const perf = items.filter((i) => i.gainPct !== null).sort((a, b) => (b.gainPct ?? 0) - (a.gainPct ?? 0));148  return {149    items,150    itemCount: items.length,151    unitCount: items.reduce((a, i) => a + Math.max(1, i.quantity || 1), 0),152    valuedCount: valued.length,153    unvaluedCount: items.length - valued.length,154    valueUsd,155    costBasisUsd,156    costKnownValueUsd,157    gainUsd,158    returnPct,159    confidence,160    allocationByFamily: buckets(items, (i) => [i.familySlug, humanize(i.familySlug)], valueUsd),161    allocationByCategory: buckets(items, (i) => [i.categorySlug, humanize(i.categorySlug)], valueUsd),162    gradingMix: buckets(items, (i) => (i.grader && i.grader !== 'raw' ? [i.grader, `${i.grader.toUpperCase()}${i.grade ? ` ${i.grade}` : ''}`] : ['raw', 'Raw / ungraded']), valueUsd),163    liquidityMix: buckets(items, (i) => scoreBand(i.liquidityScore), valueUsd),164    rarityMix: buckets(items, (i) => scoreBand(i.rarityScore), valueUsd),165    concentration: {166      topItemShare: shares[0] ?? null,167      top5Share: shares.length ? shares.slice(0, 5).reduce((a, b) => a + b, 0) : null,168      hhi: shares.length ? shares.reduce((a, s) => a + s * s, 0) : null,169    },170    best: perf.slice(0, 5),171    worst: perf.length > 1 ? perf.slice(-5).reverse().filter((i) => (i.gainPct ?? 0) < 0) : [],172    manualValueUsd: items.filter((i) => i.valueSource === 'manual').reduce((a, i) => a + (i.valueUsd ?? 0), 0),173  };174}175176export function humanize(slug: string): string {177  return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bTcg\b/, 'TCG').replace(/\bDc\b/, 'DC').replace(/\bLego\b/, 'LEGO');178}179180/** Rebase a value series to 1000 at the first point (personal index vs RARE). */181export function rebase(series: Array<{ date: string; value: number }>, base = 1000): Array<{ date: string; value: number }> {182  const first = series.find((p) => p.value > 0)?.value;183  if (!first) return [];184  return series.map((p) => ({ date: p.date, value: (p.value / first) * base }));185}186187/** CSV helpers (RFC 4180-ish). */188export function toCsv(rows: Array<Record<string, unknown>>, columns: string[]): string {189  const esc = (v: unknown) => {190    if (v === null || v === undefined) return '';191    const s = v instanceof Date ? v.toISOString() : String(v);192    return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;193  };194  return [columns.join(','), ...rows.map((r) => columns.map((c) => esc(r[c])).join(','))].join('\r\n') + '\r\n';195}196197export function parseCsv(text: string): Array<Record<string, string>> {198  const rows: string[][] = [];199  let cur: string[] = [];200  let field = '';201  let inQ = false;202  const src = text.replace(/^/, '');203  for (let i = 0; i < src.length; i++) {204    const ch = src[i]!;205    if (inQ) {206      if (ch === '"') {207        if (src[i + 1] === '"') {208          field += '"';209          i++;210        } else inQ = false;211      } else field += ch;212    } else if (ch === '"') inQ = true;213    else if (ch === ',') {214      cur.push(field);215      field = '';216    } else if (ch === '\n' || ch === '\r') {217      if (ch === '\r' && src[i + 1] === '\n') i++;218      cur.push(field);219      field = '';220      if (cur.some((c) => c.trim() !== '')) rows.push(cur);221      cur = [];222    } else field += ch;223  }224  if (field !== '' || cur.length) {225    cur.push(field);226    if (cur.some((c) => c.trim() !== '')) rows.push(cur);227  }228  const header = (rows.shift() ?? []).map((h) => h.trim().toLowerCase().replace(/\s+/g, '_'));229  return rows.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()])));230}231