TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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