Spaces:
Runtime error
Runtime error
| 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); | |
| } | |