Files
investor-flow/app/server/src/confluence/__tests__/confluenceBacktest.test.ts
T

164 lines
6.0 KiB
TypeScript

// 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);
});