alt/services/worker/internal/backtest/flow_test.go

233 lines
7.7 KiB
Go

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), 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), 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), 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)
}
// Buy 1 unit at 1000 on day 1, hold; ending equity = 9,999,000 cash + 1*1200.
if result.Summary.EndingEquity.Amount.Value != "10000200" {
t.Errorf("summary ending equity = %q, want 10000200", result.Summary.EndingEquity.Amount.Value)
}
if result.Summary.TradeCount != 1 {
t.Errorf("summary trade count = %d, want 1", result.Summary.TradeCount)
}
}
func containsStatus(haystack []domainbacktest.RunStatus, needle domainbacktest.RunStatus) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}