package backtest_test import ( "context" "sync" "testing" "time" domainbacktest "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest" "git.toki-labs.com/toki/alt/services/worker/internal/jobs" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) // flowStore is an in-memory store that satisfies the instrument, bar, run, and // result ports the backtest command flow touches. It records every run status // upsert so the test can assert the pending -> running -> succeeded transition. type flowStore struct { mu sync.Mutex instruments map[market.InstrumentID]market.Instrument bars map[market.InstrumentID][]market.Bar runs map[domainbacktest.RunID]domainbacktest.Run results map[domainbacktest.RunID]domainbacktest.Result runStatusLog []domainbacktest.RunStatus } func newFlowStore() *flowStore { return &flowStore{ instruments: make(map[market.InstrumentID]market.Instrument), bars: make(map[market.InstrumentID][]market.Bar), runs: make(map[domainbacktest.RunID]domainbacktest.Run), results: make(map[domainbacktest.RunID]domainbacktest.Result), } } func (s *flowStore) UpsertInstrument(ctx context.Context, inst market.Instrument) error { s.mu.Lock() defer s.mu.Unlock() s.instruments[inst.ID] = inst return nil } func (s *flowStore) GetInstrument(ctx context.Context, id market.InstrumentID) (market.Instrument, error) { s.mu.Lock() defer s.mu.Unlock() inst, ok := s.instruments[id] if !ok { return market.Instrument{}, storage.ErrInstrumentNotFound } return inst, nil } func (s *flowStore) ListInstruments(ctx context.Context) ([]market.Instrument, error) { s.mu.Lock() defer s.mu.Unlock() out := make([]market.Instrument, 0, len(s.instruments)) for _, inst := range s.instruments { out = append(out, inst) } return out, nil } func (s *flowStore) UpsertBar(ctx context.Context, bar market.Bar) error { s.mu.Lock() defer s.mu.Unlock() s.bars[bar.InstrumentID] = append(s.bars[bar.InstrumentID], bar) return nil } func (s *flowStore) GetBars(ctx context.Context, id market.InstrumentID, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { s.mu.Lock() defer s.mu.Unlock() var out []market.Bar for _, bar := range s.bars[id] { if bar.Timeframe != timeframe { continue } if bar.Timestamp.Before(from) || bar.Timestamp.After(to) { continue } out = append(out, bar) } return out, nil } func (s *flowStore) UpsertRun(ctx context.Context, run domainbacktest.Run) error { s.mu.Lock() defer s.mu.Unlock() s.runs[run.ID] = run s.runStatusLog = append(s.runStatusLog, run.Status) return nil } func (s *flowStore) GetRun(ctx context.Context, id domainbacktest.RunID) (domainbacktest.Run, error) { s.mu.Lock() defer s.mu.Unlock() run, ok := s.runs[id] if !ok { return domainbacktest.Run{}, storage.ErrRunNotFound } return run, nil } func (s *flowStore) UpsertResult(ctx context.Context, result domainbacktest.Result) error { s.mu.Lock() defer s.mu.Unlock() s.results[result.RunID] = result return nil } func (s *flowStore) GetResult(ctx context.Context, id domainbacktest.RunID) (domainbacktest.Result, error) { s.mu.Lock() defer s.mu.Unlock() result, ok := s.results[id] if !ok { return domainbacktest.Result{}, storage.ErrResultNotFound } return result, nil } func (s *flowStore) statuses() []domainbacktest.RunStatus { s.mu.Lock() defer s.mu.Unlock() out := make([]domainbacktest.RunStatus, len(s.runStatusLog)) copy(out, s.runStatusLog) return out } // TestBacktestCommandFlowFromImportedBars proves the command-first rail end to // end with storage-backed bars: a started run records a pending run, the runner // executes it through the bundled strategy resolver against imported daily bars, // the run reaches terminal succeeded, and the result carries summary fields the // CLI result step reads. It runs the launch synchronously so no Docker or // goroutine race is involved. func TestBacktestCommandFlowFromImportedBars(t *testing.T) { ctx := context.Background() store := newFlowStore() instID := market.InstrumentID("KRX:005930") if err := store.UpsertInstrument(ctx, market.Instrument{ID: instID, Market: market.MarketKR}); err != nil { t.Fatalf("upsert instrument: %v", err) } // Imported daily bars: a 3-day window the run spec covers. for _, b := range []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"}}}, {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"}}}, {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"}}}, } { if err := store.UpsertBar(ctx, b); err != nil { t.Fatalf("upsert bar: %v", err) } } engine := workerbacktest.NewEngine( workerbacktest.NewStorageBarSource(store, store), workerbacktest.NewBuiltInStrategyPort(), store, ) runner := jobs.NewRunner() jobs.RegisterRunBacktestHandler(runner, store, engine, time.Now) starter := jobs.NewBacktestStarter(runner, store, jobs.WithStarterIDGenerator(func() string { return "run-flow" }), jobs.WithStarterLaunch(func(fn func()) { fn() }), // synchronous for determinism ) spec := domainbacktest.RunSpec{ StrategyID: workerbacktest.BuiltInStrategyID, 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), } run, err := starter.StartBacktest(ctx, spec) if err != nil { t.Fatalf("start backtest: %v", err) } if run.ID != "run-flow" { t.Fatalf("unexpected run id %q", run.ID) } // Poll: the run reached terminal succeeded after synchronous execution. got, err := store.GetRun(ctx, run.ID) if err != nil { t.Fatalf("get run: %v", err) } if got.Status != domainbacktest.RunStatusSucceeded { t.Fatalf("expected terminal status succeeded, got %q (statuses=%v)", got.Status, store.statuses()) } // The pending -> running -> succeeded transition is recorded. statuses := store.statuses() wantTransition := []domainbacktest.RunStatus{ domainbacktest.RunStatusPending, domainbacktest.RunStatusRunning, domainbacktest.RunStatusSucceeded, } for _, want := range wantTransition { if !containsStatus(statuses, want) { t.Errorf("expected status %q in transition log %v", want, statuses) } } // Result: summary fields the CLI result step reads are present and reflect the // buy-and-hold run over the imported bars. result, err := store.GetResult(ctx, run.ID) if err != nil { t.Fatalf("get result: %v", err) } if result.RunID != run.ID { t.Errorf("result run id = %q, want %q", result.RunID, run.ID) } if result.Summary.StartingCash.Amount.Value != "10000000" { t.Errorf("summary starting cash = %q, want 10000000", result.Summary.StartingCash.Amount.Value) } // Next-bar fill: buy 1 unit at 1000 on day 1 fills on day 2 at 1100. // cash = 10,000,000 - 1100 = 9,998,900, pos = 1 // ending equity = 9,998,900 + 1*1200 = 10,000,100 if result.Summary.EndingEquity.Amount.Value != "10000100" { t.Errorf("summary ending equity = %q, want 10000100", result.Summary.EndingEquity.Amount.Value) } if result.Summary.TradeCount != 1 { t.Errorf("summary trade count = %d, want 1", result.Summary.TradeCount) } } // TestBacktestCommandFlowUsesInputSelector proves the input selector narrows the // storage-backed run input at the engine/storage boundary: two KR instruments are // imported, but the run spec selects only one, so the executed result reflects // only the selected instrument's bars and never trades the unselected one. // // It drives Engine.Execute against StorageBarSource directly rather than through // the BacktestStarter job rail so the test stays focused on the storage-side // selection behavior owned by the previous subtask. func TestBacktestCommandFlowUsesInputSelector(t *testing.T) { ctx := context.Background() store := newFlowStore() selectedID := market.InstrumentID("KRX:005930") excludedID := market.InstrumentID("KRX:000660") for _, inst := range []market.Instrument{ {ID: selectedID, Market: market.MarketKR, Venue: market.VenueKRX, Symbol: "005930"}, {ID: excludedID, Market: market.MarketKR, Venue: market.VenueKRX, Symbol: "000660"}, } { if err := store.UpsertInstrument(ctx, inst); err != nil { t.Fatalf("upsert instrument %q: %v", inst.ID, err) } } // Both instruments have bars in the same window; only the selected one should // be processed. for _, b := range []market.Bar{ {InstrumentID: selectedID, 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"}}}, {InstrumentID: selectedID, 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"}}}, {InstrumentID: selectedID, 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"}}}, {InstrumentID: excludedID, 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: "5000"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5000"}}}, {InstrumentID: excludedID, 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: "5100"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5100"}}}, {InstrumentID: excludedID, 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: "5200"}}, Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5200"}}}, } { if err := store.UpsertBar(ctx, b); err != nil { t.Fatalf("upsert bar: %v", err) } } engine := workerbacktest.NewEngine( workerbacktest.NewStorageBarSource(store, store), workerbacktest.NewBuiltInStrategyPort(), store, ) run := domainbacktest.Run{ ID: "run-selector", Spec: domainbacktest.RunSpec{ StrategyID: workerbacktest.BuiltInStrategyID, 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), Selector: domainbacktest.InputSelector{InstrumentIDs: []market.InstrumentID{selectedID}}, }, Status: domainbacktest.RunStatusRunning, } if err := engine.Execute(ctx, run); err != nil { t.Fatalf("execute backtest: %v", err) } result, err := store.GetResult(ctx, run.ID) if err != nil { t.Fatalf("get result: %v", err) } // Buy-and-hold buys one unit per instrument seen. With only the selected // instrument processed, exactly one trade is recorded and it belongs to the // selected instrument. If the unselected instrument had been processed there // would be two trades. if result.Summary.TradeCount != 1 { t.Fatalf("trade count = %d, want 1 (only selected instrument processed)", result.Summary.TradeCount) } for _, trade := range result.Trades { if trade.InstrumentID == excludedID { t.Fatalf("unselected instrument %q was traded: %+v", excludedID, trade) } if trade.InstrumentID != selectedID { t.Fatalf("unexpected traded instrument %q, want %q", trade.InstrumentID, selectedID) } } for _, pos := range result.Positions { if pos.InstrumentID == excludedID { t.Fatalf("unselected instrument %q holds a position: %+v", excludedID, pos) } } // Next-bar fill: buy 1 unit at 1000 on day 1 fills on day 2 at 1100. // cash = 10,000,000 - 1100 = 9,998,900, pos = 1 // ending equity = 9,998,900 + 1*1200 = 10,000,100 if result.Summary.EndingEquity.Amount.Value != "10000100" { t.Errorf("summary ending equity = %q, want 10000100", result.Summary.EndingEquity.Amount.Value) } } func containsStatus(haystack []domainbacktest.RunStatus, needle domainbacktest.RunStatus) bool { for _, s := range haystack { if s == needle { return true } } return false }