// Investor Flow — confluenceBacktest.test.ts // Pure follow-through math + resolvability over synthetic candles. import { describe, it, test } from 'node:test'; import assert from 'node:assert/strict'; import { candleIndexForDate, checkFollowThrough, expectedDirection, reliabilityWeightFrom, runSlotBacktest, resolveSignalHistory, } from '../confluenceBacktest.ts'; import type { PriceCandle } from '../../cache/CacheRepository.ts'; const day = (offset: number): string => { const d = new Date(Date.UTC(2025, 0, 5 + offset)); return d.toISOString().slice(0, 10); }; function candles(startOffset = 0, n = 120, step = 1): PriceCandle[] { const out: PriceCandle[] = []; let price = 100; for (let i = 0; i < n; i++) { price += step; out.push({ ts: day(startOffset + i), o: price, h: price + 1, l: price - 1, c: price, v: 1e6, adjClose: price }); } return out; } describe('expectedDirection', () => { it('maps bull slots to up and exit/bear slots to down', () => { assert.equal(expectedDirection('goldenCross'), 'up'); assert.equal(expectedDirection('deathCross'), 'down'); assert.equal(expectedDirection('insiderInformedSell'), 'down'); }); it('returns null for unknown slots', () => { assert.equal(expectedDirection('notASlot'), null); }); }); describe('candleIndexForDate', () => { it('locates the exact candle ts-date', () => { const cs = candles(); assert.equal(candleIndexForDate(cs, day(7)), 7); }); it('returns -1 when the date is absent', () => { assert.equal(candleIndexForDate(candles(), '1999-01-01'), -1); }); }); describe('checkFollowThrough', () => { it('resolves true for an uptrend with up direction', () => { // Up movement: entry at idx 0 close=100, 20 bars later close=120 → +20%. const cs = candles(0, 30, 1); assert.equal(checkFollowThrough(cs, 0, 'up', 4, 0.5), true); }); it('resolves false for an uptrend with down direction', () => { const cs = candles(0, 30, 1); assert.equal(checkFollowThrough(cs, 0, 'down', 4, 0.5), false); }); it('returns null when not enough forward bars exist', () => { const cs = candles(0, 10, 1); // only 10 bars; need 20 forward assert.equal(checkFollowThrough(cs, 5, 'up', 4, 0.5), null); }); it('returns null inside the dead band (flat outcome)', () => { const cs = candles(0, 30, 0.001); // ~0.1% per bar, likely flat over 20 const r = checkFollowThrough(cs, 0, 'up', 4, 5); assert.equal(r, null); }); }); describe('runSlotBacktest', () => { it('computes per-slot hit rates from a fire corpus', () => { const cs = candles(0, 60, 1); const fires = [ { symbol: 'PLTR', asOf: day(0), slotId: 'goldenCross' }, { symbol: 'PLTR', asOf: day(5), slotId: 'goldenCross' }, { symbol: 'PLTR', asOf: day(10), slotId: 'deathCross' }, { symbol: 'PLTR', asOf: day(15), slotId: 'deathCross' }, ]; const result = runSlotBacktest('PLTR', cs, fires, 4); const gc = result.slots.find((s) => s.slotId === 'goldenCross')!; const dc = result.slots.find((s) => s.slotId === 'deathCross')!; // Both golden crosses resolve up-confirmed (both fire bars have 20 forward bars in a 60-bar series). assert.equal(gc.fires, 2); assert.equal(gc.confirmedFires, 2); assert.equal(gc.hitRate, 1); // Death crosses expect the price down; uptrend means both are false alarms. assert.equal(dc.fires, 2); assert.equal(dc.confirmedFires, 0); assert.equal(dc.falseAlarms, 2); assert.equal(dc.hitRate, 0); }); it('flags thin samples in the caveat', () => { const result = runSlotBacktest('PLTR', candles(), [ { symbol: 'PLTR', asOf: day(0), slotId: 'goldenCross' }, ], 4); assert.ok(result.sampleSizeCaveat.includes('limited')); }); it('ignores fires for other symbols and unknown slots', () => { const cs = candles(); const result = runSlotBacktest('PLTR', cs, [ { symbol: 'NVDA', asOf: day(0), slotId: 'goldenCross' }, { symbol: 'PLTR', asOf: day(0), slotId: 'bogus' }, ], 4); assert.equal(result.fires, 0); assert.equal(result.slots.length, 0); }); it('computes reliabilityWeight from sample', () => { assert.equal(reliabilityWeightFrom(0, 0), 0.5); assert.equal(reliabilityWeightFrom(10, 10), 1.25); assert.equal(reliabilityWeightFrom(10, 4), 0.5); assert.equal(reliabilityWeightFrom(3, 3), 0.75); // thin sample dampened }); }); test('resolveSignalHistory defers when forward bars are insufficient, resolves when available', async () => { const { createDb, initSchema } = await import('../../db/client.ts'); const { ConfluenceRepository, rackFromSlots } = await import('../../db/confluenceRepository.ts'); const { evaluateRack } = await import('../confluenceRack.ts'); const db = createDb({ path: ':memory:' }); initSchema(db); const repo = new ConfluenceRepository(db); repo.saveRack(rackFromSlots('rt', 'Rack T', ['goldenCross'], { isSystem: true })); // 15 fire events spread over dates with only 10 bars after the last → most deferred. // Build a series of 22 candles (2 more than needed for a fire at bar 0 + 20). const cs = candles(0, 22, 1); for (let i = 0; i < 3; i++) { const ev = evaluateRack('PLTR', day(i * 2), [ { id: 'goldenCross', state: 'fired' as const }, { id: 'relVolume', state: 'not-fired' as const }, ]); repo.saveEvaluation(ev, 'rt', `ev-${i}`); repo.logSignalFires(ev, 'rt'); } const candleLookup = async (symbol: string) => (symbol === 'PLTR' ? cs : []); // First pass: fires near the end may be deferred. await resolveSignalHistory(db, candleLookup, 4); const allSignals = db.prepare( `SELECT id, price_resolved, verdict FROM confluence_signal_history ORDER BY fired_at`, ).all() as { id: string; price_resolved: number; verdict: string | null }[]; assert.equal(allSignals.length, 3); assert.equal(allSignals[0].price_resolved, 1); assert.equal(allSignals[0].verdict, 'real'); // Later fires (asOf day 4) have fewer than 20 forward bars → deferred. assert.equal(allSignals[2].price_resolved, 0); });