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%
2.8 KB · 62 lines typescript
Raw Blame History
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