package backtest import ( "context" "reflect" "sync" "testing" "time" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) type inMemoryResultStore struct { mu sync.RWMutex results map[backtest.RunID]backtest.Result } func newInMemoryResultStore() *inMemoryResultStore { return &inMemoryResultStore{ results: make(map[backtest.RunID]backtest.Result), } } func (m *inMemoryResultStore) UpsertResult(ctx context.Context, result backtest.Result) error { m.mu.Lock() defer m.mu.Unlock() m.results[result.RunID] = result return nil } func (m *inMemoryResultStore) GetResult(ctx context.Context, id backtest.RunID) (backtest.Result, error) { m.mu.RLock() defer m.mu.RUnlock() res, ok := m.results[id] if !ok { return backtest.Result{}, storage.ErrResultNotFound } return res, nil } type inMemoryBarSource struct { bars []market.Bar } func (m *inMemoryBarSource) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) { return m.bars, nil } type inMemoryStrategyPort struct { strategy backtest.Strategy } func (m *inMemoryStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) { return m.strategy, nil } type deterministicStrategy struct { id backtest.StrategyID } func (s *deterministicStrategy) ID() backtest.StrategyID { return s.id } func (s *deterministicStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { // Buy 2 units of the instrument on the first bar (May 1), // and sell 1 unit on the second bar (May 2). 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: "2"}}, }, }, nil } else if day == 2 { return []backtest.OrderIntent{ { InstrumentID: input.Bar.InstrumentID, Side: backtest.OrderSideSell, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, }, }, nil } return nil, nil } func TestEngineProducesDeterministicResultFromFixtureBars(t *testing.T) { instID := market.InstrumentID("KRX:005930") bar1 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, } bar2 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, } bar3 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}}, } barSource := &inMemoryBarSource{bars: []market.Bar{bar3, bar1, bar2}} // unsorted strat := &deterministicStrategy{id: "det-strat"} strategyPort := &inMemoryStrategyPort{strategy: strat} resultStore := newInMemoryResultStore() engine := NewEngine(barSource, strategyPort, resultStore) run := backtest.Run{ ID: "run-deterministic", Spec: backtest.RunSpec{ StrategyID: "det-strat", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), }, } // First execution err := engine.Execute(context.Background(), run) if err != nil { t.Fatalf("first execution failed: %v", err) } res1, err := resultStore.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get first result: %v", err) } // Clear the store to make sure upsert actually writes the second result resultStore.mu.Lock() delete(resultStore.results, run.ID) resultStore.mu.Unlock() // Second execution err = engine.Execute(context.Background(), run) if err != nil { t.Fatalf("second execution failed: %v", err) } res2, err := resultStore.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get second result: %v", err) } // Verify deep equality if !reflect.DeepEqual(res1, res2) { t.Errorf("results are not identical.\nResult 1: %+v\nResult 2: %+v", res1, res2) } } func TestEngineRunWithUSMarketReturnsEquityInUSD(t *testing.T) { // This smoke test validates the MarketUS path: USD starting cash, // USD-denominated prices, and a final equity figure expressed in USD. // Next-bar fill: buy fills on day 2 at Open=110, sell fills on day 3 at Open=120. // // Starting cash = 10,000 USD // Day 1: strategy decides buy 2. Pending. // Day 2: fill buy 2@110 → cash = 10,000 - 220 = 9,780, pos = 2 // strategy decides sell 1. Pending. // Day 3: fill sell 1@120 → cash = 9,780 + 120 = 9,900, pos = 1 // pos marked at 120. // Final equity = cash 9,900 + 1*120 = 10,020 USD // // Trades: buy 2@110, sell 1@120 // Positions: 1 unit NASDAQ:AAPL at last price 120 USD // Total return = (10020 - 10000) / 10000 = 0.002 instID := market.InstrumentID("NASDAQ:AAPL") bar1 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "100"}}, Close: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "100"}}, } bar2 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "110"}}, Close: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "110"}}, } bar3 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "120"}}, Close: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "120"}}, } barSource := &inMemoryBarSource{bars: []market.Bar{bar1, bar2, bar3}} strat := &deterministicStrategy{id: "det-strat-us"} strategyPort := &inMemoryStrategyPort{strategy: strat} resultStore := newInMemoryResultStore() engine := NewEngine(barSource, strategyPort, resultStore) run := backtest.Run{ ID: "run-us-smoke", Spec: backtest.RunSpec{ StrategyID: "det-strat-us", Market: market.MarketUS, Timeframe: market.TimeframeDaily, From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), }, } err := engine.Execute(context.Background(), run) if err != nil { t.Fatalf("US execution failed: %v", err) } res, err := resultStore.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get result: %v", err) } // Verify starting cash is USD 10,000 if res.StartingCash.Currency != market.CurrencyUSD { t.Errorf("starting cash currency: got %q, want %q", res.StartingCash.Currency, market.CurrencyUSD) } if res.StartingCash.Amount.Value != "10000" { t.Errorf("expected starting cash 10000, got %s", res.StartingCash.Amount.Value) } // Verify final equity // Day 1: pending buy, mark pos=0 → equity = 10,000 // Day 2: buy 2@110 → cash 9,780 + 2*110 = 10,000 // Day 3: sell 1@120 → cash 9,900 + 1*120 = 10,020 if res.EndingEquity.Currency != market.CurrencyUSD { t.Errorf("ending equity currency: got %q, want %q", res.EndingEquity.Currency, market.CurrencyUSD) } if res.EndingEquity.Amount.Value != "10020" { t.Errorf("expected ending equity 10020, got %s", res.EndingEquity.Amount.Value) } // Verify Trades if len(res.Trades) != 2 { t.Fatalf("expected 2 trades, got %d", len(res.Trades)) } trade1 := res.Trades[0] if trade1.Side != backtest.OrderSideBuy || trade1.Quantity.Amount.Value != "2" || trade1.Price.Amount.Value != "110" { t.Errorf("unexpected trade 1: %+v", trade1) } trade2 := res.Trades[1] if trade2.Side != backtest.OrderSideSell || trade2.Quantity.Amount.Value != "1" || trade2.Price.Amount.Value != "120" { t.Errorf("unexpected trade 2: %+v", trade2) } // Verify Positions if len(res.Positions) != 1 { t.Fatalf("expected 1 position, got %d", len(res.Positions)) } pos := res.Positions[0] if pos.InstrumentID != instID || pos.Quantity.Amount.Value != "1" || pos.LastPrice.Amount.Value != "120" { t.Errorf("unexpected position: %+v", pos) } // Verify derived summary metrics if res.Summary.EndingEquity.Amount.Value != "10020" { t.Errorf("summary ending equity: got %s, want 10020", res.Summary.EndingEquity.Amount.Value) } if res.Summary.TradeCount != 2 { t.Errorf("summary trade count: got %d, want 2", res.Summary.TradeCount) } // (10020 - 10000) / 10000 = 0.002 if res.Summary.TotalReturn.Value != "0.002" { t.Errorf("summary total return: got %s, want 0.002", res.Summary.TotalReturn.Value) } // Verify equity curve: one point per bar // Day 1: no fill yet, equity = 10,000 // Day 2: buy 2@110 → cash 9,780 + 2*110 = 10,000 // Day 3: sell 1@120 → cash 9,900 + 1*120 = 10,020 if len(res.EquityCurve) != 3 { t.Fatalf("equity curve length: got %d, want 3", len(res.EquityCurve)) } wantCurve := []struct { ts time.Time equity string }{ {bar1.Timestamp, "10000"}, {bar2.Timestamp, "10000"}, {bar3.Timestamp, "10020"}, } for i, want := range wantCurve { point := res.EquityCurve[i] if !point.Timestamp.Equal(want.ts) { t.Errorf("equity curve point %d timestamp: got %v, want %v", i, point.Timestamp, want.ts) } if point.Equity.Amount.Value != want.equity { t.Errorf("equity curve point %d equity: got %s, want %s", i, point.Equity.Amount.Value, want.equity) } if point.Equity.Currency != market.CurrencyUSD { t.Errorf("equity curve point %d currency: got %q, want %q", i, point.Equity.Currency, market.CurrencyUSD) } } // Final equity curve point must match ending equity finalPoint := res.EquityCurve[len(res.EquityCurve)-1] if finalPoint.Equity.Amount.Value != res.EndingEquity.Amount.Value { t.Errorf("final equity point %s does not match ending equity %s", finalPoint.Equity.Amount.Value, res.EndingEquity.Amount.Value) } } // TestEngineProducesDeterministicResultAcrossTimeframes verifies that the // same strategy logic produces identical financial results across monthly, // daily, and minute timeframes when bar prices are identical. func TestEngineProducesDeterministicResultAcrossTimeframes(t *testing.T) { instID := market.InstrumentID("KRX:005930") type barData struct { day int price string } baseBars := []barData{ {1, "1000"}, {2, "1100"}, {3, "1200"}, } for _, tf := range []market.Timeframe{market.TimeframeMonthly, market.TimeframeDaily, market.TimeframeMin1} { tfLabel := string(tf) t.Run(tfLabel, func(t *testing.T) { var bars []market.Bar for _, b := range baseBars { bars = append(bars, market.Bar{ InstrumentID: instID, Timeframe: tf, Timestamp: time.Date(2026, 5, b.day, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}}, High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}}, Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}}, }) } barSource := &inMemoryBarSource{bars: bars} strat := &deterministicStrategy{id: "det-strat-tf"} strategyPort := &inMemoryStrategyPort{strategy: strat} resultStore := newInMemoryResultStore() engine := NewEngine(barSource, strategyPort, resultStore) run := backtest.Run{ ID: backtest.RunID("run-tf-" + tfLabel), Spec: backtest.RunSpec{ StrategyID: "det-strat-tf", Market: market.MarketKR, Timeframe: tf, From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), }, } err := engine.Execute(context.Background(), run) if err != nil { t.Fatalf("execution failed: %v", err) } res, err := resultStore.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get result: %v", err) } // Next-bar fill: buy 2 at day 2 Open=1100, sell 1 at day 3 Open=1200. // cash = 10,000,000 - 2*1100 = 9,997,800, pos = 2 // cash = 9,997,800 + 1200 = 9,999,000, pos = 1 // equity = 9,999,000 + 1*1200 = 10,000,200 if res.StartingCash.Amount.Value != "10000000" { t.Errorf("starting cash: got %s, want 10000000", res.StartingCash.Amount.Value) } if res.EndingEquity.Amount.Value != "10000200" { t.Errorf("ending equity: got %s, want 10000200", res.EndingEquity.Amount.Value) } if res.Summary.TradeCount != 2 { t.Errorf("trade count: got %d, want 2", res.Summary.TradeCount) } if len(res.Trades) != 2 { t.Fatalf("trades length: got %d, want 2", len(res.Trades)) } if res.Trades[0].Price.Amount.Value != "1100" { t.Errorf("trade 0 price: got %s, want 1100", res.Trades[0].Price.Amount.Value) } if res.Trades[1].Price.Amount.Value != "1200" { t.Errorf("trade 1 price: got %s, want 1200", res.Trades[1].Price.Amount.Value) } if len(res.EquityCurve) != 3 { t.Fatalf("equity curve length: got %d, want 3", len(res.EquityCurve)) } }) } } // TestEngineStoresAndQueriesFixtureResult verifies the full engine pipeline: // bars → strategy → next-bar fills → store → query with deterministic values. // Next-bar fill: buy 2 on day 1 fills on day 2 at 1100, sell 1 on day 2 fills on day 3 at 1200. func TestEngineStoresAndQueriesFixtureResult(t *testing.T) { instID := market.InstrumentID("KRX:005930") bar1 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, } bar2 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}, } bar3 := market.Bar{ InstrumentID: instID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}}, } barSource := &inMemoryBarSource{bars: []market.Bar{bar1, bar2, bar3}} strat := &deterministicStrategy{id: "det-strat"} strategyPort := &inMemoryStrategyPort{strategy: strat} resultStore := newInMemoryResultStore() engine := NewEngine(barSource, strategyPort, resultStore) run := backtest.Run{ ID: "run-query", Spec: backtest.RunSpec{ StrategyID: "det-strat", Market: market.MarketKR, Timeframe: market.TimeframeDaily, From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), }, } err := engine.Execute(context.Background(), run) if err != nil { t.Fatalf("execution failed: %v", err) } res, err := resultStore.GetResult(context.Background(), run.ID) if err != nil { t.Fatalf("failed to get result: %v", err) } // Next-bar fill calculation: // Starting cash = 10,000,000 KRW // // Day 1: strategy decides buy 2. Pending. // Day 2: fill buy 2@1100 → cash = 10,000,000 - 2,200 = 9,997,800, pos = 2 // strategy decides sell 1. Pending. // Day 3: fill sell 1@1200 → cash = 9,997,800 + 1,200 = 9,999,000, pos = 1 // pos marked at 1200. // Final Equity = 9,999,000 + 1*1200 = 10,000,200 KRW // // Expected trades: // 1. Buy 2 units at 1100 KRW on May 2 (next-bar fill of day 1 order) // 2. Sell 1 unit at 1200 KRW on May 3 (next-bar fill of day 2 order) // // Expected positions: // 1. 1 unit of KRX:005930 at last price 1200 KRW if res.StartingCash.Amount.Value != "10000000" { t.Errorf("expected starting cash 10000000, got %s", res.StartingCash.Amount.Value) } if res.EndingEquity.Amount.Value != "10000200" { t.Errorf("expected ending equity 10000200, got %s", res.EndingEquity.Amount.Value) } // Verify Trades if len(res.Trades) != 2 { t.Fatalf("expected 2 trades, got %d", len(res.Trades)) } trade1 := res.Trades[0] if trade1.Side != backtest.OrderSideBuy || trade1.Quantity.Amount.Value != "2" || trade1.Price.Amount.Value != "1100" { t.Errorf("unexpected trade 1: %+v", trade1) } trade2 := res.Trades[1] if trade2.Side != backtest.OrderSideSell || trade2.Quantity.Amount.Value != "1" || trade2.Price.Amount.Value != "1200" { t.Errorf("unexpected trade 2: %+v", trade2) } // Verify Positions if len(res.Positions) != 1 { t.Fatalf("expected 1 position, got %d", len(res.Positions)) } pos := res.Positions[0] if pos.InstrumentID != instID || pos.Quantity.Amount.Value != "1" || pos.LastPrice.Amount.Value != "1200" { t.Errorf("unexpected position: %+v", pos) } // Verify derived summary metrics. if res.Summary.StartingCash.Amount.Value != "10000000" { t.Errorf("summary starting cash: got %s, want 10000000", res.Summary.StartingCash.Amount.Value) } if res.Summary.EndingEquity.Amount.Value != "10000200" { t.Errorf("summary ending equity: got %s, want 10000200", res.Summary.EndingEquity.Amount.Value) } if res.Summary.TradeCount != 2 { t.Errorf("summary trade count: got %d, want 2", res.Summary.TradeCount) } // (10000200 - 10000000) / 10000000 = 0.00002 if res.Summary.TotalReturn.Value != "0.00002" { t.Errorf("summary total return: got %s, want 0.00002", res.Summary.TotalReturn.Value) } // Verify deterministic equity curve: one point per bar, ordered by timestamp. // Day 1: no fill, equity = 10,000,000 // Day 2: buy 2@1100 → cash 9,997,800 + 2*1100 = 10,000,000 // Day 3: sell 1@1200 → cash 9,999,000 + 1*1200 = 10,000,200 if len(res.EquityCurve) != 3 { t.Fatalf("equity curve length: got %d, want 3", len(res.EquityCurve)) } wantCurve := []struct { ts time.Time equity string }{ {bar1.Timestamp, "10000000"}, {bar2.Timestamp, "10000000"}, {bar3.Timestamp, "10000200"}, } for i, want := range wantCurve { point := res.EquityCurve[i] if !point.Timestamp.Equal(want.ts) { t.Errorf("equity curve point %d timestamp: got %v, want %v", i, point.Timestamp, want.ts) } if point.Equity.Amount.Value != want.equity { t.Errorf("equity curve point %d equity: got %s, want %s", i, point.Equity.Amount.Value, want.equity) } } // Final equity curve point must match the reported ending equity. finalPoint := res.EquityCurve[len(res.EquityCurve)-1] if finalPoint.Equity.Amount.Value != res.EndingEquity.Amount.Value { t.Errorf("final equity point %s does not match ending equity %s", finalPoint.Equity.Amount.Value, res.EndingEquity.Amount.Value) } }