alt/services/worker/internal/papertrading/engine_test.go
toki aaf1af6387 feat(paper-trading): paper 상태 모니터링을 연결한다
Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
2026-06-05 15:14:41 +09:00

821 lines
32 KiB
Go

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