alt/services/worker/internal/backtest/engine.go
toki 38b68315fc feat: backtest engine baseline implementation
- Add backtest proto definitions and generated code
- Update domain types for backtest results and fixtures
- Add PostgreSQL migrations for backtest tables
- Implement storage layer for backtest result persistence
- Add backtest job definitions and execution pipeline
- Remove obsolete agent-task documents for completed items
2026-05-30 12:13:45 +09:00

156 lines
4.2 KiB
Go

package backtest
import (
"context"
"fmt"
"sort"
"time"
"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.
type BarSource interface {
GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]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.GetBars(ctx, run.Spec.Market, run.Spec.Timeframe, run.Spec.From, run.Spec.To)
if err != nil {
return fmt.Errorf("failed to get bars: %w", err)
}
// Ensure chronological sorting of bars
sort.Slice(bars, func(i, j int) bool {
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
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)
}
}
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,
}
if e.resultStore != nil {
if err := e.resultStore.UpsertResult(ctx, result); err != nil {
return fmt.Errorf("failed to upsert result: %w", err)
}
}
return nil
}