alt/services/worker/internal/backtest/engine.go
toki f084fa6916 feat: backtest data collection infrastructure milestone completion
- Update backtest-data-collection-infrastructure roadmap
- Archive completed 02+01_storage_bar_selector task files
- Refactor bar_source, engine, and related tests in worker service
2026-06-15 15:30:12 +09:00

175 lines
5 KiB
Go

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
}