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) } }