Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
821 lines
32 KiB
Go
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)
|
|
}
|
|
}
|