package backtest import ( "context" "fmt" "sort" "strings" "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" ) // StorageBarSource adapts worker market-data storage ports to the backtest // engine's RunSpec input boundary. It also keeps a market/timeframe/date-range // GetBars method so paper trading can reuse the same storage adapter. type StorageBarSource struct { instruments storage.InstrumentStore bars storage.BarStore } func NewStorageBarSource(instruments storage.InstrumentStore, bars storage.BarStore) *StorageBarSource { return &StorageBarSource{instruments: instruments, bars: bars} } // GetBarsForRun resolves the run's input selector against stored instruments and // returns only the selected instruments' bars. An empty selector falls back to // the full market so existing whole-market runs keep working. func (s *StorageBarSource) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) { if s == nil || s.instruments == nil || s.bars == nil { return nil, fmt.Errorf("backtest storage bar source: storage is not configured") } instruments, err := s.instruments.ListInstruments(ctx) if err != nil { return nil, fmt.Errorf("backtest storage bar source: list instruments: %w", err) } selected, err := selectInstruments(instruments, spec.Market, spec.Selector) if err != nil { return nil, err } return s.collectBars(ctx, selected, spec.Timeframe, spec.From, spec.To) } // GetBars returns every stored bar for the given market and date range. It is // retained for paper trading compatibility, which has no run-level selector. func (s *StorageBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { if s == nil || s.instruments == nil || s.bars == nil { return nil, fmt.Errorf("backtest storage bar source: storage is not configured") } instruments, err := s.instruments.ListInstruments(ctx) if err != nil { return nil, fmt.Errorf("backtest storage bar source: list instruments: %w", err) } var selected []market.Instrument for _, inst := range instruments { if inst.Market == mkt { selected = append(selected, inst) } } return s.collectBars(ctx, selected, timeframe, from, to) } // collectBars reads bars for each instrument and returns them sorted by // timestamp with instrument id as a stable tie-breaker. func (s *StorageBarSource) collectBars(ctx context.Context, instruments []market.Instrument, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { var out []market.Bar for _, inst := range instruments { bars, err := s.bars.GetBars(ctx, inst.ID, timeframe, from, to) if err != nil { return nil, fmt.Errorf("backtest storage bar source: get bars for %q: %w", inst.ID, err) } out = append(out, bars...) } sort.Slice(out, func(i, j int) bool { if out[i].Timestamp.Equal(out[j].Timestamp) { return out[i].InstrumentID < out[j].InstrumentID } return out[i].Timestamp.Before(out[j].Timestamp) }) return out, nil } // selectInstruments resolves the input selector within the run's market. An empty // selector returns the whole market. Each requested instrument id and symbol must // resolve to a stored instrument; an unresolved selector entry returns an error so // a backtest never runs silently on the wrong input. func selectInstruments(instruments []market.Instrument, mkt market.Market, selector backtest.InputSelector) ([]market.Instrument, error) { inMarket := make([]market.Instrument, 0, len(instruments)) for _, inst := range instruments { if inst.Market == mkt { inMarket = append(inMarket, inst) } } wantIDs := make([]string, 0, len(selector.InstrumentIDs)) for _, id := range selector.InstrumentIDs { trimmed := strings.TrimSpace(string(id)) if trimmed == "" { return nil, fmt.Errorf("backtest storage bar source: selector instrument id is empty") } wantIDs = append(wantIDs, trimmed) } wantSymbols := make([]string, 0, len(selector.Symbols)) for _, sym := range selector.Symbols { trimmed := strings.TrimSpace(sym) if trimmed == "" { return nil, fmt.Errorf("backtest storage bar source: selector symbol is empty") } wantSymbols = append(wantSymbols, trimmed) } // Empty selector keeps the historical whole-market behavior. if len(wantIDs) == 0 && len(wantSymbols) == 0 { return inMarket, nil } selected := make([]market.Instrument, 0, len(wantIDs)+len(wantSymbols)) seen := make(map[market.InstrumentID]bool) add := func(inst market.Instrument) { if !seen[inst.ID] { seen[inst.ID] = true selected = append(selected, inst) } } for _, want := range wantIDs { found := false for _, inst := range inMarket { if string(inst.ID) == want { add(inst) found = true break } } if !found { return nil, fmt.Errorf("backtest storage bar source: selector instrument id %q not found in market %q", want, mkt) } } for _, want := range wantSymbols { found := false for _, inst := range inMarket { if instrumentMatchesSymbol(inst, want) { add(inst) found = true break } } if !found { return nil, fmt.Errorf("backtest storage bar source: selector symbol %q not found in market %q", want, mkt) } } return selected, nil } // instrumentMatchesSymbol matches a selector symbol against the instrument's // canonical symbol and any provider symbol value. func instrumentMatchesSymbol(inst market.Instrument, symbol string) bool { if strings.TrimSpace(inst.Symbol) == symbol { return true } for _, providerSymbol := range inst.ProviderSymbols { if strings.TrimSpace(providerSymbol) == symbol { return true } } return false }