munger-engine / tests /backtest.test.ts
dromerosm's picture
feat: validate signal quality with backtesting
f82ea23
Raw
History Blame Contribute Delete
3.27 kB
import test from 'node:test';
import assert from 'node:assert/strict';
import { runSignalBacktest, runTechnicalSignalBacktest, BacktestInput } from '../lib/backtest';
import { WeeklyQuote } from '../lib/signal-engine';
function weeklyQuote(week: number, close: number, overrides: Partial<WeeklyQuote> = {}): WeeklyQuote {
return {
date: new Date(Date.UTC(2020, 0, 3 + week * 7)),
open: close - 1,
high: close + 1,
low: close - 1,
close,
...overrides,
};
}
function weekIso(week: number): string {
const date = weeklyQuote(week, 0).date;
assert.ok(date instanceof Date);
return date.toISOString();
}
function inputWithFundamentals(asOf: string): BacktestInput {
const bars = Array.from({ length: 210 }, (_, index) => weeklyQuote(index, 100));
bars[204] = weeklyQuote(204, 101, { open: 98, low: 99, high: 102 });
bars[205] = weeklyQuote(205, 103);
bars[209] = weeklyQuote(209, 110);
return {
symbols: [
{
symbol: 'TEST',
quotes: bars,
fundamentals: [
{ asOf: new Date('2023-01-01T00:00:00.000Z'), roe: 0.05, debtToEquity: 10, marketCap: 25_000_000_000 },
{ asOf: new Date(asOf), roe: 0.25, debtToEquity: 10, marketCap: 25_000_000_000 },
],
},
],
horizonsWeeks: [4],
};
}
test('runSignalBacktest does not use fundamentals published after the signal date', () => {
const result = runSignalBacktest(inputWithFundamentals('2024-01-01T00:00:00.000Z'));
assert.equal(result.events.length, 0);
});
test('runSignalBacktest labels 4-week outcomes using only bars after the signal date', () => {
const result = runSignalBacktest(inputWithFundamentals('2023-11-01T00:00:00.000Z'));
assert.equal(result.events.length, 1);
const event = result.events[0];
assert.ok(event);
assert.equal(event.symbol, 'TEST');
assert.equal(event.signalDate.toISOString(), weekIso(204));
assert.equal(event.entryDate.toISOString(), weekIso(205));
assert.equal(event.horizonWeeks, 4);
assert.equal(event.exitDate.toISOString(), weekIso(209));
assert.equal(Number(event.forwardReturn.toFixed(4)), Number(((110 - 103) / 103).toFixed(4)));
assert.equal(result.summary.eventCount, 1);
assert.equal(result.summary.winRate, 1);
});
test('runTechnicalSignalBacktest reports quality metrics for 4 and 6 week horizons without fundamentals', () => {
const bars = Array.from({ length: 214 }, (_, index) => weeklyQuote(index, 100));
bars[204] = weeklyQuote(204, 101, { open: 98, low: 99, high: 102 });
bars[205] = weeklyQuote(205, 103);
bars[209] = weeklyQuote(209, 110);
bars[211] = weeklyQuote(211, 99);
const result = runTechnicalSignalBacktest({
symbols: [{ symbol: 'TECH', quotes: bars }],
horizonsWeeks: [4, 6],
});
assert.equal(result.summary.eventCount, 2);
assert.equal(result.summary.byHorizon[4].eventCount, 1);
assert.equal(result.summary.byHorizon[4].winRate, 1);
assert.equal(Number(result.summary.byHorizon[4].meanReturn.toFixed(4)), Number(((110 - 103) / 103).toFixed(4)));
assert.equal(result.summary.byHorizon[6].eventCount, 1);
assert.equal(result.summary.byHorizon[6].winRate, 0);
assert.equal(Number(result.summary.byHorizon[6].meanReturn.toFixed(4)), Number(((99 - 103) / 103).toFixed(4)));
});