alt/services/worker/internal/backtest/bar_source.go
toki f084fa6916 feat: backtest data collection infrastructure milestone completion
- Update backtest-data-collection-infrastructure roadmap
- Archive completed 02+01_storage_bar_selector task files
- Refactor bar_source, engine, and related tests in worker service
2026-06-15 15:30:12 +09:00

177 lines
5.7 KiB
Go

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
}