import { describe, expect, it } from 'vitest'; import { marketDepth } from './depth.js'; import { bandFor, fairPrices, liquidationModel, type ListingLifecycle } from './liquidation.js'; import { classifySale, countVerification } from './verification.js'; describe('marketDepth (§25–§26)', () => { it('counts asks around RIV and derives the spread from the best ask', () => { const d = marketDepth([950, 1000, 1040, 1150, 1300, 700], 1000)!; expect(d.asks).toBe(6); expect(d.within5).toBe(3); // 950, 1000, 1040 expect(d.within10).toBe(3); expect(d.within20).toBe(4); // + 1150 expect(d.belowRiv).toBe(2); expect(d.aboveRiv).toBe(3); expect(d.lowestAsk).toBe(700); expect(d.medianAsk).toBe(1020); expect(d.askRivSpread).toBeCloseTo(-0.3, 4); }); it('returns null without a valuation and no spread without asks', () => { expect(marketDepth([100], null)).toBeNull(); expect(marketDepth([], 100)).toMatchObject({ asks: 0, askRivSpread: null, lowestAsk: null, medianAsk: null }); expect(marketDepth([null, -5, 0], 100)!.asks).toBe(0); }); }); const lc = (days: number, outcome: ListingLifecycle['outcome'], askToRiv?: number): ListingLifecycle => ({ firstSeen: new Date('2026-01-01T00:00:00Z'), lastSeen: new Date(Date.UTC(2026, 0, 1 + days)), outcome, askToRiv }); describe('liquidationModel (§24, §30)', () => { it('separates sold from withdrawn and needs 5 observations per band', () => { const rows = [...Array(6)].map((_, i) => lc(3 + i, 'sold', 0.85)).concat([...Array(6)].map((_, i) => lc(20 + i, 'sold', 1.05)), [lc(9, 'sold', 0.95), lc(11, 'sold', 0.97)], [lc(40, 'ended', 1.2), lc(35, 'removed', 1.3)]); const m = liquidationModel(rows); expect(m.sold.n).toBe(14); expect(m.withdrawn.n).toBe(2); expect(m.withdrawn.median).toBe(37.5); expect(m.sellThrough).toBeCloseTo(14 / 16, 3); const byBand = Object.fromEntries(m.bands.map((b) => [b.band, b])); expect(byBand.le90!.medianDays).toBe(5.5); expect(byBand.b100_110!.medianDays).toBe(22.5); expect(byBand.b90_100!.n).toBe(2); expect(byBand.b90_100!.medianDays).toBeNull(); // not enough data expect(byBand.gt110!.medianDays).toBeNull(); // withdrawn never count as sales }); it('bands by ask / RIV ratio', () => { expect(bandFor(0.5)).toBe('le90'); expect(bandFor(0.9)).toBe('le90'); expect(bandFor(0.95)).toBe('b90_100'); expect(bandFor(1.0)).toBe('b90_100'); expect(bandFor(1.1)).toBe('b100_110'); expect(bandFor(2)).toBe('gt110'); }); }); describe('fairPrices (§213–§214)', () => { it('uses only the valuation band and observed time-to-sale', () => { const m = liquidationModel([...Array(5)].map((_, i) => lc(4 + i, 'sold', 0.88)).concat([...Array(5)].map((_, i) => lc(15 + i, 'sold', 0.98)))); const f = fairPrices({ riv: 1000, low: 880, high: 1120 }, m); expect(f.buyAggressive).toBe(880); expect(f.buyFair).toBe(1000); expect(f.sellFast).toBe(900); // max(low 880, 0.9 × RIV) expect(f.sellFastDays).toBe(6); expect(f.sellTypical).toBe(1000); expect(f.sellTypicalDays).toBe(17); expect(f.sellPatient).toBe(1120); expect(f.sellPatientDays).toBeNull(); }); it('leaves time components null without band evidence', () => { const f = fairPrices({ riv: 1000, low: 900, high: 1100 }, liquidationModel([lc(3, 'sold', 0.9)])); expect(f.sellFast).toBeNull(); expect(f.sellFastDays).toBeNull(); expect(f.sellTypical).toBe(1000); expect(fairPrices({ riv: null, low: null, high: null }, null)).toMatchObject({ buyFair: null, sellPatient: null }); }); }); describe('classifySale (§39)', () => { it('labels by status, confidence, source and sale type', () => { expect(classifySale({ status: 'excluded', confidence: 0.9, flags: ['bundle'] }).label).toBe('excluded'); expect(classifySale({ status: 'flagged', confidence: 0.9, flags: ['abnormally_high_price'] })).toMatchObject({ label: 'unverified' }); expect(classifySale({ status: 'valid', confidence: 0.5, trust: 0.9 }).label).toBe('unverified'); expect(classifySale({ status: 'valid', confidence: 0.9, trust: 0.3 }).label).toBe('unverified'); expect(classifySale({ status: 'valid', confidence: 0.7, sourceType: 'auction_house', trust: 0.7 }).label).toBe('verified'); expect(classifySale({ status: 'valid', confidence: 0.85, saleType: 'auction', sourceType: 'marketplace', trust: 0.6 }).label).toBe('verified'); expect(classifySale({ status: 'valid', confidence: 0.9, sourceType: 'marketplace', saleType: 'fixed_price', trust: 0.85 }).label).toBe('verified'); expect(classifySale({ status: 'valid', confidence: 0.8, sourceType: 'marketplace', saleType: 'fixed_price', trust: 0.6 }).label).toBe('likely'); }); it('counts labels', () => { expect(countVerification(['verified', 'likely', 'likely', 'excluded'])).toEqual({ verified: 1, likely: 2, unverified: 0, excluded: 1 }); }); });