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%
4.8 KB · 90 lines typescript
Raw Blame History
1import { describe, expect, it } from 'vitest';2import { marketDepth } from './depth.js';3import { bandFor, fairPrices, liquidationModel, type ListingLifecycle } from './liquidation.js';4import { classifySale, countVerification } from './verification.js';56describe('marketDepth (§25–§26)', () => {7  it('counts asks around RIV and derives the spread from the best ask', () => {8    const d = marketDepth([950, 1000, 1040, 1150, 1300, 700], 1000)!;9    expect(d.asks).toBe(6);10    expect(d.within5).toBe(3); // 950, 1000, 104011    expect(d.within10).toBe(3);12    expect(d.within20).toBe(4); // + 115013    expect(d.belowRiv).toBe(2);14    expect(d.aboveRiv).toBe(3);15    expect(d.lowestAsk).toBe(700);16    expect(d.medianAsk).toBe(1020);17    expect(d.askRivSpread).toBeCloseTo(-0.3, 4);18  });19  it('returns null without a valuation and no spread without asks', () => {20    expect(marketDepth([100], null)).toBeNull();21    expect(marketDepth([], 100)).toMatchObject({ asks: 0, askRivSpread: null, lowestAsk: null, medianAsk: null });22    expect(marketDepth([null, -5, 0], 100)!.asks).toBe(0);23  });24});2526const 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 });2728describe('liquidationModel (§24, §30)', () => {29  it('separates sold from withdrawn and needs 5 observations per band', () => {30    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)]);31    const m = liquidationModel(rows);32    expect(m.sold.n).toBe(14);33    expect(m.withdrawn.n).toBe(2);34    expect(m.withdrawn.median).toBe(37.5);35    expect(m.sellThrough).toBeCloseTo(14 / 16, 3);36    const byBand = Object.fromEntries(m.bands.map((b) => [b.band, b]));37    expect(byBand.le90!.medianDays).toBe(5.5);38    expect(byBand.b100_110!.medianDays).toBe(22.5);39    expect(byBand.b90_100!.n).toBe(2);40    expect(byBand.b90_100!.medianDays).toBeNull(); // not enough data41    expect(byBand.gt110!.medianDays).toBeNull(); // withdrawn never count as sales42  });43  it('bands by ask / RIV ratio', () => {44    expect(bandFor(0.5)).toBe('le90');45    expect(bandFor(0.9)).toBe('le90');46    expect(bandFor(0.95)).toBe('b90_100');47    expect(bandFor(1.0)).toBe('b90_100');48    expect(bandFor(1.1)).toBe('b100_110');49    expect(bandFor(2)).toBe('gt110');50  });51});5253describe('fairPrices (§213–§214)', () => {54  it('uses only the valuation band and observed time-to-sale', () => {55    const m = liquidationModel([...Array(5)].map((_, i) => lc(4 + i, 'sold', 0.88)).concat([...Array(5)].map((_, i) => lc(15 + i, 'sold', 0.98))));56    const f = fairPrices({ riv: 1000, low: 880, high: 1120 }, m);57    expect(f.buyAggressive).toBe(880);58    expect(f.buyFair).toBe(1000);59    expect(f.sellFast).toBe(900); // max(low 880, 0.9 × RIV)60    expect(f.sellFastDays).toBe(6);61    expect(f.sellTypical).toBe(1000);62    expect(f.sellTypicalDays).toBe(17);63    expect(f.sellPatient).toBe(1120);64    expect(f.sellPatientDays).toBeNull();65  });66  it('leaves time components null without band evidence', () => {67    const f = fairPrices({ riv: 1000, low: 900, high: 1100 }, liquidationModel([lc(3, 'sold', 0.9)]));68    expect(f.sellFast).toBeNull();69    expect(f.sellFastDays).toBeNull();70    expect(f.sellTypical).toBe(1000);71    expect(fairPrices({ riv: null, low: null, high: null }, null)).toMatchObject({ buyFair: null, sellPatient: null });72  });73});7475describe('classifySale (§39)', () => {76  it('labels by status, confidence, source and sale type', () => {77    expect(classifySale({ status: 'excluded', confidence: 0.9, flags: ['bundle'] }).label).toBe('excluded');78    expect(classifySale({ status: 'flagged', confidence: 0.9, flags: ['abnormally_high_price'] })).toMatchObject({ label: 'unverified' });79    expect(classifySale({ status: 'valid', confidence: 0.5, trust: 0.9 }).label).toBe('unverified');80    expect(classifySale({ status: 'valid', confidence: 0.9, trust: 0.3 }).label).toBe('unverified');81    expect(classifySale({ status: 'valid', confidence: 0.7, sourceType: 'auction_house', trust: 0.7 }).label).toBe('verified');82    expect(classifySale({ status: 'valid', confidence: 0.85, saleType: 'auction', sourceType: 'marketplace', trust: 0.6 }).label).toBe('verified');83    expect(classifySale({ status: 'valid', confidence: 0.9, sourceType: 'marketplace', saleType: 'fixed_price', trust: 0.85 }).label).toBe('verified');84    expect(classifySale({ status: 'valid', confidence: 0.8, sourceType: 'marketplace', saleType: 'fixed_price', trust: 0.6 }).label).toBe('likely');85  });86  it('counts labels', () => {87    expect(countVerification(['verified', 'likely', 'likely', 'excluded'])).toEqual({ verified: 1, likely: 2, unverified: 0, excluded: 1 });88  });89});90