From 358286ce184438f61c987a6ee3ffd86166905031 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 10 Aug 2026 15:18:21 -0400 Subject: [PATCH] feat(confluence): add slot backtest, follow-through resolver, and reliability scorecard (M22 slice 4) --- .../__tests__/confluenceBacktest.test.ts | 164 ++++++++++ .../src/confluence/confluenceBacktest.ts | 300 ++++++++++++++++++ app/server/src/db/confluenceRepository.ts | 8 + 3 files changed, 472 insertions(+) create mode 100644 app/server/src/confluence/__tests__/confluenceBacktest.test.ts create mode 100644 app/server/src/confluence/confluenceBacktest.ts diff --git a/app/server/src/confluence/__tests__/confluenceBacktest.test.ts b/app/server/src/confluence/__tests__/confluenceBacktest.test.ts new file mode 100644 index 0000000..b360f4e --- /dev/null +++ b/app/server/src/confluence/__tests__/confluenceBacktest.test.ts @@ -0,0 +1,164 @@ +// 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); +}); \ No newline at end of file diff --git a/app/server/src/confluence/confluenceBacktest.ts b/app/server/src/confluence/confluenceBacktest.ts new file mode 100644 index 0000000..7ef4802 --- /dev/null +++ b/app/server/src/confluence/confluenceBacktest.ts @@ -0,0 +1,300 @@ +// Investor Flow — Confluence Backtest + Signal Resolver (M22, slice 4) +// +// The closed loop: every slot fire is logged to confluence_signal_history, then +// resolved to 'real' | 'false_alarm' by N-week price follow-through aligned with +// the slot body (bull slots expect the price up, bear/exit slots expect it down). +// +// `runSlotBacktest` answers "when this slot fired, did price follow through N +// weeks later?" for a corpus of fire events — the raw material for a per-slot +// reliability scorecard that teaches the rack which confluences deserve weight. +// +// `resolveSignalHistory` walks pending `confluence_signal_history` rows and flips +// them to confirmed / false_alarm once enough forward bars exist. Mirrors the +// rotation_signals gamma two-stage, but for confluence slots. +// +// Pure where possible: candle math is pure; the resolver is a thin DB+data shim. + +import type { PriceCandle } from '../cache/CacheRepository.ts'; +import { confluenceslotById, type ConfluenceSlot } from './confluenceSlots.ts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** One recorded slot fire at a date, as the backtest input unit. */ +export interface ConfluenceFireEvent { + symbol: string; + /** YYYY-MM-DD the slot fired (as-of of the evaluation that fired it). */ + asOf: string; + slotId: string; +} + +/** Directional expectation of a slot body for follow-through resolution. */ +export type FollowThrough = 'up' | 'down'; + +/** Per-slot follow-through stats across the backtest corpus. */ +export interface SlotFollowThroughStats { + slotId: string; + name: string; + body: ConfluenceSlot['body']; + direction: FollowThrough; + fires: number; + /** Fires with enough forward bars to resolve. */ + resolvedFires: number; + /** Resolved fires where price moved the expected way. */ + confirmedFires: number; + /** resolved - confirmed (price moved the opposite way). */ + falseAlarms: number; + /** confirmed / resolved over resolvedFires; null when none resolved. */ + hitRate: number | null; + /** 0..1 multiplier a rack can apply to this slot's weight. */ + reliabilityWeight: number; +} + +/** Whole backtest summary (ADR-0007-safe: no directional advice). */ +export interface ConfluenceBacktestResult { + symbol: string; + fires: number; + /** Calendar-day horizon used (weeks * 7). */ + horizonDays: number; + slots: SlotFollowThroughStats[]; + sampleSizeCaveat: string; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/** Expected follow-through direction for a slot: bull ⇒ up, bear/exit ⇒ down. */ +export function expectedDirection(slotId: string): FollowThrough | null { + const slot = confluenceslotById.get(slotId); + if (!slot) return null; + return slot.body === 'bull' ? 'up' : 'down'; +} + +/** The candle index whose ts-date is `asOf`, or -1. Candles sorted ascending. */ +export function candleIndexForDate(candles: PriceCandle[], asOf: string): number { + for (let i = 0; i < candles.length; i++) { + const ts = (candles[i].ts ?? '').slice(0, 10); + if (ts === asOf) return i; + } + return -1; +} + +/** + * Pure follow-through check: did price move the expected way `weeks` later? + * Uses the close `weeks*TRADING_DAYS_PER_WEEK` bars after the fire bar and + * permits a small dead-band so a whipsaw to flat is treated as unresolved + * rather than a confirmation or false alarm. Returns null when there is not + * enough forward data to resolve. + */ +export function checkFollowThrough( + candles: PriceCandle[], + fireIndex: number, + direction: FollowThrough, + weeks = 4, + deadBandPct = 0.5, +): boolean | null { + const step = weeks * 5; + const target = fireIndex + step; + if (target >= candles.length) return null; + + const entry = candles[fireIndex].c; + const exit = candles[target].c; + if (!Number.isFinite(entry) || !Number.isFinite(exit) || entry <= 0) return null; + + const retPct = ((exit - entry) / entry) * 100; + if (Math.abs(retPct) < deadBandPct) return null; + return direction === 'up' ? retPct > 0 : retPct < 0; +} + +/** + * Backtest a corpus of fire events against candles: per-slot follow-through + * stats over `weeks`-week forward windows. Pure. + */ +export function runSlotBacktest( + symbol: string, + candles: PriceCandle[], + fires: ConfluenceFireEvent[], + weeks = 4, +): ConfluenceBacktestResult { + const hitsBySlot = new Map(); + + for (const fire of fires) { + if (fire.symbol !== symbol) continue; + const dir = expectedDirection(fire.slotId); + if (dir === null) continue; + + const row = hitsBySlot.get(fire.slotId) ?? { fires: 0, resolved: 0, confirmed: 0 }; + row.fires += 1; + + const idx = candleIndexForDate(candles, fire.asOf); + if (idx >= 0) { + const confirmed = checkFollowThrough(candles, idx, dir, weeks); + if (confirmed !== null) { + row.resolved += 1; + if (confirmed) row.confirmed += 1; + } + } + hitsBySlot.set(fire.slotId, row); + } + + const slots: SlotFollowThroughStats[] = [...hitsBySlot.entries()].map(([slotId, s]) => { + const slot = confluenceslotById.get(slotId); + const hitRate = s.resolved > 0 ? s.confirmed / s.resolved : null; + return { + slotId, + name: slot?.name ?? slotId, + body: slot?.body ?? 'bull', + direction: expectedDirection(slotId) ?? 'up', + fires: s.fires, + resolvedFires: s.resolved, + confirmedFires: s.confirmed, + falseAlarms: s.resolved - s.confirmed, + hitRate, + reliabilityWeight: reliabilityWeightFrom(s.resolved, s.confirmed), + }; + }).sort((a, b) => (b.hitRate ?? 0) - (a.hitRate ?? 0)); + + const totalFires = slots.reduce((acc, s) => acc + s.fires, 0); + return { + symbol, + fires: totalFires, + horizonDays: weeks * 7, + slots, + sampleSizeCaveat: + totalFires < 30 + ? `Sample size of ${totalFires} slot-fires is limited. Reliability estimates may not generalize.` + : `Sample size: ${totalFires} slot-fires.`, + }; +} + +/** + * A defensible reliability multiplier in [0.5, 1.25] from a slot's resolved + * sample: 100% and ≥8 resolved ⇒ 1.25×; worse-than-coin-flip or <2 resolved ⇒ + * 0.5×; otherwise linear interpolation across [50%, 75%] hit rate. The rack can + * multiply per-slot evidence by this to self-tune. Pure. + */ +export function reliabilityWeightFrom(resolvedFires: number, confirmedFires: number): number { + if (resolvedFires <= 0) return 0.5; + const rate = confirmedFires / resolvedFires; + if (resolvedFires >= 8 && rate >= 0.9) return 1.25; + if (rate <= 0.5 || resolvedFires < 2) return 0.5; + if (resolvedFires < 8) return 0.75; // thin sample: hold confidence down + return Math.min(1.25, 0.5 + (rate - 0.5) * 2); +} + +// --------------------------------------------------------------------------- +// Signal-history resolver (thin service shim over the repository) +// --------------------------------------------------------------------------- + +import type { DatabaseSync } from 'node:sqlite'; +import { ConfluenceRepository, type ConfluenceSignalHistoryRow } from '../db/confluenceRepository.ts'; + +/** Result of resolving one pending signal. */ +export interface SignalResolutionOutcome { + signalId: string; + symbol: string; + slotId: string; + verdict: 'real' | 'false_alarm' | 'deferred'; + priceConfirmed: boolean | null; + note: string; +} + +export interface ResolvedSignalsResult { + resolved: SignalResolutionOutcome[]; +} + +/** + * Resolve pending `confluence_signal_history` rows using forward candle data. + * + * @param db database handle + * @param candleProvider async lookup of a symbol's daily candles (sorted) + * @param weeks follow-through window (default 4) + */ +export async function resolveSignalHistory( + db: DatabaseSync, + candleProvider: (symbol: string) => Promise, + weeks = 4, +): Promise { + const repo = new ConfluenceRepository(db); + const pending = repo.listPendingSignals(); + if (pending.length === 0) return { resolved: [] }; + + const resolved: SignalResolutionOutcome[] = []; + + for (const signal of pending) { + if (signal.verdict !== null) continue; // already resolved (guard) + const dir = expectedDirection(signal.slotId); + + const outcome = await resolveOneSignal(signal, repo, candleProvider, dir, weeks); + resolved.push(outcome); + } + + return { resolved }; +} + +async function resolveOneSignal( + signal: ConfluenceSignalHistoryRow, + repo: ConfluenceRepository, + candleProvider: (symbol: string) => Promise, + direction: FollowThrough | null, + weeks: number, +): Promise { + try { + const candles = await candleProvider(signal.symbol); + const idx = candleIndexForDate(candles, signal.firedAt); + if (direction !== null && idx >= 0) { + const confirmed = checkFollowThrough(candles, idx, direction, weeks); + if (confirmed !== null) { + repo.resolveSignal(signal.id, confirmed, confirmed ? 'real' : 'false_alarm'); + return { + signalId: signal.id, + symbol: signal.symbol, + slotId: signal.slotId, + verdict: confirmed ? 'real' : 'false_alarm', + priceConfirmed: confirmed, + note: `follow-through ${confirmed ? 'confirmed' : 'failed'} over ${weeks} weeks`, + }; + } + return { signalId: signal.id, symbol: signal.symbol, slotId: signal.slotId, verdict: 'deferred', priceConfirmed: null, note: 'not enough forward bars yet' }; + } + return { signalId: signal.id, symbol: signal.symbol, slotId: signal.slotId, verdict: 'deferred', priceConfirmed: null, note: 'no fire bar or unknown slot' }; + } catch (e) { + return { signalId: signal.id, symbol: signal.symbol, slotId: signal.slotId, verdict: 'deferred', priceConfirmed: null, note: `candle fetch error: ${String(e)}` }; + } +} + +/** Extract per-slot stats from a live DB for the scorecard view. */ +export function signalHistoryToStats(db: DatabaseSync, symbol?: string): SlotFollowThroughStats[] { + const repo = new ConfluenceRepository(db); + const rows = symbol ? repo.listSignalsForSymbol(symbol) : repo.listAllSignals(); + + const bySlot = new Map(); + for (const s of rows) { + const row = bySlot.get(s.slotId) ?? { fires: 0, resolved: 0, confirmed: 0 }; + row.fires += 1; + if (s.priceResolved) { + row.resolved += 1; + if (s.priceConfirmed) row.confirmed += 1; + } + bySlot.set(s.slotId, row); + } + + return [...bySlot.entries()].map(([slotId, s]) => { + const slot = confluenceslotById.get(slotId); + const hitRate = s.resolved > 0 ? s.confirmed / s.resolved : null; + return { + slotId, + name: slot?.name ?? slotId, + body: slot?.body ?? 'bull', + direction: expectedDirection(slotId) ?? 'up', + fires: s.fires, + resolvedFires: s.resolved, + confirmedFires: s.confirmed, + falseAlarms: s.resolved - s.confirmed, + hitRate, + reliabilityWeight: reliabilityWeightFrom(s.resolved, s.confirmed), + }; + }); +} \ No newline at end of file diff --git a/app/server/src/db/confluenceRepository.ts b/app/server/src/db/confluenceRepository.ts index c8c842e..83f779f 100644 --- a/app/server/src/db/confluenceRepository.ts +++ b/app/server/src/db/confluenceRepository.ts @@ -141,6 +141,10 @@ function stmts(db: DatabaseSync) { `SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict FROM confluence_signal_history WHERE price_resolved = 0 ORDER BY fired_at`, ), + selectAllSignals: db.prepare( + `SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict + FROM confluence_signal_history ORDER BY fired_at`, + ), selectSignalsForSymbol: db.prepare( `SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict FROM confluence_signal_history WHERE symbol = ? ORDER BY fired_at DESC`, @@ -324,6 +328,10 @@ export class ConfluenceRepository { return (stmts(this.db).selectPendingSignals.all() as Record[]).map(mapSignal); } + listAllSignals(): ConfluenceSignalHistoryRow[] { + return (stmts(this.db).selectAllSignals.all() as Record[]).map(mapSignal); + } + listSignalsForSymbol(symbol: string): ConfluenceSignalHistoryRow[] { return (stmts(this.db).selectSignalsForSymbol.all(symbol) as Record[]).map(mapSignal); }