/** * Pure portfolio mathematics (§129–§130). No I/O — unit-tested. Values are USD. * Valuation source order: variant RIV → asset RIV → member manual value (flagged as such). */ export interface PortfolioItemInput { id: string; assetId: string; title: string; categorySlug: string; familySlug: string; quantity: number; purchasePriceUsd: number | null; acquiredAt: string | null; // YYYY-MM-DD grader: string | null; grade: string | null; variantRivUsd: number | null; variantConfidence: number | null; assetRivUsd: number | null; assetConfidence: number | null; manualValueUsd: number | null; liquidityScore: number | null; rarityScore: number | null; change30d: number | null; soldPriceUsd?: number | null; } export type ValueSource = 'variant_riv' | 'asset_riv' | 'manual' | 'none'; export interface ValuedItem extends PortfolioItemInput { unitValueUsd: number | null; valueUsd: number | null; valueSource: ValueSource; confidence: number | null; costUsd: number | null; gainUsd: number | null; gainPct: number | null; holdingDays: number | null; annualizedReturn: number | null; } export function valueItem(it: PortfolioItemInput, now = new Date()): ValuedItem { let unit: number | null = null; let source: ValueSource = 'none'; let confidence: number | null = null; if (it.variantRivUsd !== null && it.variantRivUsd > 0) { unit = it.variantRivUsd; source = 'variant_riv'; confidence = it.variantConfidence; } else if (it.assetRivUsd !== null && it.assetRivUsd > 0) { unit = it.assetRivUsd; source = 'asset_riv'; confidence = it.assetConfidence; } else if (it.manualValueUsd !== null && it.manualValueUsd > 0) { unit = it.manualValueUsd; source = 'manual'; confidence = null; } const qty = Math.max(1, it.quantity || 1); const value = unit === null ? null : unit * qty; const cost = it.purchasePriceUsd === null ? null : it.purchasePriceUsd * qty; const gain = value !== null && cost !== null ? value - cost : null; const gainPct = gain !== null && cost && cost > 0 ? gain / cost : null; let holdingDays: number | null = null; let annualized: number | null = null; if (it.acquiredAt) { const d = new Date(`${it.acquiredAt}T00:00:00Z`); if (!Number.isNaN(d.getTime())) { holdingDays = Math.max(0, Math.floor((now.getTime() - d.getTime()) / 86_400_000)); if (gainPct !== null && holdingDays >= 30 && value !== null && cost && cost > 0) { annualized = Math.pow(value / cost, 365 / holdingDays) - 1; } } } return { ...it, unitValueUsd: unit, valueUsd: value, valueSource: source, confidence, costUsd: cost, gainUsd: gain, gainPct, holdingDays, annualizedReturn: annualized }; } export interface Bucket { key: string; label: string; valueUsd: number; count: number; share: number; } export interface PortfolioSummary { items: ValuedItem[]; itemCount: number; unitCount: number; valuedCount: number; unvaluedCount: number; valueUsd: number; costBasisUsd: number; costKnownValueUsd: number; // value of items that also have a cost (for the return %) gainUsd: number | null; returnPct: number | null; /** value-weighted average confidence of the valued items (0–1) */ confidence: number | null; allocationByFamily: Bucket[]; allocationByCategory: Bucket[]; gradingMix: Bucket[]; liquidityMix: Bucket[]; rarityMix: Bucket[]; concentration: { topItemShare: number | null; top5Share: number | null; hhi: number | null }; best: ValuedItem[]; worst: ValuedItem[]; manualValueUsd: number; } function buckets(items: ValuedItem[], keyFn: (i: ValuedItem) => [string, string] | null, total: number): Bucket[] { const m = new Map(); for (const i of items) { if (i.valueUsd === null) continue; const k = keyFn(i); if (!k) continue; const b = m.get(k[0]) ?? { key: k[0], label: k[1], valueUsd: 0, count: 0, share: 0 }; b.valueUsd += i.valueUsd; b.count += 1; m.set(k[0], b); } return [...m.values()].map((b) => ({ ...b, share: total > 0 ? b.valueUsd / total : 0 })).sort((a, b) => b.valueUsd - a.valueUsd); } function scoreBand(s: number | null): [string, string] | null { if (s === null) return ['unknown', 'Unknown']; if (s >= 70) return ['high', 'High']; if (s >= 40) return ['medium', 'Medium']; return ['low', 'Low']; } export function summarizePortfolio(inputs: PortfolioItemInput[], now = new Date()): PortfolioSummary { const items = inputs.map((i) => valueItem(i, now)); const valued = items.filter((i) => i.valueUsd !== null); const valueUsd = valued.reduce((a, i) => a + (i.valueUsd ?? 0), 0); const withCost = items.filter((i) => i.costUsd !== null); const costBasisUsd = withCost.reduce((a, i) => a + (i.costUsd ?? 0), 0); const both = items.filter((i) => i.costUsd !== null && i.valueUsd !== null); const costKnownValueUsd = both.reduce((a, i) => a + (i.valueUsd ?? 0), 0); const costOfBoth = both.reduce((a, i) => a + (i.costUsd ?? 0), 0); const gainUsd = both.length ? costKnownValueUsd - costOfBoth : null; const returnPct = both.length && costOfBoth > 0 ? (costKnownValueUsd - costOfBoth) / costOfBoth : null; const confPairs = valued.filter((i) => i.confidence !== null); const confDen = confPairs.reduce((a, i) => a + (i.valueUsd ?? 0), 0); const confidence = confDen > 0 ? confPairs.reduce((a, i) => a + (i.valueUsd ?? 0) * (i.confidence ?? 0), 0) / confDen : null; const sorted = [...valued].sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0)); const shares = sorted.map((i) => (valueUsd > 0 ? (i.valueUsd ?? 0) / valueUsd : 0)); const perf = items.filter((i) => i.gainPct !== null).sort((a, b) => (b.gainPct ?? 0) - (a.gainPct ?? 0)); return { items, itemCount: items.length, unitCount: items.reduce((a, i) => a + Math.max(1, i.quantity || 1), 0), valuedCount: valued.length, unvaluedCount: items.length - valued.length, valueUsd, costBasisUsd, costKnownValueUsd, gainUsd, returnPct, confidence, allocationByFamily: buckets(items, (i) => [i.familySlug, humanize(i.familySlug)], valueUsd), allocationByCategory: buckets(items, (i) => [i.categorySlug, humanize(i.categorySlug)], valueUsd), gradingMix: buckets(items, (i) => (i.grader && i.grader !== 'raw' ? [i.grader, `${i.grader.toUpperCase()}${i.grade ? ` ${i.grade}` : ''}`] : ['raw', 'Raw / ungraded']), valueUsd), liquidityMix: buckets(items, (i) => scoreBand(i.liquidityScore), valueUsd), rarityMix: buckets(items, (i) => scoreBand(i.rarityScore), valueUsd), concentration: { topItemShare: shares[0] ?? null, top5Share: shares.length ? shares.slice(0, 5).reduce((a, b) => a + b, 0) : null, hhi: shares.length ? shares.reduce((a, s) => a + s * s, 0) : null, }, best: perf.slice(0, 5), worst: perf.length > 1 ? perf.slice(-5).reverse().filter((i) => (i.gainPct ?? 0) < 0) : [], manualValueUsd: items.filter((i) => i.valueSource === 'manual').reduce((a, i) => a + (i.valueUsd ?? 0), 0), }; } export function humanize(slug: string): string { return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bTcg\b/, 'TCG').replace(/\bDc\b/, 'DC').replace(/\bLego\b/, 'LEGO'); } /** Rebase a value series to 1000 at the first point (personal index vs RARE). */ export function rebase(series: Array<{ date: string; value: number }>, base = 1000): Array<{ date: string; value: number }> { const first = series.find((p) => p.value > 0)?.value; if (!first) return []; return series.map((p) => ({ date: p.date, value: (p.value / first) * base })); } /** CSV helpers (RFC 4180-ish). */ export function toCsv(rows: Array>, columns: string[]): string { const esc = (v: unknown) => { if (v === null || v === undefined) return ''; const s = v instanceof Date ? v.toISOString() : String(v); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; return [columns.join(','), ...rows.map((r) => columns.map((c) => esc(r[c])).join(','))].join('\r\n') + '\r\n'; } export function parseCsv(text: string): Array> { const rows: string[][] = []; let cur: string[] = []; let field = ''; let inQ = false; const src = text.replace(/^/, ''); for (let i = 0; i < src.length; i++) { const ch = src[i]!; if (inQ) { if (ch === '"') { if (src[i + 1] === '"') { field += '"'; i++; } else inQ = false; } else field += ch; } else if (ch === '"') inQ = true; else if (ch === ',') { cur.push(field); field = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && src[i + 1] === '\n') i++; cur.push(field); field = ''; if (cur.some((c) => c.trim() !== '')) rows.push(cur); cur = []; } else field += ch; } if (field !== '' || cur.length) { cur.push(field); if (cur.some((c) => c.trim() !== '')) rows.push(cur); } const header = (rows.shift() ?? []).map((h) => h.trim().toLowerCase().replace(/\s+/g, '_')); return rows.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()]))); }