package backtest import ( "context" "fmt" "sort" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) // BarSource resolves daily bars for a backtest run. It receives the full RunSpec // so input selectors (instrument ids / symbols) reach the storage boundary and // the engine only iterates the bars the run actually selected. type BarSource interface { GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) } // StrategyPort resolves strategies for a backtest run. type StrategyPort interface { GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) } // Engine implements the jobs.BacktestExecutor interface and runs the backtest strategy loop. type Engine struct { barSource BarSource strategyPort StrategyPort resultStore storage.BacktestResultStore } // NewEngine creates a new backtest Engine instance. func NewEngine(barSource BarSource, strategyPort StrategyPort, resultStore storage.BacktestResultStore) *Engine { return &Engine{ barSource: barSource, strategyPort: strategyPort, resultStore: resultStore, } } // Execute runs the backtest strategy loop. func (e *Engine) Execute(ctx context.Context, run backtest.Run) error { strategy, err := e.strategyPort.GetStrategy(ctx, run.Spec.StrategyID) if err != nil { return fmt.Errorf("failed to get strategy %s: %w", run.Spec.StrategyID, err) } bars, err := e.barSource.GetBarsForRun(ctx, run.Spec) if err != nil { return fmt.Errorf("failed to get bars: %w", err) } sort.Slice(bars, func(i, j int) bool { if bars[i].Timestamp.Equal(bars[j].Timestamp) { return bars[i].InstrumentID < bars[j].InstrumentID } return bars[i].Timestamp.Before(bars[j].Timestamp) }) var startingCash market.Price if run.Spec.Market == market.MarketUS { startingCash = market.Price{ Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "10000"}, } } else { startingCash = market.Price{ Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}, } } portfolio := backtest.NewPortfolioState(startingCash) var history []market.Bar var trades []backtest.TradeSummary var equityCurve []backtest.EquityPoint for _, bar := range bars { input := backtest.StrategyInput{ Run: run, Bar: bar, Portfolio: portfolio, History: history, } orders, err := strategy.Decide(input) if err != nil { return fmt.Errorf("strategy decide failed for bar at %s: %w", bar.Timestamp, err) } for _, order := range orders { fill := backtest.Fill{ InstrumentID: order.InstrumentID, Side: order.Side, Quantity: order.Quantity, Price: bar.Close, } nextPortfolio, err := portfolio.ApplyFill(fill) if err != nil { return fmt.Errorf("failed to apply fill for order %+v: %w", order, err) } portfolio = nextPortfolio // Record trade trades = append(trades, backtest.TradeSummary{ InstrumentID: fill.InstrumentID, Side: fill.Side, Quantity: fill.Quantity, Price: fill.Price, Timestamp: bar.Timestamp, }) } // Update mark price of any open position for the current bar if _, ok := portfolio.Position(bar.InstrumentID); ok { portfolio, err = portfolio.MarkPrice(bar.InstrumentID, bar.Close) if err != nil { return fmt.Errorf("failed to mark price for instrument %s: %w", bar.InstrumentID, err) } } // Record one equity point per processed bar after marking the current close // so the curve is deterministic and ordered by bar timestamp. equity, err := portfolio.Equity() if err != nil { return fmt.Errorf("failed to calculate equity at %s: %w", bar.Timestamp, err) } equityCurve = append(equityCurve, backtest.EquityPoint{Timestamp: bar.Timestamp, Equity: equity}) history = append(history, bar) } // Calculate ending equity endingEquity, err := portfolio.Equity() if err != nil { return fmt.Errorf("failed to calculate ending equity: %w", err) } // Collect positions var positions []backtest.PositionSummary for instID, pos := range portfolio.Positions { positions = append(positions, backtest.PositionSummary{ InstrumentID: instID, Quantity: pos.Quantity, LastPrice: pos.LastPrice, }) } // Sort positions alphabetically by InstrumentID for determinism sort.Slice(positions, func(i, j int) bool { return positions[i].InstrumentID < positions[j].InstrumentID }) result := backtest.Result{ RunID: run.ID, StartingCash: startingCash, EndingEquity: endingEquity, Trades: trades, Positions: positions, Summary: backtest.SummaryMetrics{ StartingCash: startingCash, EndingEquity: endingEquity, TotalReturn: backtest.TotalReturn(startingCash, endingEquity), TradeCount: len(trades), }, EquityCurve: equityCurve, } if e.resultStore != nil { if err := e.resultStore.UpsertResult(ctx, result); err != nil { return fmt.Errorf("failed to upsert result: %w", err) } } return nil }