- 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
177 lines
5.7 KiB
Go
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
|
|
}
|