import { describe, expect, it } from 'vitest'; import { computeValuation } from './valuation.js'; import { detectOutliers } from './outliers.js'; import { adjustmentFactor, computeGradePremiums } from './premiums.js'; import { liquidityScore, rarityScore, momentumScore, valueOpportunity, trendingScore } from './scores.js'; const now = new Date('2026-09-01T00:00:00Z'); const d = (daysAgo: number) => new Date(now.getTime() - daysAgo * 86_400_000); const sale = (id: string, price: number, daysAgo: number, extra: Partial[0]['sales'][number]> = {}) => ({ id, priceUsd: price, date: d(daysAgo), trust: 0.8, confidence: 0.9, ...extra }); describe('computeValuation', () => { it('returns no number without evidence', () => { const v = computeValuation({ sales: [], now }); expect(v.riv).toBeNull(); expect(v.label).toBe('insufficient'); expect(v.basis).toBe('none'); }); it('builds a robust ensemble from transactions', () => { const sales = [sale('a', 1000, 5), sale('b', 1100, 15), sale('c', 950, 30), sale('d', 1050, 60), sale('e', 990, 90), sale('f', 5000, 20, { status: 'excluded' }), sale('g', 1200, 10, { quantity: 3 })]; const v = computeValuation({ sales, now }); expect(v.sampleSize).toBe(5); expect(v.riv!).toBeGreaterThan(950); expect(v.riv!).toBeLessThan(1100); expect(v.low!).toBeLessThanOrEqual(v.riv!); expect(v.high!).toBeGreaterThanOrEqual(v.riv!); expect(v.methods.median5).toBe(1000); expect(v.confidence).toBeGreaterThan(0.4); expect(v.salesUsed).not.toContain('f'); }); it('caps confidence for guide-only estimates', () => { const v = computeValuation({ sales: [], observations: [{ priceUsd: 120, date: d(1), priceKind: 'market' }, { priceUsd: 118, date: d(8), priceKind: 'market' }], now }); expect(v.basis).toBe('guide'); expect(v.riv).toBe(119); expect(v.confidence).toBeLessThanOrEqual(0.45); }); it('extends the window when recent evidence is thin', () => { const v = computeValuation({ sales: [sale('a', 500, 400), sale('b', 520, 500), sale('c', 480, 700)], now }); expect(v.windowDays).toBe(1095); expect(v.sampleSize).toBe(3); expect(v.confidence).toBeLessThanOrEqual(0.7); }); it('uses grade-adjusted comps when the variant has no sales', () => { const v = computeValuation({ sales: [], comps: [{ priceUsd: 100, date: d(3), adjustment: 2.5 }, { priceUsd: 110, date: d(9), adjustment: 2.5 }, { priceUsd: 90, date: d(20), adjustment: 2.5 }], now }); expect(v.basis).toBe('comps'); expect(v.riv).toBe(250); }); }); describe('outliers', () => { it('flags abnormal prices without deleting', () => { const pts = [100, 105, 98, 102, 101, 99, 1500, 3].map((p, i) => ({ id: `s${i}`, priceUsd: p, date: d(i) })); const flags = detectOutliers(pts); expect(flags.map((f) => f.id).sort()).toEqual(['s6', 's7']); expect(flags.find((f) => f.id === 's6')?.reason).toBe('abnormally_high_price'); }); it('needs at least five points', () => { expect(detectOutliers([{ id: 'a', priceUsd: 1, date: now }, { id: 'b', priceUsd: 1000, date: now }])).toEqual([]); }); }); describe('grade premiums', () => { it('measures multipliers empirically from paired assets', () => { const sales: Parameters[0] = []; for (let a = 0; a < 10; a++) { sales.push({ assetId: `A${a}`, grader: null, grade: null, priceUsd: 100 }); sales.push({ assetId: `A${a}`, grader: 'psa', grade: '10', priceUsd: 230 + a }); sales.push({ assetId: `A${a}`, grader: 'psa', grade: '9', priceUsd: 150 }); } const p = computeGradePremiums(sales, { minPairs: 8 }); const psa10 = p.find((x) => x.grade === '10')!; expect(psa10.marketMultiplier).toBeGreaterThan(2.2); expect(psa10.sampleSize).toBe(10); expect(adjustmentFactor(p, { grader: 'psa', grade: '9' }, { grader: 'psa', grade: '10' })!).toBeCloseTo(psa10.marketMultiplier / 1.5, 2); expect(adjustmentFactor(p, { grader: 'bgs', grade: '10' }, { grader: 'psa', grade: '10' })).toBeNull(); }); }); describe('scores', () => { it('liquidity and rarity stay null without evidence', () => { expect(liquidityScore({ salesPerMonth: 0, activeListings: 0, sources: 0, medianDaysBetweenSales: null, askSoldSpread: null })).toBeNull(); expect(rarityScore({ population: null, productionQuantity: null, listingsPerYear: null, salesPerYear: null, populationGrowthPct: null })).toBeNull(); }); it('orders sensibly', () => { const liquid = liquidityScore({ salesPerMonth: 40, activeListings: 60, sources: 5, medianDaysBetweenSales: 1, askSoldSpread: 0.05 })!; const illiquid = liquidityScore({ salesPerMonth: 0.2, activeListings: 1, sources: 1, medianDaysBetweenSales: 200, askSoldSpread: 0.6 })!; expect(liquid).toBeGreaterThan(80); expect(illiquid).toBeLessThan(20); expect(rarityScore({ population: 5, productionQuantity: null, listingsPerYear: 1, salesPerYear: 1, populationGrowthPct: null })!).toBeGreaterThan(rarityScore({ population: 50000, productionQuantity: null, listingsPerYear: 500, salesPerYear: 800, populationGrowthPct: null })!); expect(momentumScore({ priceChange: 0.25, volumeNow: 10, volumePrev: 5 })!).toBeGreaterThan(0); expect(valueOpportunity(2950, 3850, 0.91)).toBeCloseTo(-0.2338, 3); expect(valueOpportunity(2950, 3850, 0.3)).toBeNull(); expect(trendingScore({ priceMomentum: 50, volumeMomentum: 30, searchMomentum: null, listingMomentum: 10, newsMomentum: null })!).toBeGreaterThan(50); }); });