import { describe, expect, it } from 'vitest'; import { computeValuation } from './valuation.js'; import { detectOutliers } from './outliers.js'; import { adjustmentFactor, computeGradePremiums } from './premiums.js'; import { assessAsk, dealScore, isPlausibleChange, 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(valueOpportunity(2950, 3850, 0.91, 2)).toBeNull(); // thin sample never qualifies an ask expect(trendingScore({ priceMomentum: 50, volumeMomentum: 30, searchMomentum: null, listingMomentum: 10, newsMomentum: null })!).toBeGreaterThan(50); }); }); describe('ask vs RIV gate (§83–§84, §196)', () => { it('signs a discount negative and classifies deal / fair / premium', () => { expect(assessAsk({ askUsd: 2650, rivUsd: 3180, confidence: 0.8, sampleSize: 42, basis: 'transactions' })).toMatchObject({ verdict: 'deal', discount: -0.1667 }); expect(assessAsk({ askUsd: 3200, rivUsd: 3180, confidence: 0.8, sampleSize: 42 }).verdict).toBe('fair'); expect(assessAsk({ askUsd: 4000, rivUsd: 3180, confidence: 0.8, sampleSize: 42 }).verdict).toBe('premium'); }); it('never turns the 1986 Fleer case into a deal: a $472 slab against a $5.48 raw RIV is an anomaly', () => { const a = assessAsk({ askUsd: 472.2, rivUsd: 5.48, confidence: 0.78, sampleSize: 18, basis: 'transactions' }); expect(a.verdict).toBe('anomaly'); expect(a.discount).toBeNull(); expect(a.ratio).toBeCloseTo(86.17, 1); expect(assessAsk({ askUsd: 0.4, rivUsd: 5.48, confidence: 0.78, sampleSize: 18 }).verdict).toBe('anomaly'); // implausibly low }); it('refuses comps/guide valuations, thin samples, low confidence and variant mismatches', () => { expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 20, basis: 'comps' })).toMatchObject({ verdict: 'ungated', reasons: ['riv_basis_comps'] }); expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 3 }).reasons).toContain('riv_sample'); expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.45, sampleSize: 30 }).reasons).toContain('riv_confidence'); expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 30, sameVariant: false }).reasons).toContain('variant_mismatch'); expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 30, matchConfidence: 0.5 }).reasons).toContain('match_confidence'); }); it('adds fees before comparing', () => { expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30, feesUsd: 150 }).verdict).toBe('fair'); expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30 }).verdict).toBe('deal'); }); it('sends asks more than 50 % below RIV to review instead of calling them deals (§174)', () => { const a = assessAsk({ askUsd: 115.8, rivUsd: 1077.54, confidence: 0.8, sampleSize: 58 }); // a booster pack ask matched to a booster-box RIV expect(a.verdict).toBe('review'); expect(a.discount).toBeCloseTo(-0.8925, 3); expect(valueOpportunity(115.8, 1077.54, 0.8, 58)).toBeNull(); expect(dealScore({ discount: -0.89, confidence: 0.8, sampleSize: 58, liquidity: 60 })).toBe(0); expect(assessAsk({ askUsd: 520, rivUsd: 1000, confidence: 0.8, sampleSize: 58 }).verdict).toBe('deal'); }); it('deal score is 0 without a gated discount and rewards confidence, depth and liquidity', () => { expect(dealScore({ discount: null, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); expect(dealScore({ discount: 0.2, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); const strong = dealScore({ discount: -0.3, confidence: 0.9, sampleSize: 40, liquidity: 80 }); const weak = dealScore({ discount: -0.3, confidence: 0.55, sampleSize: 6, liquidity: 10 }); expect(strong).toBeGreaterThan(weak); expect(strong).toBeLessThanOrEqual(100); expect(isPlausibleChange(85.17)).toBe(false); expect(isPlausibleChange(0.42)).toBe(true); }); });