TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { describe, expect, it } from 'vitest';2import { summarizePortfolio, valueItem, parseCsv, toCsv, rebase, type PortfolioItemInput } from './portfolio';34const base: PortfolioItemInput = {5 id: 'ci_1',6 assetId: 'rare_1',7 title: 'Charizard',8 categorySlug: 'pokemon',9 familySlug: 'trading_cards',10 quantity: 1,11 purchasePriceUsd: 1000,12 acquiredAt: '2024-01-01',13 grader: 'psa',14 grade: '10',15 variantRivUsd: 1500,16 variantConfidence: 0.8,17 assetRivUsd: 900,18 assetConfidence: 0.6,19 manualValueUsd: null,20 liquidityScore: 75,21 rarityScore: 50,22 change30d: 0.05,23};2425describe('portfolio math', () => {26 it('prefers variant RIV, then asset RIV, then manual', () => {27 expect(valueItem(base).valueSource).toBe('variant_riv');28 expect(valueItem({ ...base, variantRivUsd: null }).valueSource).toBe('asset_riv');29 expect(valueItem({ ...base, variantRivUsd: null, assetRivUsd: null, manualValueUsd: 50 })).toMatchObject({ valueSource: 'manual', valueUsd: 50, confidence: null });30 expect(valueItem({ ...base, variantRivUsd: null, assetRivUsd: null }).valueUsd).toBeNull();31 });32 it('computes gain, return and annualised return', () => {33 const v = valueItem(base, new Date('2025-01-01T00:00:00Z'));34 expect(v.gainUsd).toBe(500);35 expect(v.gainPct).toBeCloseTo(0.5);36 expect(v.holdingDays).toBe(366);37 expect(v.annualizedReturn).toBeCloseTo(0.499, 1);38 });39 it('summarises allocation and concentration', () => {40 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 }]);41 expect(s.itemCount).toBe(3);42 expect(s.valuedCount).toBe(2);43 expect(s.unvaluedCount).toBe(1);44 expect(s.valueUsd).toBe(9500);45 expect(s.costBasisUsd).toBe(11000);46 expect(s.gainUsd).toBe(-1500);47 expect(s.returnPct).toBeCloseTo(-1500 / 11000);48 expect(s.allocationByFamily[0]).toMatchObject({ key: 'watches', share: 8000 / 9500 });49 expect(s.concentration.topItemShare).toBeCloseTo(8000 / 9500);50 expect(s.gradingMix.find((b) => b.key === 'psa')?.valueUsd).toBe(1500);51 expect(s.best[0]?.id).toBe('ci_1');52 expect(s.worst[0]?.id).toBe('ci_2');53 expect(s.confidence).toBeCloseTo((1500 * 0.8 + 8000 * 0.6) / 9500);54 });55 it('csv roundtrip and rebase', () => {56 const csv = toCsv([{ a: 'x,y', b: 1 }, { a: 'q"r', b: null }], ['a', 'b']);57 const back = parseCsv(csv);58 expect(back).toEqual([{ a: 'x,y', b: '1' }, { a: 'q"r', b: '' }]);59 expect(rebase([{ date: 'd1', value: 50 }, { date: 'd2', value: 75 }])[1]?.value).toBe(1500);60 });61});62