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; } 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 = {}; 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); }