package papertrading import ( "context" "testing" "time" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" ) // --- helpers --- type testBarSource struct { bars []market.Bar } func (t *testBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { return t.bars, nil } type testStrategyPort struct { strategy backtest.Strategy } func (t *testStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) { return t.strategy, nil } // simpleBuyStrategy buys 1 unit on the first bar only. type simpleBuyStrategy struct { id backtest.StrategyID } func (s simpleBuyStrategy) ID() backtest.StrategyID { return s.id } func (s simpleBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // buyThenSellStrategy buys on bar 1, sells 1 unit on bar 2. type buyThenSellStrategy struct { id backtest.StrategyID } func (s buyThenSellStrategy) ID() backtest.StrategyID { return s.id } func (s buyThenSellStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } if day == 2 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideSell, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // emptyInstOrderStrategy returns an order with empty InstrumentID to verify rejection policy. type emptyInstOrderStrategy struct { id backtest.StrategyID } func (s emptyInstOrderStrategy) ID() backtest.StrategyID { return s.id } func (s emptyInstOrderStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: "", // deliberately empty Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // limitBuyStrategy: on day 1 places a limit buy at price 1050, then buys more on day 3. type limitBuyStrategy struct { id backtest.StrategyID } func (s limitBuyStrategy) ID() backtest.StrategyID { return s.id } func (s limitBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeLimit, LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1050"}}, }}, nil } if day == 3 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // nonCrossedLimitOnSellStrategy buys 1 unit on day 1, then places a limit sell at 2000 on day 2. // Bar highs: day1=1100, day2=1200, day3=1300. None of them cross 2000, so the order persists pending. type nonCrossedLimitOnSellStrategy struct { id backtest.StrategyID } func (s nonCrossedLimitOnSellStrategy) ID() backtest.StrategyID { return s.id } func (s nonCrossedLimitOnSellStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } if day == 2 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideSell, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeLimit, LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "2000"}}, }}, nil } return nil, nil } func makeBars(tb testing.TB) []market.Bar { inst := market.InstrumentID("KRX:005930") marketKRW := func(v string) market.Price { return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}} } return []market.Bar{ {InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"), Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}}}, {InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("1050"), High: marketKRW("1200"), Low: marketKRW("1000"), Close: marketKRW("1150"), Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}}}, {InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: marketKRW("1150"), High: marketKRW("1300"), Low: marketKRW("1100"), Close: marketKRW("1200"), Volume: market.Quantity{Amount: market.Decimal{Value: "1500"}}}, } } // --- tests --- func TestEngineFillsMarketOrderOnNextBarOpen(t *testing.T) { bars := makeBars(t) strategyPort := &testStrategyPort{strategy: simpleBuyStrategy{id: "test-simple-buy"}} engine := NewEngine(&testBarSource{bars: bars}, strategyPort) startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}} req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-1", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(startingCash), }, Run: backtest.Run{ ID: "run-1", Spec: backtest.RunSpec{ StrategyID: "test-simple-buy", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[len(bars)-1].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } // Strategy fires a market buy on bar 1; it should fill on bar 2 at bar 2's open price (1050). if len(snap.Fills) != 1 { t.Fatalf("expected 1 fill, got %d", len(snap.Fills)) } fill := snap.Fills[0] if fill.Side != backtest.OrderSideBuy { t.Errorf("expected fill side buy, got %s", fill.Side) } // Market order fills at next-bar open if fill.Price.Amount.Value != "1050" { t.Errorf("expected fill price 1050 (bar 2 open), got %s", fill.Price.Amount.Value) } // Cash after buy: 10000000 - 1 * 1050 = 9998950 if snap.Account.Portfolio.Cash.Amount.Value != "9998950" { t.Errorf("expected cash 9998950, got %s", snap.Account.Portfolio.Cash.Amount.Value) } // Position: 1 unit of KRX:005930 pos, ok := snap.Account.Portfolio.Position(instrumentIDFromBars(t, bars)) if !ok { t.Fatal("expected position after fill") } if pos.Quantity.Amount.Value != "1" { t.Errorf("expected position qty 1, got %s", pos.Quantity.Amount.Value) } } func TestEngineFillsLimitOrderOnDailyCrossing(t *testing.T) { // Bars: day1 O1000 H1100 L950 C1050, day3 O1150 H1300 L1100 C1200 // Strategy places limit buy at 1050 on day 1. // Day 2 does not exist in the strategy (limitBuyStrategy orders on day 1 and 3 only) but the engine // iterates through all bars. The pending limit buy sits until day 2 bar high (1200) crosses 1050 (sell side). // For buy limit, we check: limit must be within [low, high]. For buy limit at 1050, // day 2: low=1000, high=1200. 1050 is between 1000 and 1200, so it fills. // Actually, FillOrderOnDailyBar doesn't distinguish buy/sell for limit crossing: // it just checks if limit is within [low, high]. 1050 is within [1000, 1200]. // So it fills on bar 2 (day 2) at limit price 1050. bars := makeBars(t) strategyPort := &testStrategyPort{strategy: limitBuyStrategy{id: "test-limit-buy"}} engine := NewEngine(&testBarSource{bars: bars}, strategyPort) startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}} req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-2", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(startingCash), }, Run: backtest.Run{ ID: "run-2", Spec: backtest.RunSpec{ StrategyID: "test-limit-buy", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[2].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } // The limit buy at 1050 should fill on bar 2 (day 2) which has low=1000, high=1200 // 1050 is between 1000 and 1200, so it crosses. // There's also a market buy on day 3 (bar 3) which fills on bar 4 which doesn't exist. if len(snap.Fills) < 1 { t.Fatalf("expected at least 1 fill, got %d", len(snap.Fills)) } // First fill should be the limit buy limitFill := snap.Fills[0] if limitFill.Price.Amount.Value != "1050" { t.Errorf("expected limit fill price 1050, got %s", limitFill.Price.Amount.Value) } } func TestEngineLeavesNonCrossedLimitPending(t *testing.T) { // Bars day1: H1100, day2: H1200, day3: H1300. // Strategy buys 1 unit on day 1 (market). Buy fills on day 2 at open 1050. // Strategy places limit sell at 2000 on day 2. Day 2 high=1200, so limit 2000 does not cross. // Day 3 high=1300, limit 2000 still does not cross. // Result: order remains pending, 0 fills for the sell, and 0 rejections. bars := makeBars(t) strategyPort := &testStrategyPort{strategy: nonCrossedLimitOnSellStrategy{id: "test-non-crossed"}} engine := NewEngine(&testBarSource{bars: bars}, strategyPort) startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}} req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-3", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(startingCash), }, Run: backtest.Run{ ID: "run-3", Spec: backtest.RunSpec{ StrategyID: "test-non-crossed", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[2].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } // The market buy from day 1 fills on day 2 (open 1050) - 1 fill. // The limit sell from day 2 at 2000 never crosses bar high (max 1300) - does not fill. // Total fills = 1 (just the market buy). if len(snap.Fills) != 1 { t.Fatalf("expected 1 fill (market buy), got %d", len(snap.Fills)) } fill := snap.Fills[0] if fill.Side != backtest.OrderSideBuy || fill.Price.Amount.Value != "1050" { t.Errorf("unexpected fill: %+v", fill) } // No rejections: risk check passed (we have 1 unit position), just limit didn't cross. if len(snap.Rejected) != 0 { t.Errorf("expected 0 rejections, got %d", len(snap.Rejected)) } // Position still holds 1 unit (sell limit didn't fill). pos, ok := snap.Account.Portfolio.Position(instrumentIDFromBars(t, bars)) if !ok { t.Fatal("expected position to still exist (limit sell didn't fill)") } if pos.Quantity.Amount.Value != "1" { t.Errorf("expected position qty 1, got %s", pos.Quantity.Amount.Value) } } func TestEngineRejectsRiskDeniedOrder(t *testing.T) { bars := makeBars(t) // Strategy tries to sell 2 units on day 1 but has no position. strategyPort := &testStrategyPort{strategy: riskSellStrategy{id: "test-risk-sell"}} engine := NewEngine(&testBarSource{bars: bars}, strategyPort) startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}} req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-4", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(startingCash), }, Run: backtest.Run{ ID: "run-4", Spec: backtest.RunSpec{ StrategyID: "test-risk-sell", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[2].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } // The sell order should be rejected because we have no position if len(snap.Rejected) != 1 { t.Fatalf("expected 1 rejected order, got %d", len(snap.Rejected)) } rej := snap.Rejected[0] if rej.Reason == "" { t.Error("expected non-empty rejection reason") } } func TestEngineRecordsEquitySnapshots(t *testing.T) { bars := makeBars(t) // buyThenSell buys 1 unit on bar 1 (day 1), sells 1 unit on bar 2 (day 2). // Expected: // Bar 1 (day 1): strategy fires buy on bar 1, but fills on bar 2. No fill on bar 1. // Bar 2 (day 2): pending buy from bar 1 fills at bar 2 open (1050). Strategy fires sell on bar 2, fills on bar 3. // Bar 3 (day 3): pending sell from bar 2 fills at bar 3 open (1150). Strategy fires no order. // Equity at day 1: 10000000 (no position filled yet, cash unchanged) // Equity at day 2 cash after: 10000000 - 1050 = 9998950. position 1@1050. equity = 9998950 + 1050 = 10000000 // But wait... bar 2 is also when the strategy sells. The sell fills on bar 3. // After bar 2 fill: position 1 unit at 1050. Marked at close 1200. equity = 9998950 + 1200 = 10000150 // Bar 3: sell fills at 1150. Cash = 9998950 + 1150 = 10000100. Position = 0. equity = 10000100 // // Actually let me trace more carefully: // Bar 1: no pending orders -> no fills. Strategy fires buy(1 unit, market). pendingOrders = [buy]. Mark price? No position yet. Equity = 10000000. // Bar 2: fill pending buy at bar 2 open 1050. Cash = 10000000 - 1050 = 9998950. position = 1@1050. Strategy fires sell(1 unit, market). pendingOrders = [sell]. Mark price: position exists, mark at close 1150. Equity = 9998950 + 1150 = 10000100. (Wait, but bar 2 close = 1150) // Bar 3: fill pending sell at bar 3 open 1150. Cash = 9998950 + 1150 = 10000100. position = 0. Strategy fires no order. No position to mark. Equity = 10000100. strategyPort := &testStrategyPort{strategy: buyThenSellStrategy{id: "test-buy-sell"}} engine := NewEngine(&testBarSource{bars: bars}, strategyPort) startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}} req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-5", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(startingCash), }, Run: backtest.Run{ ID: "run-5", Spec: backtest.RunSpec{ StrategyID: "test-buy-sell", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[2].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } if len(snap.EquityCurve) != 3 { t.Fatalf("expected 3 equity points, got %d", len(snap.EquityCurve)) } // Bar 1: no position filled yet (strategy buy fires on bar 1 but fills on bar 2) if snap.EquityCurve[0].Equity.Amount.Value != "10000000" { t.Errorf("equity at bar 1: expected 10000000, got %s", snap.EquityCurve[0].Equity.Amount.Value) } // Bar 2: buy filled at 1050. position 1 unit. Mark at close 1150. // equity = 9998950 + 1150 = 10000100 wantEq2 := "10000100" if snap.EquityCurve[1].Equity.Amount.Value != wantEq2 { t.Errorf("equity at bar 2: expected %s, got %s", wantEq2, snap.EquityCurve[1].Equity.Amount.Value) } // Bar 3: sell filled at 1150. No position. equity = 10000100 wantEq3 := "10000100" if snap.EquityCurve[2].Equity.Amount.Value != wantEq3 { t.Errorf("equity at bar 3: expected %s, got %s", wantEq3, snap.EquityCurve[2].Equity.Amount.Value) } // Should have exactly 2 fills if len(snap.Fills) != 2 { t.Errorf("expected 2 fills, got %d", len(snap.Fills)) } } // --- auxiliary --- // riskSellStrategy places a sell order on the first bar without any position. type riskSellStrategy struct { id backtest.StrategyID } func (s riskSellStrategy) ID() backtest.StrategyID { return s.id } func (s riskSellStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideSell, Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } func instrumentIDFromBars(tb testing.TB, bars []market.Bar) market.InstrumentID { if len(bars) == 0 { tb.Fatal("empty bars") } return bars[0].InstrumentID } // multiInstBuyStrategy buys on the first bar of each instrument it sees. type multiInstBuyStrategy struct { id backtest.StrategyID } func (s multiInstBuyStrategy) ID() backtest.StrategyID { return s.id } func (s multiInstBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // TestEngineMultiInstrumentPendingIsScoped verifies that a pending order for // instrument A cannot be filled by a bar belonging to instrument B on the same // timestamp. func TestEngineMultiInstrumentPendingIsScoped(t *testing.T) { marketKRW := func(v string) market.Price { return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}} } instA := market.InstrumentID("KRX:000660") instB := market.InstrumentID("KRX:005930") // Both on same timestamp. Strategy fires buy on day 1 for both instruments. // After sorting by timestamp then instrument ID: instA, instB, instA day2, instB day2 // instA day1 → pendingA=[buyA]. instB day1 → pendingB=[buyB]. // instA day2 → pendingA buyA fills at instA.day2.open=1000. // instB day2 → pendingB buyB fills at instB.day2.open=900. day1A := market.Bar{ InstrumentID: instA, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"), Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}}, } day1B := market.Bar{ InstrumentID: instB, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("900"), High: marketKRW("950"), Low: marketKRW("850"), Close: marketKRW("920"), Volume: market.Quantity{Amount: market.Decimal{Value: "800"}}, } // instA day2 must NOT be consumed by instB pending day2A := market.Bar{ InstrumentID: instA, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("1100"), High: marketKRW("1200"), Low: marketKRW("1050"), Close: marketKRW("1150"), Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}}, } day2B := market.Bar{ InstrumentID: instB, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("2000"), High: marketKRW("2100"), Low: marketKRW("1900"), Close: marketKRW("2050"), Volume: market.Quantity{Amount: market.Decimal{Value: "900"}}, } bars := []market.Bar{day1A, day1B, day2A, day2B} engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: multiInstBuyStrategy{id: "test-multi"}}) req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-multi", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}), }, Run: backtest.Run{ ID: "run-multi", Spec: backtest.RunSpec{ StrategyID: "test-multi", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: day1A.Timestamp, To: day2B.Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } if len(snap.Fills) != 2 { t.Fatalf("expected 2 fills, got %d", len(snap.Fills)) } // Fills occur on the NEXT bar of the same instrument at that bar's open. // pendingA buys on day1A, fills on day2A.open=1100. // pendingB buys on day1B, fills on day2B.open=2000. if snap.Fills[0].InstrumentID != instA || snap.Fills[0].Price.Amount.Value != "1100" { t.Errorf("expected A fill at 1100 (day2A open), got %+v", snap.Fills[0]) } if snap.Fills[1].InstrumentID != instB || snap.Fills[1].Price.Amount.Value != "2000" { t.Errorf("expected B fill at 2000 (day2B open), got %+v", snap.Fills[1]) } } // TestEngineApplyFillFailureIsRejected verifies that a market buy that passes // CheckRisk but fails ApplyFill (insufficient cash) ends up as RejectedOrder // and is NOT retried on future bars. func TestEngineApplyFillFailureIsRejected(t *testing.T) { marketKRW := func(v string) market.Price { return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}} } inst := market.InstrumentID("KRX:005930") bars := []market.Bar{ {InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"), Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}}, }, {InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("1050"), High: marketKRW("1200"), Low: marketKRW("1000"), Close: marketKRW("1150"), Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}}, }, {InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: marketKRW("1150"), High: marketKRW("1300"), Low: marketKRW("1100"), Close: marketKRW("1200"), Volume: market.Quantity{Amount: market.Decimal{Value: "1500"}}, }, } engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: &buyLargeStrategy{id: "test-buy-large"}}) req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-reject", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "500"}}), }, Run: backtest.Run{ ID: "run-reject", Spec: backtest.RunSpec{ StrategyID: "test-buy-large", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[2].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } if len(snap.Rejected) != 1 { t.Fatalf("expected 1 rejected, got %d", len(snap.Rejected)) } if snap.Rejected[0].Reason == "" { t.Error("expected non-empty rejection reason") } if len(snap.Fills) != 0 { t.Errorf("expected 0 fills, got %d", len(snap.Fills)) } if snap.Account.Portfolio.Cash.Amount.Value != "500" { t.Errorf("expected cash 500 unchanged, got %s", snap.Account.Portfolio.Cash.Amount.Value) } } // buyLargeStrategy returns a buy order for 9999999 units only on the first bar (will fail ApplyFill). type buyLargeStrategy struct { id backtest.StrategyID } func (s buyLargeStrategy) ID() backtest.StrategyID { return s.id } func (s buyLargeStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 { return []backtest.OrderIntent{{ InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "9999999"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // orderABStrategy returns an order for instrument B while processing // the first bar of instrument A. type orderABStrategy struct { id backtest.StrategyID aInst market.InstrumentID bInst market.InstrumentID } func (s orderABStrategy) ID() backtest.StrategyID { return s.id } func (s orderABStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { day := input.Bar.Timestamp.Day() if day == 1 && input.Bar.InstrumentID == s.aInst { return []backtest.OrderIntent{{ InstrumentID: s.bInst, Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Type: backtest.OrderTypeMarket, }}, nil } return nil, nil } // TestEngineCrossInstrumentPendingKeyed verifies that a strategy can return an // order for a different instrument than the current bar, and that order fills // only on the matching instrument's next bar. func TestEngineCrossInstrumentPendingKeyed(t *testing.T) { marketKRW := func(v string) market.Price { return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}} } instA := market.InstrumentID("KRX:000660") instB := market.InstrumentID("KRX:005930") // Two bars with different timestamps: instA on day1, instB on day2. // A bar day1 → strategy fires B-order, stored in pendingOrders[B]. // B bar day2 → Phase 1 picks up B-order, fills at day2B.open=950. // Key invariant: B-order stored during A bar fills at B price, not A price. day1A := market.Bar{ InstrumentID: instA, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"), Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}}, } day2B := market.Bar{ InstrumentID: instB, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("950"), High: marketKRW("1000"), Low: marketKRW("900"), Close: marketKRW("970"), Volume: market.Quantity{Amount: market.Decimal{Value: "900"}}, } bars := []market.Bar{day1A, day2B} // Strategy returns a B-order on day 1 (while the bar is for instrument A) engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: orderABStrategy{id: "test-cross-inst", aInst: instA, bInst: instB}}) req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-cross", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}), }, Run: backtest.Run{ ID: "run-cross", Spec: backtest.RunSpec{ StrategyID: "test-cross-inst", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: day1A.Timestamp, To: day2B.Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } // The B-order fires during A's day1 processing. It sits in pendingOrders[instB] // and only fills when the next instB bar arrives on day2. The fill price is // day2B.open=950. A's bars never consume orders for B. if len(snap.Fills) != 1 { t.Fatalf("expected 1 fill from cross-instrument order, got %d", len(snap.Fills)) } fill := snap.Fills[0] if fill.InstrumentID != instB { t.Fatalf("expected fill instrument %s, got %s", instB, fill.InstrumentID) } if fill.Price.Amount.Value != "950" { t.Errorf("expected fill price 950 (B bar open), got %s", fill.Price.Amount.Value) } } // TestEngineEmptyInstrumentIDRejected verifies that an order with empty // InstrumentID is rejected rather than silently falling back to the current bar's instrument. func TestEngineEmptyInstrumentIDRejected(t *testing.T) { marketKRW := func(v string) market.Price { return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}} } inst := market.InstrumentID("KRX:005930") day1 := market.Bar{ InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"), Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}}, } day2 := market.Bar{ InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("1050"), High: marketKRW("1200"), Low: marketKRW("1000"), Close: marketKRW("1150"), Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}}, } bars := []market.Bar{day1, day2} engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: emptyInstOrderStrategy{id: "test-empty-inst"}}) req := RunRequest{ Account: backtest.PaperAccount{ ID: "paper-empty-inst", FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}), }, Run: backtest.Run{ ID: "run-empty-inst", Spec: backtest.RunSpec{ StrategyID: "test-empty-inst", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: day1.Timestamp, To: day2.Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } // Empty InstrumentID → rejected, never retried on day 2 if len(snap.Rejected) != 1 { t.Fatalf("expected 1 rejected order for empty InstrumentID, got %d", len(snap.Rejected)) } if snap.Rejected[0].Reason != "empty order.InstrumentID" { t.Errorf("expected reason 'empty order.InstrumentID', got %q", snap.Rejected[0].Reason) } if len(snap.Fills) != 0 { t.Errorf("expected 0 fills for rejected empty-Inst order, got %d", len(snap.Fills)) } } // TestEnginePreservesAccountIdentity verifies that RunRequest.Account.ID is // preserved in Snapshot.Account.ID regardless of Run.ID. func TestEnginePreservesAccountIdentity(t *testing.T) { bars := makeBars(t) const paperID = "my-paper-account-42" const runID = "run-unique-run-id" engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: simpleBuyStrategy{id: "test-acct"}}) req := RunRequest{ Account: backtest.PaperAccount{ ID: backtest.PaperAccountID(paperID), FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}), }, Run: backtest.Run{ ID: backtest.RunID(runID), Spec: backtest.RunSpec{ StrategyID: "test-acct", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: bars[0].Timestamp, To: bars[2].Timestamp, }, }, } snap, err := engine.Run(context.Background(), req) if err != nil { t.Fatalf("Run failed: %v", err) } if string(snap.Account.ID) != paperID { t.Errorf("expected account ID %q, got %q", paperID, snap.Account.ID) } }