alt/services/worker/internal/backtest/engine_test.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

211 lines
7.2 KiB
Go

package backtest
import (
"context"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
type mockBarSource struct {
bars []market.Bar
gotSpec backtest.RunSpec
}
func (m *mockBarSource) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) {
m.gotSpec = spec
return m.bars, nil
}
type mockStrategy struct {
decideCalls []backtest.StrategyInput
}
func (m *mockStrategy) ID() backtest.StrategyID {
return "test-strategy"
}
func (m *mockStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
m.decideCalls = append(m.decideCalls, input)
// Buy 2 units of the instrument on the first bar
if len(m.decideCalls) == 1 {
return []backtest.OrderIntent{
{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
},
}, nil
}
return nil, nil
}
type mockStrategyPort struct {
strategy backtest.Strategy
}
func (m *mockStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) {
return m.strategy, nil
}
func TestEngineCallsStrategyForBars(t *testing.T) {
// Create fixture bars in unsorted order to verify sorting
bar1 := market.Bar{
InstrumentID: market.InstrumentID("KRX:005930"),
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}},
}
bar2 := market.Bar{
InstrumentID: market.InstrumentID("KRX:005930"),
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}},
}
barSource := &mockBarSource{bars: []market.Bar{bar2, bar1}} // unsorted
strat := &mockStrategy{}
strategyPort := &mockStrategyPort{strategy: strat}
engine := NewEngine(barSource, strategyPort, nil)
run := backtest.Run{
ID: "run-1",
Spec: backtest.RunSpec{
StrategyID: "test-strategy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
},
}
err := engine.Execute(context.Background(), run)
if err != nil {
t.Fatalf("Engine execution failed: %v", err)
}
if len(strat.decideCalls) != 2 {
t.Fatalf("expected 2 strategy Decide calls, got %d", len(strat.decideCalls))
}
// Verify chronological order: first call should have bar1 (May 1), second should have bar2 (May 2)
firstCall := strat.decideCalls[0]
if !firstCall.Bar.Timestamp.Equal(bar1.Timestamp) {
t.Errorf("expected first bar to be May 1, got %s", firstCall.Bar.Timestamp)
}
// Initial portfolio cash should be 10000000 (KRW default)
if firstCall.Portfolio.Cash.Amount.Value != "10000000" {
t.Errorf("expected starting cash 10000000, got %s", firstCall.Portfolio.Cash.Amount.Value)
}
secondCall := strat.decideCalls[1]
if !secondCall.Bar.Timestamp.Equal(bar2.Timestamp) {
t.Errorf("expected second bar to be May 2, got %s", secondCall.Bar.Timestamp)
}
// History in second call should contain bar1
if len(secondCall.History) != 1 {
t.Fatalf("expected history size 1 in second call, got %d", len(secondCall.History))
}
if !secondCall.History[0].Timestamp.Equal(bar1.Timestamp) {
t.Errorf("expected history[0] to be May 1 bar, got %s", secondCall.History[0].Timestamp)
}
// Portfolio cash in second call should reflect the purchase: 10000000 - 2 * 1000 = 9998000
if secondCall.Portfolio.Cash.Amount.Value != "9998000" {
t.Errorf("expected cash after buy to be 9998000, got %s", secondCall.Portfolio.Cash.Amount.Value)
}
// Position quantity should be 2
pos, ok := secondCall.Portfolio.Position(market.InstrumentID("KRX:005930"))
if !ok {
t.Fatal("expected position in second call portfolio")
}
if pos.Quantity.Amount.Value != "2" {
t.Errorf("expected position quantity 2, got %s", pos.Quantity.Amount.Value)
}
}
// TestEnginePassesRunSpecToBarSource proves the engine forwards the whole RunSpec
// (including the input selector) to the bar source instead of dropping selection
// at the engine boundary.
func TestEnginePassesRunSpecToBarSource(t *testing.T) {
barSource := &mockBarSource{}
strategyPort := &mockStrategyPort{strategy: &mockStrategy{}}
engine := NewEngine(barSource, strategyPort, nil)
run := backtest.Run{
ID: "run-selector",
Spec: backtest.RunSpec{
StrategyID: "test-strategy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Selector: backtest.InputSelector{
InstrumentIDs: []market.InstrumentID{"KRX:005930"},
Symbols: []string{"005930"},
},
},
}
if err := engine.Execute(context.Background(), run); err != nil {
t.Fatalf("Engine execution failed: %v", err)
}
if len(barSource.gotSpec.Selector.InstrumentIDs) != 1 ||
barSource.gotSpec.Selector.InstrumentIDs[0] != "KRX:005930" {
t.Errorf("selector instrument ids not forwarded: %+v", barSource.gotSpec.Selector)
}
if len(barSource.gotSpec.Selector.Symbols) != 1 || barSource.gotSpec.Selector.Symbols[0] != "005930" {
t.Errorf("selector symbols not forwarded: %+v", barSource.gotSpec.Selector)
}
}
// TestEngineOrdersEqualTimestampBarsByInstrumentID verifies that when multiple
// bars share the same timestamp the engine passes them to strategy.Decide in
// ascending instrument id order, matching the StorageBarSource sort contract.
func TestEngineOrdersEqualTimestampBarsByInstrumentID(t *testing.T) {
sameTime := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
barA := market.Bar{
InstrumentID: market.InstrumentID("KRX:000660"),
Timeframe: market.TimeframeDaily,
Timestamp: sameTime,
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "80000"}},
}
barB := market.Bar{
InstrumentID: market.InstrumentID("KRX:005930"),
Timeframe: market.TimeframeDaily,
Timestamp: sameTime,
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}},
}
// Provide bars in reverse instrument-id order to prove the engine sorts them.
barSource := &mockBarSource{bars: []market.Bar{barB, barA}}
strat := &mockStrategy{}
engine := NewEngine(barSource, &mockStrategyPort{strategy: strat}, nil)
run := backtest.Run{
ID: "run-tie",
Spec: backtest.RunSpec{
StrategyID: "test-strategy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: sameTime,
To: sameTime,
},
}
if err := engine.Execute(context.Background(), run); err != nil {
t.Fatalf("Engine execution failed: %v", err)
}
if len(strat.decideCalls) != 2 {
t.Fatalf("expected 2 Decide calls, got %d", len(strat.decideCalls))
}
if strat.decideCalls[0].Bar.InstrumentID != barA.InstrumentID {
t.Errorf("first Decide call: got instrument %q, want %q", strat.decideCalls[0].Bar.InstrumentID, barA.InstrumentID)
}
if strat.decideCalls[1].Bar.InstrumentID != barB.InstrumentID {
t.Errorf("second Decide call: got instrument %q, want %q", strat.decideCalls[1].Bar.InstrumentID, barB.InstrumentID)
}
}