munger-engine / lib /backtest.ts
dromerosm's picture
feat: validate signal quality with backtesting
f82ea23
Raw
History Blame Contribute Delete
7.77 kB
import { evaluateMungerSignal } from './signal-engine';
import type { SignalFundamentals, WeeklyQuote } from './signal-engine';
export interface FundamentalsPoint extends SignalFundamentals {
asOf: Date;
}
export interface BacktestSymbolInput {
symbol: string;
quotes: WeeklyQuote[];
fundamentals: FundamentalsPoint[];
}
export interface BacktestInput {
symbols: BacktestSymbolInput[];
horizonsWeeks: number[];
}
export interface TechnicalBacktestSymbolInput {
symbol: string;
quotes: WeeklyQuote[];
}
export interface TechnicalBacktestInput {
symbols: TechnicalBacktestSymbolInput[];
horizonsWeeks: number[];
}
export interface BacktestEvent {
symbol: string;
signalDate: Date;
entryDate: Date;
exitDate: Date;
horizonWeeks: number;
forwardReturn: number;
maxDrawdown: number;
}
export interface BacktestSummary {
eventCount: number;
winRate: number;
meanReturn: number;
medianReturn: number;
averageMaxDrawdown: number;
minReturn: number;
maxReturn: number;
byHorizon: Record<number, BacktestHorizonSummary>;
}
export interface BacktestHorizonSummary {
eventCount: number;
winRate: number;
meanReturn: number;
medianReturn: number;
averageMaxDrawdown: number;
minReturn: number;
maxReturn: number;
}
export interface BacktestResult {
events: BacktestEvent[];
summary: BacktestSummary;
}
const MIN_QUOTES_FOR_SIGNAL = 205;
export function runSignalBacktest(input: BacktestInput): BacktestResult {
const events: BacktestEvent[] = [];
for (const symbolInput of input.symbols) {
const { symbol, quotes } = symbolInput;
for (let quoteIndex = MIN_QUOTES_FOR_SIGNAL - 1; quoteIndex < quotes.length; quoteIndex += 1) {
const signalQuote = quotes[quoteIndex];
const signalDate = quoteDate(signalQuote);
if (!signalDate) {
continue;
}
const fundamentals = latestFundamentalsAt(symbolInput.fundamentals, signalDate);
if (!fundamentals) {
continue;
}
const signal = evaluateMungerSignal({
quotes: quotes.slice(0, quoteIndex + 1),
fundamentals,
});
if (signal.signal !== 'BUY_TRIGGER' || wasBuyTriggerOnPreviousBar(quotes, quoteIndex, fundamentals)) {
continue;
}
const entryIndex = quoteIndex + 1;
const entryQuote = quotes[entryIndex];
const entryDate = quoteDate(entryQuote);
if (!entryQuote || !entryDate || entryQuote.close === undefined) {
continue;
}
for (const horizonWeeks of input.horizonsWeeks) {
const exitQuote = quotes[entryIndex + horizonWeeks];
const exitDate = quoteDate(exitQuote);
if (!exitQuote || !exitDate || exitQuote.close === undefined) {
continue;
}
events.push({
symbol,
signalDate,
entryDate,
exitDate,
horizonWeeks,
forwardReturn: (exitQuote.close - entryQuote.close) / entryQuote.close,
maxDrawdown: calculatePathMaxDrawdown(quotes.slice(entryIndex, entryIndex + horizonWeeks + 1), entryQuote.close),
});
}
}
}
return {
events,
summary: summarizeEvents(events),
};
}
export function runTechnicalSignalBacktest(input: TechnicalBacktestInput): BacktestResult {
const events: BacktestEvent[] = [];
const passThroughFundamentals: SignalFundamentals = {
roe: 1,
debtToEquity: 0,
marketCap: 10_000_000_000,
};
for (const symbolInput of input.symbols) {
const { symbol, quotes } = symbolInput;
for (let quoteIndex = MIN_QUOTES_FOR_SIGNAL - 1; quoteIndex < quotes.length; quoteIndex += 1) {
const signalQuote = quotes[quoteIndex];
const signalDate = quoteDate(signalQuote);
if (!signalDate) {
continue;
}
const signal = evaluateMungerSignal({
quotes: quotes.slice(0, quoteIndex + 1),
fundamentals: passThroughFundamentals,
});
if (signal.signal !== 'BUY_TRIGGER' || wasBuyTriggerOnPreviousBar(quotes, quoteIndex, passThroughFundamentals)) {
continue;
}
const entryIndex = quoteIndex + 1;
const entryQuote = quotes[entryIndex];
const entryDate = quoteDate(entryQuote);
if (!entryQuote || !entryDate || entryQuote.close === undefined) {
continue;
}
for (const horizonWeeks of input.horizonsWeeks) {
const exitQuote = quotes[entryIndex + horizonWeeks];
const exitDate = quoteDate(exitQuote);
if (!exitQuote || !exitDate || exitQuote.close === undefined) {
continue;
}
events.push({
symbol,
signalDate,
entryDate,
exitDate,
horizonWeeks,
forwardReturn: (exitQuote.close - entryQuote.close) / entryQuote.close,
maxDrawdown: calculatePathMaxDrawdown(quotes.slice(entryIndex, entryIndex + horizonWeeks + 1), entryQuote.close),
});
}
}
}
return {
events,
summary: summarizeEvents(events),
};
}
function wasBuyTriggerOnPreviousBar(
quotes: WeeklyQuote[],
quoteIndex: number,
fundamentals: SignalFundamentals,
): boolean {
if (quoteIndex <= MIN_QUOTES_FOR_SIGNAL - 1) {
return false;
}
return evaluateMungerSignal({
quotes: quotes.slice(0, quoteIndex),
fundamentals,
}).signal === 'BUY_TRIGGER';
}
function summarizeEvents(events: BacktestEvent[]): BacktestSummary {
const byHorizon: Record<number, BacktestHorizonSummary> = {};
const horizons = [...new Set(events.map((event) => event.horizonWeeks))].sort((a, b) => a - b);
for (const horizon of horizons) {
byHorizon[horizon] = summarizeHorizon(events.filter((event) => event.horizonWeeks === horizon));
}
return {
...summarizeHorizon(events),
byHorizon,
};
}
function summarizeHorizon(events: BacktestEvent[]): BacktestHorizonSummary {
if (events.length === 0) {
return {
eventCount: 0,
winRate: 0,
meanReturn: 0,
medianReturn: 0,
averageMaxDrawdown: 0,
minReturn: 0,
maxReturn: 0,
};
}
const returns = events.map((event) => event.forwardReturn);
const drawdowns = events.map((event) => event.maxDrawdown);
return {
eventCount: events.length,
winRate: events.filter((event) => event.forwardReturn > 0).length / events.length,
meanReturn: mean(returns),
medianReturn: median(returns),
averageMaxDrawdown: mean(drawdowns),
minReturn: Math.min(...returns),
maxReturn: Math.max(...returns),
};
}
function mean(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
function calculatePathMaxDrawdown(path: WeeklyQuote[], entryPrice: number): number {
let maxDrawdown = 0;
for (const quote of path) {
if (quote.low === undefined) {
continue;
}
maxDrawdown = Math.min(maxDrawdown, (quote.low - entryPrice) / entryPrice);
}
return maxDrawdown;
}
function latestFundamentalsAt(
fundamentals: FundamentalsPoint[],
signalDate: Date,
): FundamentalsPoint | undefined {
let latest: FundamentalsPoint | undefined;
for (const point of fundamentals) {
if (point.asOf.getTime() > signalDate.getTime()) {
continue;
}
if (!latest || point.asOf.getTime() > latest.asOf.getTime()) {
latest = point;
}
}
return latest;
}
function quoteDate(quote: WeeklyQuote | undefined): Date | undefined {
if (!quote?.date) {
return undefined;
}
return quote.date instanceof Date ? quote.date : new Date(quote.date);
}