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%
8.9 KB · 140 lines typescript
Raw Blame History
1import { describe, expect, it } from 'vitest';2import { computeValuation } from './valuation.js';3import { detectOutliers } from './outliers.js';4import { adjustmentFactor, computeGradePremiums } from './premiums.js';5import { assessAsk, dealScore, isPlausibleChange, liquidityScore, rarityScore, momentumScore, valueOpportunity, trendingScore } from './scores.js';67const now = new Date('2026-09-01T00:00:00Z');8const d = (daysAgo: number) => new Date(now.getTime() - daysAgo * 86_400_000);9const sale = (id: string, price: number, daysAgo: number, extra: Partial<Parameters<typeof computeValuation>[0]['sales'][number]> = {}) => ({ id, priceUsd: price, date: d(daysAgo), trust: 0.8, confidence: 0.9, ...extra });1011describe('computeValuation', () => {12  it('returns no number without evidence', () => {13    const v = computeValuation({ sales: [], now });14    expect(v.riv).toBeNull();15    expect(v.label).toBe('insufficient');16    expect(v.basis).toBe('none');17  });18  it('builds a robust ensemble from transactions', () => {19    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 })];20    const v = computeValuation({ sales, now });21    expect(v.sampleSize).toBe(5);22    expect(v.riv!).toBeGreaterThan(950);23    expect(v.riv!).toBeLessThan(1100);24    expect(v.low!).toBeLessThanOrEqual(v.riv!);25    expect(v.high!).toBeGreaterThanOrEqual(v.riv!);26    expect(v.methods.median5).toBe(1000);27    expect(v.confidence).toBeGreaterThan(0.4);28    expect(v.salesUsed).not.toContain('f');29  });30  it('caps confidence for guide-only estimates', () => {31    const v = computeValuation({ sales: [], observations: [{ priceUsd: 120, date: d(1), priceKind: 'market' }, { priceUsd: 118, date: d(8), priceKind: 'market' }], now });32    expect(v.basis).toBe('guide');33    expect(v.riv).toBe(119);34    expect(v.confidence).toBeLessThanOrEqual(0.45);35  });36  it('extends the window when recent evidence is thin', () => {37    const v = computeValuation({ sales: [sale('a', 500, 400), sale('b', 520, 500), sale('c', 480, 700)], now });38    expect(v.windowDays).toBe(1095);39    expect(v.sampleSize).toBe(3);40    expect(v.confidence).toBeLessThanOrEqual(0.7);41  });42  it('uses grade-adjusted comps when the variant has no sales', () => {43    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 });44    expect(v.basis).toBe('comps');45    expect(v.riv).toBe(250);46  });47});4849describe('outliers', () => {50  it('flags abnormal prices without deleting', () => {51    const pts = [100, 105, 98, 102, 101, 99, 1500, 3].map((p, i) => ({ id: `s${i}`, priceUsd: p, date: d(i) }));52    const flags = detectOutliers(pts);53    expect(flags.map((f) => f.id).sort()).toEqual(['s6', 's7']);54    expect(flags.find((f) => f.id === 's6')?.reason).toBe('abnormally_high_price');55  });56  it('needs at least five points', () => {57    expect(detectOutliers([{ id: 'a', priceUsd: 1, date: now }, { id: 'b', priceUsd: 1000, date: now }])).toEqual([]);58  });59});6061describe('grade premiums', () => {62  it('measures multipliers empirically from paired assets', () => {63    const sales: Parameters<typeof computeGradePremiums>[0] = [];64    for (let a = 0; a < 10; a++) {65      sales.push({ assetId: `A${a}`, grader: null, grade: null, priceUsd: 100 });66      sales.push({ assetId: `A${a}`, grader: 'psa', grade: '10', priceUsd: 230 + a });67      sales.push({ assetId: `A${a}`, grader: 'psa', grade: '9', priceUsd: 150 });68    }69    const p = computeGradePremiums(sales, { minPairs: 8 });70    const psa10 = p.find((x) => x.grade === '10')!;71    expect(psa10.marketMultiplier).toBeGreaterThan(2.2);72    expect(psa10.sampleSize).toBe(10);73    expect(adjustmentFactor(p, { grader: 'psa', grade: '9' }, { grader: 'psa', grade: '10' })!).toBeCloseTo(psa10.marketMultiplier / 1.5, 2);74    expect(adjustmentFactor(p, { grader: 'bgs', grade: '10' }, { grader: 'psa', grade: '10' })).toBeNull();75  });76});7778describe('scores', () => {79  it('liquidity and rarity stay null without evidence', () => {80    expect(liquidityScore({ salesPerMonth: 0, activeListings: 0, sources: 0, medianDaysBetweenSales: null, askSoldSpread: null })).toBeNull();81    expect(rarityScore({ population: null, productionQuantity: null, listingsPerYear: null, salesPerYear: null, populationGrowthPct: null })).toBeNull();82  });83  it('orders sensibly', () => {84    const liquid = liquidityScore({ salesPerMonth: 40, activeListings: 60, sources: 5, medianDaysBetweenSales: 1, askSoldSpread: 0.05 })!;85    const illiquid = liquidityScore({ salesPerMonth: 0.2, activeListings: 1, sources: 1, medianDaysBetweenSales: 200, askSoldSpread: 0.6 })!;86    expect(liquid).toBeGreaterThan(80);87    expect(illiquid).toBeLessThan(20);88    expect(rarityScore({ population: 5, productionQuantity: null, listingsPerYear: 1, salesPerYear: 1, populationGrowthPct: null })!).toBeGreaterThan(rarityScore({ population: 50000, productionQuantity: null, listingsPerYear: 500, salesPerYear: 800, populationGrowthPct: null })!);89    expect(momentumScore({ priceChange: 0.25, volumeNow: 10, volumePrev: 5 })!).toBeGreaterThan(0);90    expect(valueOpportunity(2950, 3850, 0.91)).toBeCloseTo(-0.2338, 3);91    expect(valueOpportunity(2950, 3850, 0.3)).toBeNull();92    expect(valueOpportunity(2950, 3850, 0.91, 2)).toBeNull(); // thin sample never qualifies an ask93    expect(trendingScore({ priceMomentum: 50, volumeMomentum: 30, searchMomentum: null, listingMomentum: 10, newsMomentum: null })!).toBeGreaterThan(50);94  });95});9697describe('ask vs RIV gate (§83–§84, §196)', () => {98  it('signs a discount negative and classifies deal / fair / premium', () => {99    expect(assessAsk({ askUsd: 2650, rivUsd: 3180, confidence: 0.8, sampleSize: 42, basis: 'transactions' })).toMatchObject({ verdict: 'deal', discount: -0.1667 });100    expect(assessAsk({ askUsd: 3200, rivUsd: 3180, confidence: 0.8, sampleSize: 42 }).verdict).toBe('fair');101    expect(assessAsk({ askUsd: 4000, rivUsd: 3180, confidence: 0.8, sampleSize: 42 }).verdict).toBe('premium');102  });103  it('never turns the 1986 Fleer case into a deal: a $472 slab against a $5.48 raw RIV is an anomaly', () => {104    const a = assessAsk({ askUsd: 472.2, rivUsd: 5.48, confidence: 0.78, sampleSize: 18, basis: 'transactions' });105    expect(a.verdict).toBe('anomaly');106    expect(a.discount).toBeNull();107    expect(a.ratio).toBeCloseTo(86.17, 1);108    expect(assessAsk({ askUsd: 0.4, rivUsd: 5.48, confidence: 0.78, sampleSize: 18 }).verdict).toBe('anomaly'); // implausibly low109  });110  it('refuses comps/guide valuations, thin samples, low confidence and variant mismatches', () => {111    expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 20, basis: 'comps' })).toMatchObject({ verdict: 'ungated', reasons: ['riv_basis_comps'] });112    expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 3 }).reasons).toContain('riv_sample');113    expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.45, sampleSize: 30 }).reasons).toContain('riv_confidence');114    expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 30, sameVariant: false }).reasons).toContain('variant_mismatch');115    expect(assessAsk({ askUsd: 100, rivUsd: 120, confidence: 0.9, sampleSize: 30, matchConfidence: 0.5 }).reasons).toContain('match_confidence');116  });117  it('adds fees before comparing', () => {118    expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30, feesUsd: 150 }).verdict).toBe('fair');119    expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30 }).verdict).toBe('deal');120  });121  it('sends asks more than 50 % below RIV to review instead of calling them deals (§174)', () => {122    const a = assessAsk({ askUsd: 115.8, rivUsd: 1077.54, confidence: 0.8, sampleSize: 58 }); // a booster pack ask matched to a booster-box RIV123    expect(a.verdict).toBe('review');124    expect(a.discount).toBeCloseTo(-0.8925, 3);125    expect(valueOpportunity(115.8, 1077.54, 0.8, 58)).toBeNull();126    expect(dealScore({ discount: -0.89, confidence: 0.8, sampleSize: 58, liquidity: 60 })).toBe(0);127    expect(assessAsk({ askUsd: 520, rivUsd: 1000, confidence: 0.8, sampleSize: 58 }).verdict).toBe('deal');128  });129  it('deal score is 0 without a gated discount and rewards confidence, depth and liquidity', () => {130    expect(dealScore({ discount: null, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0);131    expect(dealScore({ discount: 0.2, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0);132    const strong = dealScore({ discount: -0.3, confidence: 0.9, sampleSize: 40, liquidity: 80 });133    const weak = dealScore({ discount: -0.3, confidence: 0.55, sampleSize: 6, liquidity: 10 });134    expect(strong).toBeGreaterThan(weak);135    expect(strong).toBeLessThanOrEqual(100);136    expect(isPlausibleChange(85.17)).toBe(false);137    expect(isPlausibleChange(0.42)).toBe(true);138  });139});140