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 } // TestEngineFillsOrdersOnNextMatchingInstrumentBar proves the engine holds // strategy orders as pending and executes them on the next available bar for // the same instrument using FillOrderOnBar (bar.Open for market orders). func TestEngineFillsOrdersOnNextMatchingInstrumentBar(t *testing.T) { bar1 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "990"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "980"}}, High: 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), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1010"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, } barSource := &mockBarSource{bars: []market.Bar{bar2, bar1}} // unsorted strat := &mockStrategy{} engine := NewEngine(barSource, &mockStrategyPort{strategy: strat}, nil) run := backtest.Run{ ID: "run-next-bar", 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)) } // First call: bar1 (May 1). Strategy returns buy order for 2 units. // Cash should be 10000000 (initial), position 0. 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) } if firstCall.Portfolio.Cash.Amount.Value != "10000000" { t.Errorf("expected starting cash 10000000, got %s", firstCall.Portfolio.Cash.Amount.Value) } // Second call: bar2 (May 2). Strategy returns nil (no orders). // The buy from bar1 should have been filled on bar2 at bar2.Open = 1010. 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) } // After buy: cash = 10000000 - 2*1010 = 9997980, position = 2 if secondCall.Portfolio.Cash.Amount.Value != "9997980" { t.Errorf("expected cash after buy at 1010 to be 9997980, got %s", secondCall.Portfolio.Cash.Amount.Value) } 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, Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "80000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "80000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "79000"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "81000"}}, } barB := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: sameTime, Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "990"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1010"}}, } // Provide bars in reverse instrument-id order to prove the engine sorts them. barSource := &mockBarSource{bars: []market.Bar{barB, barA}} strat := &multiInstrumentBuyStrategy{} 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) } } // multiInstrumentBuyStrategy buys on every bar. type multiInstrumentBuyStrategy struct { decideCalls []backtest.StrategyInput } func (s *multiInstrumentBuyStrategy) ID() backtest.StrategyID { return "multi-buy" } func (s *multiInstrumentBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { s.decideCalls = append(s.decideCalls, input) return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, }}, nil } // TestEngineCarriesUncrossedLimitOrderToLaterMatchingBar proves that a limit order // whose price was not crossed on the first matching bar is kept pending and fills // on a later bar where the price condition is met. func TestEngineCarriesUncrossedLimitOrderToLaterMatchingBar(t *testing.T) { bar1 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "990"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1010"}}, } // bar2: low=950 > limit=940, so limit buy does NOT cross bar2 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1050"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "950"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1060"}}, } // bar3: low=930 <= limit=940, so limit buy DOES cross bar3 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "950"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "900"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "930"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "970"}}, } limitBuyStrategy := &limitBuyStrategy{limitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "940"}}} store := newInMemoryResultStore() engine := NewEngine(&mockBarSource{bars: []market.Bar{bar1, bar2, bar3}}, &mockStrategyPort{strategy: limitBuyStrategy}, store) run := backtest.Run{ ID: "run-limit-carry", Spec: backtest.RunSpec{ StrategyID: "limit-buy-strategy", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), }, } if err := engine.Execute(context.Background(), run); err != nil { t.Fatalf("Engine execution failed: %v", err) } result, err := store.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get result: %v", err) } // bar1: strategy returns limit buy @ 940 on first bar. // bar2: low=950 > limit=940, limit buy @ 940 does NOT fill. // bar3: low=930 <= limit=940, limit buy @ 940 DOES fill. // Expected: 1 trade, filled at bar3. if len(result.Trades) != 1 { t.Fatalf("expected 1 trade, got %d", len(result.Trades)) } if len(result.Trades) > 0 { trade := result.Trades[0] if trade.InstrumentID != market.InstrumentID("KRX:005930") { t.Errorf("expected trade instrument KRX:005930, got %s", trade.InstrumentID) } expectedQty := market.Quantity{Amount: market.Decimal{Value: "2"}} if trade.Quantity.Amount.Value != expectedQty.Amount.Value { t.Errorf("expected trade quantity %s, got %s", expectedQty.Amount.Value, trade.Quantity.Amount.Value) } } } // limitBuyStrategy returns a limit buy order on the first bar call. type limitBuyStrategy struct { limitPrice market.Price callCount int } func (s *limitBuyStrategy) ID() backtest.StrategyID { return "limit-buy-strategy" } func (s *limitBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { s.callCount++ if s.callCount == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}}, Type: backtest.OrderTypeLimit, LimitPrice: s.limitPrice, }}, nil } return nil, nil } // TestEngineQueuesOrdersByOrderInstrumentID proves that orders are enqueued by // their own InstrumentID, not the bar's instrument. // // Fixture: A1 (May1, KRX:000660), A2 (May2, KRX:000660), B2 (May2, KRX:005930). // No B1 bar exists, so a B order placed on A1 cannot fill until B2. // Strategy: on A1 bar returns a B (KRX:005930) order; on all other bars returns nil. // // Correct keying (order.InstrumentID): B order sits in pendingOrders["KRX:005930"], // fills on B2 at B2.Open=1100. // Old buggy keying (bar.InstrumentID): B order sits in pendingOrders["KRX:000660"], // fills on A2 at A2.Open=82000. func TestEngineQueuesOrdersByOrderInstrumentID(t *testing.T) { barA1 := market.Bar{ InstrumentID: market.InstrumentID("KRX:000660"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "80000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "80000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "79000"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "81000"}}, } barA2 := market.Bar{ InstrumentID: market.InstrumentID("KRX:000660"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "82000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "82000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "81000"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "83000"}}, } // B2 is the only B bar; B1 is intentionally absent so the B order from A1 waits until B2. barB2 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1090"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1110"}}, } // Sorted order: A1 (May1, 000660), A2 (May2, 000660), B2 (May2, 005930) crossInstStrat := &crossInstrumentStrategy{} store := newInMemoryResultStore() engine := NewEngine(&mockBarSource{bars: []market.Bar{barA1, barA2, barB2}}, &mockStrategyPort{strategy: crossInstStrat}, store) run := backtest.Run{ ID: "run-cross-instrument", Spec: backtest.RunSpec{ StrategyID: "cross-instrument-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), }, } if err := engine.Execute(context.Background(), run); err != nil { t.Fatalf("Engine execution failed: %v", err) } result, err := store.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get result: %v", err) } // B order from A1 must fill on B2 at B2.Open=1100, not on A2 at A2.Open=82000. // No A trade should exist. if len(result.Trades) != 1 { t.Fatalf("expected 1 trade, got %d", len(result.Trades)) } trade := result.Trades[0] if trade.InstrumentID != market.InstrumentID("KRX:005930") { t.Errorf("expected trade instrument KRX:005930, got %s", trade.InstrumentID) } if trade.Price.Amount.Value != "1100" { t.Errorf("expected fill price B2.Open=1100, got %s (82000 means order was keyed by bar instrument)", trade.Price.Amount.Value) } } // crossInstrumentStrategy returns a KRX:005930 (B) order when processing the // KRX:000660 (A) bar on its first call, and nil for all subsequent calls. // This simulates a strategy that places an order for a different instrument than // the bar being evaluated. type crossInstrumentStrategy struct { decideCalls []backtest.StrategyInput } func (s *crossInstrumentStrategy) ID() backtest.StrategyID { return "cross-instrument" } func (s *crossInstrumentStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { s.decideCalls = append(s.decideCalls, input) if len(s.decideCalls) == 1 { return []backtest.OrderIntent{{ InstrumentID: market.InstrumentID("KRX:005930"), Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, }}, nil } return nil, nil } // TestEngineDoesNotFillPendingOrderWithoutNextBar proves that if the strategy // places an order on the last bar in the series it is discarded, and verifies // via the result store instead of strategy-local state. func TestEngineDoesNotFillPendingOrderWithoutNextBar(t *testing.T) { bar1 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "990"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1010"}}, } bar2 := market.Bar{ InstrumentID: market.InstrumentID("KRX:005930"), Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1090"}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1110"}}, } lastBarBuyStrategy := &lastBarBuyStrategy{} store := newInMemoryResultStore() barSource := &mockBarSource{bars: []market.Bar{bar1, bar2}} engine := NewEngine(barSource, &mockStrategyPort{strategy: lastBarBuyStrategy}, store) run := backtest.Run{ ID: "run-last-bar", Spec: backtest.RunSpec{ StrategyID: "last-bar-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), }, } if err := engine.Execute(context.Background(), run); err != nil { t.Fatalf("Engine execution failed: %v", err) } result, err := store.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get result: %v", err) } // There should be no trades because the buy was placed on the last bar // and no next bar existed to execute it. if len(result.Trades) != 0 { t.Errorf("expected 0 trades (order discarded), got %d", len(result.Trades)) } // No positions should be open if len(result.Positions) != 0 { t.Errorf("expected 0 positions, got %d", len(result.Positions)) } // Ending equity should equal starting cash (10000000 KRW) since no trades occurred if result.EndingEquity.Amount.Value != startingCashKRW { t.Errorf("expected ending equity %s (starting cash), got %s", startingCashKRW, result.EndingEquity.Amount.Value) } } // lastBarBuyStrategy buys only on the last bar (day == 2). type lastBarBuyStrategy struct { callCount int } func (s *lastBarBuyStrategy) ID() backtest.StrategyID { return "last-bar-strategy" } func (s *lastBarBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { s.callCount++ if input.Bar.Timestamp.Day() != 2 { return nil, nil } return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}}, }}, nil } // NOTE: startingCashKRW constant for test assertions const startingCashKRW = "10000000"