164 lines
6.0 KiB
TypeScript
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);
|
|
}); |