import { describe, expect, it } from 'vitest'; import { summarizePortfolio, valueItem, parseCsv, toCsv, rebase, type PortfolioItemInput } from './portfolio'; const base: PortfolioItemInput = { id: 'ci_1', assetId: 'rare_1', title: 'Charizard', categorySlug: 'pokemon', familySlug: 'trading_cards', quantity: 1, purchasePriceUsd: 1000, acquiredAt: '2024-01-01', grader: 'psa', grade: '10', variantRivUsd: 1500, variantConfidence: 0.8, assetRivUsd: 900, assetConfidence: 0.6, manualValueUsd: null, liquidityScore: 75, rarityScore: 50, change30d: 0.05, }; describe('portfolio math', () => { it('prefers variant RIV, then asset RIV, then manual', () => { expect(valueItem(base).valueSource).toBe('variant_riv'); expect(valueItem({ ...base, variantRivUsd: null }).valueSource).toBe('asset_riv'); expect(valueItem({ ...base, variantRivUsd: null, assetRivUsd: null, manualValueUsd: 50 })).toMatchObject({ valueSource: 'manual', valueUsd: 50, confidence: null }); expect(valueItem({ ...base, variantRivUsd: null, assetRivUsd: null }).valueUsd).toBeNull(); }); it('computes gain, return and annualised return', () => { const v = valueItem(base, new Date('2025-01-01T00:00:00Z')); expect(v.gainUsd).toBe(500); expect(v.gainPct).toBeCloseTo(0.5); expect(v.holdingDays).toBe(366); expect(v.annualizedReturn).toBeCloseTo(0.499, 1); }); it('summarises allocation and concentration', () => { const s = summarizePortfolio([base, { ...base, id: 'ci_2', assetId: 'rare_2', title: 'Rolex', categorySlug: 'rolex', familySlug: 'watches', quantity: 1, purchasePriceUsd: 10000, variantRivUsd: null, assetRivUsd: 8000, grader: null, grade: null, liquidityScore: 30 }, { ...base, id: 'ci_3', assetId: 'rare_3', purchasePriceUsd: null, variantRivUsd: null, assetRivUsd: null }]); expect(s.itemCount).toBe(3); expect(s.valuedCount).toBe(2); expect(s.unvaluedCount).toBe(1); expect(s.valueUsd).toBe(9500); expect(s.costBasisUsd).toBe(11000); expect(s.gainUsd).toBe(-1500); expect(s.returnPct).toBeCloseTo(-1500 / 11000); expect(s.allocationByFamily[0]).toMatchObject({ key: 'watches', share: 8000 / 9500 }); expect(s.concentration.topItemShare).toBeCloseTo(8000 / 9500); expect(s.gradingMix.find((b) => b.key === 'psa')?.valueUsd).toBe(1500); expect(s.best[0]?.id).toBe('ci_1'); expect(s.worst[0]?.id).toBe('ci_2'); expect(s.confidence).toBeCloseTo((1500 * 0.8 + 8000 * 0.6) / 9500); }); it('csv roundtrip and rebase', () => { const csv = toCsv([{ a: 'x,y', b: 1 }, { a: 'q"r', b: null }], ['a', 'b']); const back = parseCsv(csv); expect(back).toEqual([{ a: 'x,y', b: '1' }, { a: 'q"r', b: '' }]); expect(rebase([{ date: 'd1', value: 50 }, { date: 'd2', value: 75 }])[1]?.value).toBe(1500); }); });