327 lines
13 KiB
Go
327 lines
13 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), 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
|
|
}
|