alt/apps/cli/internal/operator/scenario.go
toki 9cdda549c2 feat(cli): 백테스트 operator에 multi-timeframe 지원과 테스트를 추가한다
runner.go, scenario.go에 multi-timeframe 기능 추가
scenario_test.go에 테스트 Cases 추가
2026-06-17 21:33:01 +09:00

1019 lines
39 KiB
Go

// Package operator defines the headless operator scenario schema and the
// dry-run validation used before the API/worker runtime exists. Keeping the
// scenario format and its checks here lets the CLI verify operator workflows
// as plain YAML fixtures, in line with ALT's headless-first operations gate.
package operator
import (
"bytes"
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Action enumerates the operator step actions the headless validator
// understands. The set is intentionally small: it grows as later subtasks add
// real API/worker request runners. Any action outside this set is rejected at
// dry-run time so scenario files cannot reference unimplemented behaviour.
type Action string
const (
// ActionHello validates a basic API connection handshake.
ActionHello Action = "hello"
// ActionCollectionFreshness validates a freshness check of collected data.
ActionCollectionFreshness Action = "collection_freshness"
// ActionImportDailyBars imports market data daily bars.
ActionImportDailyBars Action = "import_daily_bars"
// ActionListInstruments lists market instruments through the API.
ActionListInstruments Action = "list_instruments"
// ActionListBars lists market bars for an instrument through the API.
ActionListBars Action = "list_bars"
// ActionStartBacktest starts a backtest run.
ActionStartBacktest Action = "start_backtest"
// ActionListBacktestRuns lists backtest runs.
ActionListBacktestRuns Action = "list_backtest_runs"
// ActionGetBacktestRunDetail gets backtest run detail.
ActionGetBacktestRunDetail Action = "get_backtest_run_detail"
// ActionGetBacktestResult gets backtest result.
ActionGetBacktestResult Action = "get_backtest_result"
// ActionCompareBacktestRuns compares backtest runs.
ActionCompareBacktestRuns Action = "compare_backtest_runs"
// ActionPollBacktestRun polls a backtest run until terminal status.
ActionPollBacktestRun Action = "poll_backtest_run"
// ActionStartPaperTrading starts a paper trading account run.
ActionStartPaperTrading Action = "start_paper_trading"
// ActionGetPaperTradingState reads a paper trading account state.
ActionGetPaperTradingState Action = "get_paper_trading_state"
// ActionSubmitPaperOrder submits a virtual paper order.
ActionSubmitPaperOrder Action = "submit_paper_order"
// ActionCancelPaperOrder cancels a pending virtual paper order.
ActionCancelPaperOrder Action = "cancel_paper_order"
// ActionFillPaperOrder simulates the fill of a pending virtual paper order.
ActionFillPaperOrder Action = "fill_paper_order"
// ActionSubmitLiveOrder submits a real live order through the broker with
// operator confirmation.
ActionSubmitLiveOrder Action = "submit_live_order"
// ActionCancelLiveOrder cancels a pending live order.
ActionCancelLiveOrder Action = "cancel_live_order"
// ActionGetLiveOrder retrieves the current status of a live order.
ActionGetLiveOrder Action = "get_live_order"
// ActionGetLiveRiskPolicy retrieves the current live trading risk policy.
ActionGetLiveRiskPolicy Action = "get_live_risk_policy"
// ActionGetLiveKillSwitch retrieves the current live kill switch state.
ActionGetLiveKillSwitch Action = "get_live_kill_switch"
// ActionSetLiveKillSwitch sets the live kill switch state (halt/resume).
ActionSetLiveKillSwitch Action = "set_live_kill_switch"
// ActionSyncLiveAccount fetches a fresh account snapshot from the broker and
// stores it in the worker.
ActionSyncLiveAccount Action = "sync_live_account"
// ActionGetLiveAccountSnapshot retrieves the last synced account snapshot for
// an operator alias account.
ActionGetLiveAccountSnapshot Action = "get_live_account_snapshot"
// ActionListLiveAuditEvents queries the durable live operation audit trail.
ActionListLiveAuditEvents Action = "list_live_audit_events"
)
// validActions is the closed set used for strict dry-run validation.
var validActions = map[Action]bool{
ActionHello: true,
ActionCollectionFreshness: true,
ActionImportDailyBars: true,
ActionListInstruments: true,
ActionListBars: true,
ActionStartBacktest: true,
ActionListBacktestRuns: true,
ActionGetBacktestRunDetail: true,
ActionGetBacktestResult: true,
ActionCompareBacktestRuns: true,
ActionPollBacktestRun: true,
ActionStartPaperTrading: true,
ActionGetPaperTradingState: true,
ActionSubmitPaperOrder: true,
ActionCancelPaperOrder: true,
ActionFillPaperOrder: true,
ActionSubmitLiveOrder: true,
ActionCancelLiveOrder: true,
ActionGetLiveOrder: true,
ActionGetLiveRiskPolicy: true,
ActionGetLiveKillSwitch: true,
ActionSetLiveKillSwitch: true,
ActionSyncLiveAccount: true,
ActionGetLiveAccountSnapshot: true,
ActionListLiveAuditEvents: true,
}
// validOrderSides is the set of order side strings a submit_paper_order may use.
var validOrderSides = map[string]bool{
"buy": true,
"sell": true,
}
// validOrderTypes is the set of order type strings a submit_paper_order may use.
// An empty value defaults to "market" at the runner.
var validOrderTypes = map[string]bool{
"": true,
"market": true,
"limit": true,
}
// validPaperOrderStatuses is the closed set of expect.order_status values for the
// dry-run check, mirroring the worker-owned PaperOrderStatus vocabulary.
var validPaperOrderStatuses = map[string]bool{
"": true,
"pending": true,
"filled": true,
"canceled": true,
"rejected": true,
}
// validMarkets is the set of market filter strings a list_instruments request
// may use. It mirrors the API's accepted Market values; the runner maps these
// strings onto the altv1.Market enum. An empty value means "no filter".
var validMarkets = map[string]bool{
"": true,
"unspecified": true,
"kr": true,
"us": true,
}
// validProviders is the set of data providers the import_daily_bars request may use.
var validProviders = map[string]bool{
"kis": true,
}
// validSelectorKinds is the set of selector kinds the import_daily_bars request may use.
var validSelectorKinds = map[string]bool{
"watchlist": true,
}
// validVenues is the set of venues the import_daily_bars request may use.
var validVenues = map[string]bool{
"": true,
"unspecified": true,
"krx": true,
"nasdaq": true,
"nyse": true,
}
// validTimeframes is the set of bar timeframe strings a list_bars request may
// use, mirroring the API's accepted Timeframe values.
var validTimeframes = map[string]bool{
"monthly": true,
"daily": true,
"minute_1": true,
"minute_5": true,
}
// validFreshnessStatuses defines the set of expected freshness status values.
var validFreshnessStatuses = map[string]bool{
"": true,
"fresh": true,
"missing": true,
"error": true,
}
// validGapStatuses defines the set of expected gap status values.
var validGapStatuses = map[string]bool{
"": true,
"clean": true,
"gap": true,
"duplicate": true,
"delayed": true,
"mixed": true,
"error": true,
}
// validBacktestStatuses contains valid backtest run status values for the dry-run check.
var validBacktestStatuses = map[string]bool{
"": true,
"unspecified": true,
"pending": true,
"running": true,
"succeeded": true,
"failed": true,
"canceled": true,
}
// expectStatusError is the only non-success expectation status. An empty status
// means the default success expectation.
const expectStatusError = "error"
// validExpectStatuses is the closed set of expectation status values. Anything
// else is a fixture typo that must fail validation rather than silently behave
// like a success expectation.
var validExpectStatuses = map[string]bool{
"": true,
"ok": true,
expectStatusError: true,
"transport_error": true,
"mismatch": true,
}
// Duration wraps time.Duration so scenario YAML can express timeouts as Go
// duration strings (for example "5s") rather than raw nanoseconds.
type Duration time.Duration
// UnmarshalYAML decodes a duration string such as "5s" into a Duration.
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return fmt.Errorf("timeout must be a duration string: %w", err)
}
parsed, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid timeout %q: %w", s, err)
}
*d = Duration(parsed)
return nil
}
// Expect captures the assertions a step makes against its response. The runner
// compares these with the real API response and decides the step status and
// process exit code.
type Expect struct {
// Status is the expected outcome: "ok" (default) for a successful typed
// response, or "error" when a typed ErrorInfo is the intended result.
Status string `yaml:"status"`
// Capabilities lists hello capabilities that must be present in the response.
Capabilities []string `yaml:"capabilities"`
// ErrorCode, when set, is the expected typed ErrorInfo code (for example
// "invalid_request"). It is only meaningful when Status is "error".
ErrorCode string `yaml:"error_code"`
// MinCount, when set, is the minimum number of market results the response
// must contain (instruments or bars).
MinCount *int `yaml:"min_count"`
// RunStatus expects a specific backtest run status (e.g. "succeeded", "failed").
RunStatus string `yaml:"run_status"`
// OrderStatus expects a specific paper order status after a lifecycle step
// (e.g. "pending", "filled", "canceled", "rejected").
OrderStatus string `yaml:"order_status"`
// LiveOrderStatus expects a specific live order status. Unlike paper order
// status, the live order status set is open (broker-defined), so only a
// non-empty value is meaningful here and no closed-set validation is applied.
LiveOrderStatus string `yaml:"live_order_status"`
// TransportStatus expects a specific transport status (e.g. "transport_error").
TransportStatus string `yaml:"transport_status"`
// ExitCode expects a specific runner exit code.
ExitCode *int `yaml:"exit_code"`
// FreshnessStatus expects a specific freshness status ("fresh", "missing", "error").
FreshnessStatus string `yaml:"freshness_status"`
// MissingCount expects a specific number of missing symbols.
MissingCount *int `yaml:"missing_count"`
// LatestYYYYMMDD expects a specific latest date.
LatestYYYYMMDD string `yaml:"latest_yyyymmdd"`
// GapStatus expects a specific gap status ("clean", "gap", "duplicate", "delayed", "mixed", "error").
GapStatus string `yaml:"gap_status"`
// GapCount expects a specific number of gaps.
GapCount *int `yaml:"gap_count"`
// DuplicateCount expects a specific number of duplicate bars.
DuplicateCount *int `yaml:"duplicate_count"`
// ProviderDelayDays expects a specific provider delay in days.
ProviderDelayDays *int `yaml:"provider_delay_days"`
}
// Request carries the per-action parameters for market and backtest steps. Fields not
// used by a given action are ignored; the runner reads only what each action needs.
type Request struct {
// Universe targets a named universe config defined at the scenario root.
Universe string `yaml:"universe"`
// Market filters a list_instruments query ("", "kr", "us", "unspecified").
Market string `yaml:"market"`
// InstrumentID identifies the instrument for a list_bars query.
InstrumentID string `yaml:"instrument_id"`
// Timeframe selects the bar timeframe for a list_bars query.
Timeframe string `yaml:"timeframe"`
// FromUnixMs and ToUnixMs bound a list_bars query window.
FromUnixMs int64 `yaml:"from_unix_ms"`
ToUnixMs int64 `yaml:"to_unix_ms"`
// Provider is the data provider (e.g. "kis").
Provider string `yaml:"provider"`
// SelectorKind selects the instrument list source (e.g. "watchlist").
SelectorKind string `yaml:"selector_kind"`
// Venue specifies the exchange/market venue (e.g. "krx", "unspecified").
Venue string `yaml:"venue"`
// Name is the name of the selection list, if applicable.
Name string `yaml:"name"`
// Symbols is the explicit list of tickers/symbols to import.
Symbols []string `yaml:"symbols"`
// FromYYYYMMDD and ToYYYYMMDD bound the import date window.
FromYYYYMMDD string `yaml:"from_yyyymmdd"`
ToYYYYMMDD string `yaml:"to_yyyymmdd"`
// ExpectedYYYYMMDD is the list of expected trading dates.
ExpectedYYYYMMDD []string `yaml:"expected_yyyymmdd"`
// StrategyID selects the strategy for starting a backtest.
StrategyID string `yaml:"strategy_id"`
// RunID identifies a specific backtest run.
RunID string `yaml:"run_id"`
// RunIDs is a list of run IDs for comparing runs.
RunIDs []string `yaml:"run_ids"`
// Status filters or specifies a backtest run status.
Status string `yaml:"status"`
// AccountID identifies a paper trading account.
AccountID string `yaml:"account_id"`
// StartingCash is the paper account's initial cash decimal value (for
// example "10000000"). The currency is derived from the request market.
StartingCash string `yaml:"starting_cash"`
// Paper order lifecycle fields. Side/OrderType select the order shape;
// Quantity/LimitPrice/FillPrice are decimal strings whose currency is derived
// from the request market like StartingCash. OrderID targets an existing order
// for cancel/fill and supports {{steps.<id>.order.id}} interpolation.
Side string `yaml:"side"`
Quantity string `yaml:"quantity"`
OrderType string `yaml:"order_type"`
LimitPrice string `yaml:"limit_price"`
OrderID string `yaml:"order_id"`
FillPrice string `yaml:"fill_price"`
// Live order lifecycle fields. Broker identifies the live broker adapter.
// OperatorConfirmed, OperatorID, and ConfirmationReason carry the operator
// confirmation gate. LiveOrderID targets an existing live order for cancel/get
// and supports {{steps.<id>.live_order.id}} interpolation.
Broker string `yaml:"broker"`
OperatorConfirmed bool `yaml:"operator_confirmed"`
OperatorID string `yaml:"operator_id"`
ConfirmationReason string `yaml:"confirmation_reason"`
IdempotencyKey string `yaml:"idempotency_key"`
LiveOrderID string `yaml:"live_order_id"`
InstrumentIDs []string `yaml:"instrument_ids"`
// Kill switch fields. Halted sets the kill switch state for set_live_kill_switch.
// KillSwitchReason is the reason message carried with the state change.
Halted bool `yaml:"halted"`
KillSwitchReason string `yaml:"kill_switch_reason"`
// Audit query fields. EventType filters list_live_audit_events by event type.
// Limit caps the number of returned audit events (0 = no limit).
EventType string `yaml:"event_type"`
Limit int `yaml:"limit"`
// PollingInterval configures how often the runner polls for updates.
PollingInterval Duration `yaml:"polling_interval"`
// PollingTimeout configures the maximum time to wait during polling.
PollingTimeout Duration `yaml:"polling_timeout"`
}
// Step is a single operator action within a scenario.
type Step struct {
ID string `yaml:"id"`
Action Action `yaml:"action"`
Request Request `yaml:"request"`
Expect Expect `yaml:"expect"`
SaveAs string `yaml:"save_as"`
}
// UniverseConfig defines a named selection of instruments that can be referenced
// by multiple steps in a scenario instead of repeating raw tickers inline.
type UniverseConfig struct {
Name string `yaml:"name"`
Provider string `yaml:"provider"`
SelectorKind string `yaml:"selector_kind"`
Market string `yaml:"market"`
Venue string `yaml:"venue"`
Symbols []string `yaml:"symbols"`
}
// Scenario is the top-level operator scenario document.
type Scenario struct {
Name string `yaml:"name"`
Timeout Duration `yaml:"timeout"`
Universes []UniverseConfig `yaml:"universes"`
Matrix *BacktestScenarioMatrix `yaml:"matrix"`
Steps []Step `yaml:"steps"`
}
type MatrixExpect struct {
RunStatus string `yaml:"run_status"`
}
type BacktestScenarioMatrix struct {
Universes []string `yaml:"universes"`
Strategies []string `yaml:"strategies"`
Timeframes []string `yaml:"timeframes"`
Periods []MatrixPeriod `yaml:"periods"`
Expect MatrixExpect `yaml:"expect"`
}
type MatrixPeriod struct {
ID string `yaml:"id"`
FromUnixMs int64 `yaml:"from_unix_ms"`
ToUnixMs int64 `yaml:"to_unix_ms"`
}
type MatrixRun struct {
ID string
Universe string
StrategyID string
Timeframe string
PeriodID string
Market string
Symbols []string
FromUnixMs int64
ToUnixMs int64
ExpectedRunStatus string
}
// LoadScenario reads a scenario file and returns it after strict decode and
// dry-run validation.
func LoadScenario(path string) (*Scenario, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read scenario: %w", err)
}
return ParseScenario(data)
}
// ParseScenario strictly decodes scenario YAML and validates it. Unknown YAML
// fields are rejected so typos in fixtures surface immediately.
func ParseScenario(data []byte) (*Scenario, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var s Scenario
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("decode scenario: %w", err)
}
if err := s.Validate(); err != nil {
return nil, err
}
return &s, nil
}
// Validate runs the dry-run checks: a named scenario, at least one step, and
// every step having a unique id and a known action.
func (s *Scenario) Validate() error {
if s.Name == "" {
return fmt.Errorf("scenario name is required")
}
if len(s.Steps) == 0 && s.Matrix == nil {
return fmt.Errorf("scenario %q has no steps and no matrix", s.Name)
}
universeByName := make(map[string]UniverseConfig)
for _, u := range s.Universes {
if u.Name == "" {
return fmt.Errorf("universe name is required")
}
if _, ok := universeByName[u.Name]; ok {
return fmt.Errorf("duplicate universe name %q", u.Name)
}
if u.Provider == "" {
return fmt.Errorf("universe %q: provider is required", u.Name)
}
if !validProviders[u.Provider] {
return fmt.Errorf("universe %q: unsupported provider %q", u.Name, u.Provider)
}
if u.SelectorKind == "" {
return fmt.Errorf("universe %q: selector_kind is required", u.Name)
}
if !validSelectorKinds[u.SelectorKind] {
return fmt.Errorf("universe %q: unsupported selector_kind %q", u.Name, u.SelectorKind)
}
if len(u.Symbols) == 0 {
return fmt.Errorf("universe %q: symbols is required", u.Name)
}
for _, sym := range u.Symbols {
if strings.TrimSpace(sym) == "" {
return fmt.Errorf("universe %q: symbols cannot contain empty or blank values", u.Name)
}
}
if !validMarkets[u.Market] {
return fmt.Errorf("universe %q: unsupported market %q", u.Name, u.Market)
}
if u.Venue != "" && !validVenues[u.Venue] {
return fmt.Errorf("universe %q: unsupported venue %q", u.Name, u.Venue)
}
universeByName[u.Name] = u
}
if s.Matrix != nil {
if s.Matrix.Expect.RunStatus != "" && !validBacktestStatuses[s.Matrix.Expect.RunStatus] {
return fmt.Errorf("matrix: unsupported expect.run_status %q", s.Matrix.Expect.RunStatus)
}
if len(s.Matrix.Universes) == 0 {
return fmt.Errorf("matrix universes is empty")
}
if len(s.Matrix.Strategies) == 0 {
return fmt.Errorf("matrix strategies is empty")
}
if len(s.Matrix.Timeframes) == 0 {
return fmt.Errorf("matrix timeframes is empty")
}
if len(s.Matrix.Periods) == 0 {
return fmt.Errorf("matrix periods is empty")
}
for _, uName := range s.Matrix.Universes {
if uName == "" {
return fmt.Errorf("matrix universe name is required")
}
u, ok := universeByName[uName]
if !ok {
return fmt.Errorf("matrix: referenced universe %q not found", uName)
}
if u.Market == "" || u.Market == "unspecified" {
return fmt.Errorf("matrix: universe %q requires a concrete market for backtest runs (got %q)", uName, u.Market)
}
}
for _, strat := range s.Matrix.Strategies {
if strat == "" {
return fmt.Errorf("matrix strategy name is required")
}
}
for _, tf := range s.Matrix.Timeframes {
if tf == "" {
return fmt.Errorf("matrix timeframe is required")
}
if !validTimeframes[tf] {
return fmt.Errorf("matrix: unsupported timeframe %q", tf)
}
}
for _, p := range s.Matrix.Periods {
if p.ID == "" {
return fmt.Errorf("matrix period ID is required")
}
if p.FromUnixMs == 0 || p.ToUnixMs == 0 {
return fmt.Errorf("matrix period %q: from_unix_ms and to_unix_ms are required", p.ID)
}
if p.FromUnixMs > p.ToUnixMs {
return fmt.Errorf("matrix period %q: from_unix_ms %d cannot be after to_unix_ms %d", p.ID, p.FromUnixMs, p.ToUnixMs)
}
}
seenIDs := make(map[string]bool)
for _, uName := range s.Matrix.Universes {
for _, strat := range s.Matrix.Strategies {
for _, tf := range s.Matrix.Timeframes {
for _, p := range s.Matrix.Periods {
id := fmt.Sprintf("%s__%s__%s__%s", uName, strat, tf, p.ID)
if strings.ContainsAny(id, " \t\n\r") {
return fmt.Errorf("matrix generated ID %q contains whitespace", id)
}
if seenIDs[id] {
return fmt.Errorf("matrix generated duplicate ID %q", id)
}
seenIDs[id] = true
}
}
}
}
}
seen := make(map[string]bool, len(s.Steps))
for i := range s.Steps {
step := &s.Steps[i]
if step.ID == "" {
return fmt.Errorf("step %d: id is required", i)
}
if seen[step.ID] {
return fmt.Errorf("step %q: duplicate id", step.ID)
}
seen[step.ID] = true
if !validActions[step.Action] {
return fmt.Errorf("step %q: unknown action %q", step.ID, step.Action)
}
if err := applyUniverseDefaults(step, universeByName); err != nil {
return err
}
if err := validateRequest(*step); err != nil {
return err
}
if err := validateExpect(*step); err != nil {
return err
}
}
return nil
}
func (s *Scenario) MatrixRuns() ([]MatrixRun, error) {
if s.Matrix == nil {
return []MatrixRun{}, nil
}
if err := s.Validate(); err != nil {
return nil, err
}
universeByName := make(map[string]UniverseConfig, len(s.Universes))
for _, u := range s.Universes {
universeByName[u.Name] = u
}
var runs []MatrixRun
for _, uName := range s.Matrix.Universes {
u := universeByName[uName]
for _, strat := range s.Matrix.Strategies {
for _, tf := range s.Matrix.Timeframes {
for _, p := range s.Matrix.Periods {
id := fmt.Sprintf("%s__%s__%s__%s", uName, strat, tf, p.ID)
runs = append(runs, MatrixRun{
ID: id,
Universe: uName,
StrategyID: strat,
Timeframe: tf,
PeriodID: p.ID,
Market: u.Market,
Symbols: append([]string(nil), u.Symbols...),
FromUnixMs: p.FromUnixMs,
ToUnixMs: p.ToUnixMs,
ExpectedRunStatus: s.Matrix.Expect.RunStatus,
})
}
}
}
}
return runs, nil
}
func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig) error {
if step.Action != ActionImportDailyBars && step.Action != ActionCollectionFreshness && step.Action != ActionStartBacktest {
return nil
}
if step.Request.Universe == "" {
return nil
}
u, ok := universeByName[step.Request.Universe]
if !ok {
return fmt.Errorf("step %q: referenced universe %q not found", step.ID, step.Request.Universe)
}
// Check inline conflicts
if step.Action == ActionImportDailyBars {
if step.Request.Provider != "" ||
step.Request.SelectorKind != "" ||
step.Request.Market != "" ||
step.Request.Venue != "" ||
step.Request.Name != "" ||
len(step.Request.Symbols) > 0 {
return fmt.Errorf("step %q: cannot specify both universe reference %q and inline import fields", step.ID, step.Request.Universe)
}
}
if step.Action == ActionStartBacktest {
if len(step.Request.Symbols) > 0 || len(step.Request.InstrumentIDs) > 0 {
return fmt.Errorf("step %q: cannot specify both universe reference %q and inline selector fields", step.ID, step.Request.Universe)
}
}
// Materialize fields
if step.Action == ActionImportDailyBars || step.Action == ActionCollectionFreshness {
step.Request.Provider = u.Provider
step.Request.SelectorKind = u.SelectorKind
step.Request.Market = u.Market
step.Request.Venue = u.Venue
step.Request.Name = u.Name
step.Request.Symbols = append([]string(nil), u.Symbols...)
if step.Action == ActionImportDailyBars {
step.Request.Universe = ""
}
} else if step.Action == ActionStartBacktest {
step.Request.Market = u.Market
step.Request.Symbols = append([]string(nil), u.Symbols...)
step.Request.Universe = ""
}
return nil
}
// validateExpect applies dry-run checks to a step expectation so a mistyped
// expect.status or a misused error_code fails validation (exit code 2) before
// any socket is opened, instead of being silently treated as a success
// expectation.
func validateExpect(step Step) error {
if !validExpectStatuses[step.Expect.Status] {
return fmt.Errorf("step %q: unsupported expect.status %q (want \"ok\", \"error\", \"transport_error\", or \"mismatch\")", step.ID, step.Expect.Status)
}
if step.Expect.ErrorCode != "" && step.Expect.Status != expectStatusError {
return fmt.Errorf("step %q: expect.error_code is only valid when expect.status is \"error\"", step.ID)
}
if step.Expect.MinCount != nil && *step.Expect.MinCount < 0 {
return fmt.Errorf("step %q: expect.min_count cannot be negative", step.ID)
}
if step.Expect.ExitCode != nil && *step.Expect.ExitCode < 0 {
return fmt.Errorf("step %q: expect.exit_code cannot be negative", step.ID)
}
if step.Expect.RunStatus != "" && !validBacktestStatuses[step.Expect.RunStatus] {
return fmt.Errorf("step %q: unsupported expect.run_status %q", step.ID, step.Expect.RunStatus)
}
if step.Expect.OrderStatus != "" && !validPaperOrderStatuses[step.Expect.OrderStatus] {
return fmt.Errorf("step %q: unsupported expect.order_status %q", step.ID, step.Expect.OrderStatus)
}
if step.Expect.FreshnessStatus != "" && !validFreshnessStatuses[step.Expect.FreshnessStatus] {
return fmt.Errorf("step %q: unsupported expect.freshness_status %q (want \"fresh\", \"missing\", or \"error\")", step.ID, step.Expect.FreshnessStatus)
}
if step.Expect.MissingCount != nil && *step.Expect.MissingCount < 0 {
return fmt.Errorf("step %q: expect.missing_count cannot be negative", step.ID)
}
if step.Expect.GapStatus != "" && !validGapStatuses[step.Expect.GapStatus] {
return fmt.Errorf("step %q: unsupported expect.gap_status %q (want \"clean\", \"gap\", \"duplicate\", \"delayed\", \"mixed\", or \"error\")", step.ID, step.Expect.GapStatus)
}
if step.Expect.GapCount != nil && *step.Expect.GapCount < 0 {
return fmt.Errorf("step %q: expect.gap_count cannot be negative", step.ID)
}
if step.Expect.DuplicateCount != nil && *step.Expect.DuplicateCount < 0 {
return fmt.Errorf("step %q: expect.duplicate_count cannot be negative", step.ID)
}
if step.Expect.ProviderDelayDays != nil && *step.Expect.ProviderDelayDays < 0 {
return fmt.Errorf("step %q: expect.provider_delay_days cannot be negative", step.ID)
}
return nil
}
// validateRequest applies the per-action dry-run checks so a market scenario
// fails validation (exit code 2) before any socket is opened when its request
// fields are malformed.
func validateRequest(step Step) error {
switch step.Action {
case ActionCollectionFreshness:
if step.Request.Universe == "" {
return fmt.Errorf("step %q: collection_freshness requires request.universe", step.ID)
}
if !validTimeframes[step.Request.Timeframe] {
return fmt.Errorf("step %q: unsupported timeframe %q", step.ID, step.Request.Timeframe)
}
if step.Request.FromUnixMs == 0 || step.Request.ToUnixMs == 0 {
return fmt.Errorf("step %q: collection_freshness requires request.from_unix_ms and request.to_unix_ms", step.ID)
}
if step.Request.FromUnixMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
}
if len(step.Request.ExpectedYYYYMMDD) > 0 {
if step.Request.Timeframe != "daily" {
return fmt.Errorf("step %q: expected_yyyymmdd is only supported for timeframe \"daily\"", step.ID)
}
var prevDate string
for idx, d := range step.Request.ExpectedYYYYMMDD {
t, err := time.ParseInLocation("20060102", d, time.UTC)
if err != nil {
return fmt.Errorf("step %q: expected_yyyymmdd[%d] %q must be in YYYYMMDD format", step.ID, idx, d)
}
if prevDate != "" {
if d < prevDate {
return fmt.Errorf("step %q: expected_yyyymmdd must be in ascending order", step.ID)
}
if d == prevDate {
return fmt.Errorf("step %q: expected_yyyymmdd contains duplicate date %q", step.ID, d)
}
}
prevDate = d
tMs := t.UnixMilli()
if tMs < step.Request.FromUnixMs || tMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: expected_yyyymmdd[%d] %q is out of request window [%d, %d]", step.ID, idx, d, step.Request.FromUnixMs, step.Request.ToUnixMs)
}
}
}
case ActionListInstruments:
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
case ActionListBars:
if step.Request.InstrumentID == "" {
return fmt.Errorf("step %q: list_bars requires request.instrument_id", step.ID)
}
if !validTimeframes[step.Request.Timeframe] {
return fmt.Errorf("step %q: unsupported timeframe %q", step.ID, step.Request.Timeframe)
}
if step.Request.FromUnixMs == 0 || step.Request.ToUnixMs == 0 {
return fmt.Errorf("step %q: list_bars requires request.from_unix_ms and request.to_unix_ms", step.ID)
}
if step.Request.FromUnixMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
}
case ActionStartBacktest:
if step.Request.StrategyID == "" {
return fmt.Errorf("step %q: start_backtest requires request.strategy_id", step.ID)
}
if step.Request.Market == "" || step.Request.Market == "unspecified" {
return fmt.Errorf("step %q: start_backtest requires request.market", step.ID)
}
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
if !validTimeframes[step.Request.Timeframe] {
return fmt.Errorf("step %q: unsupported timeframe %q", step.ID, step.Request.Timeframe)
}
if step.Request.FromUnixMs == 0 || step.Request.ToUnixMs == 0 {
return fmt.Errorf("step %q: start_backtest requires request.from_unix_ms and request.to_unix_ms", step.ID)
}
if step.Request.FromUnixMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
}
if len(step.Request.Symbols) == 0 && len(step.Request.InstrumentIDs) == 0 {
return fmt.Errorf("step %q: start_backtest requires request.universe, request.symbols, or request.instrument_ids", step.ID)
}
for _, sym := range step.Request.Symbols {
if strings.TrimSpace(sym) == "" {
return fmt.Errorf("step %q: start_backtest request.symbols cannot contain empty or blank values", step.ID)
}
}
for _, id := range step.Request.InstrumentIDs {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("step %q: start_backtest request.instrument_ids cannot contain empty or blank values", step.ID)
}
}
case ActionGetBacktestRunDetail, ActionGetBacktestResult, ActionPollBacktestRun:
if step.Request.RunID == "" {
return fmt.Errorf("step %q: %s requires request.run_id", step.ID, step.Action)
}
case ActionCompareBacktestRuns:
if len(step.Request.RunIDs) == 0 {
return fmt.Errorf("step %q: compare_backtest_runs requires at least one request.run_ids", step.ID)
}
for _, rid := range step.Request.RunIDs {
if strings.TrimSpace(rid) == "" {
return fmt.Errorf("step %q: compare_backtest_runs request.run_ids cannot contain empty or blank values", step.ID)
}
}
case ActionListBacktestRuns:
if step.Request.Status != "" && !validBacktestStatuses[step.Request.Status] {
return fmt.Errorf("step %q: unsupported status %q", step.ID, step.Request.Status)
}
case ActionStartPaperTrading:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: start_paper_trading requires request.account_id", step.ID)
}
if step.Request.StrategyID == "" {
return fmt.Errorf("step %q: start_paper_trading requires request.strategy_id", step.ID)
}
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
if !validTimeframes[step.Request.Timeframe] {
return fmt.Errorf("step %q: unsupported timeframe %q", step.ID, step.Request.Timeframe)
}
if step.Request.FromUnixMs == 0 || step.Request.ToUnixMs == 0 {
return fmt.Errorf("step %q: start_paper_trading requires request.from_unix_ms and request.to_unix_ms", step.ID)
}
if step.Request.FromUnixMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
}
if strings.TrimSpace(step.Request.StartingCash) == "" {
return fmt.Errorf("step %q: start_paper_trading requires request.starting_cash", step.ID)
}
case ActionGetPaperTradingState:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_paper_trading_state requires request.account_id", step.ID)
}
case ActionSubmitPaperOrder:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: submit_paper_order requires request.account_id", step.ID)
}
if step.Request.InstrumentID == "" {
return fmt.Errorf("step %q: submit_paper_order requires request.instrument_id", step.ID)
}
if !validOrderSides[step.Request.Side] {
return fmt.Errorf("step %q: unsupported side %q (want \"buy\" or \"sell\")", step.ID, step.Request.Side)
}
if strings.TrimSpace(step.Request.Quantity) == "" {
return fmt.Errorf("step %q: submit_paper_order requires request.quantity", step.ID)
}
if !validOrderTypes[step.Request.OrderType] {
return fmt.Errorf("step %q: unsupported order_type %q (want \"market\" or \"limit\")", step.ID, step.Request.OrderType)
}
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
if step.Request.OrderType == "limit" && strings.TrimSpace(step.Request.LimitPrice) == "" {
return fmt.Errorf("step %q: submit_paper_order with order_type limit requires request.limit_price", step.ID)
}
case ActionCancelPaperOrder:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: cancel_paper_order requires request.account_id", step.ID)
}
if strings.TrimSpace(step.Request.OrderID) == "" {
return fmt.Errorf("step %q: cancel_paper_order requires request.order_id", step.ID)
}
case ActionFillPaperOrder:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: fill_paper_order requires request.account_id", step.ID)
}
if strings.TrimSpace(step.Request.OrderID) == "" {
return fmt.Errorf("step %q: fill_paper_order requires request.order_id", step.ID)
}
if step.Request.FillPrice != "" && !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
case ActionSubmitLiveOrder:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: submit_live_order requires request.account_id", step.ID)
}
if step.Request.InstrumentID == "" {
return fmt.Errorf("step %q: submit_live_order requires request.instrument_id", step.ID)
}
if !validOrderSides[step.Request.Side] {
return fmt.Errorf("step %q: unsupported side %q (want \"buy\" or \"sell\")", step.ID, step.Request.Side)
}
if strings.TrimSpace(step.Request.Quantity) == "" {
return fmt.Errorf("step %q: submit_live_order requires request.quantity", step.ID)
}
if strings.TrimSpace(step.Request.OrderType) == "" {
return fmt.Errorf("step %q: submit_live_order requires request.order_type", step.ID)
}
case ActionCancelLiveOrder:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: cancel_live_order requires request.account_id", step.ID)
}
if strings.TrimSpace(step.Request.LiveOrderID) == "" {
return fmt.Errorf("step %q: cancel_live_order requires request.live_order_id", step.ID)
}
case ActionGetLiveOrder:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_live_order requires request.account_id", step.ID)
}
if strings.TrimSpace(step.Request.LiveOrderID) == "" {
return fmt.Errorf("step %q: get_live_order requires request.live_order_id", step.ID)
}
case ActionGetLiveRiskPolicy:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_live_risk_policy requires request.account_id", step.ID)
}
case ActionGetLiveKillSwitch:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_live_kill_switch requires request.account_id", step.ID)
}
case ActionSetLiveKillSwitch:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: set_live_kill_switch requires request.account_id", step.ID)
}
case ActionSyncLiveAccount:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: sync_live_account requires request.account_id", step.ID)
}
case ActionGetLiveAccountSnapshot:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_live_account_snapshot requires request.account_id", step.ID)
}
case ActionListLiveAuditEvents:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: list_live_audit_events requires request.account_id", step.ID)
}
case ActionImportDailyBars:
if step.Request.Provider == "" {
return fmt.Errorf("step %q: import_daily_bars requires request.provider", step.ID)
}
if !validProviders[step.Request.Provider] {
return fmt.Errorf("step %q: unsupported provider %q", step.ID, step.Request.Provider)
}
if step.Request.SelectorKind == "" {
return fmt.Errorf("step %q: import_daily_bars requires request.selector_kind", step.ID)
}
if !validSelectorKinds[step.Request.SelectorKind] {
return fmt.Errorf("step %q: unsupported selector_kind %q", step.ID, step.Request.SelectorKind)
}
if len(step.Request.Symbols) == 0 {
return fmt.Errorf("step %q: import_daily_bars requires request.symbols", step.ID)
}
for _, sym := range step.Request.Symbols {
if strings.TrimSpace(sym) == "" {
return fmt.Errorf("step %q: import_daily_bars request.symbols cannot contain empty or blank values", step.ID)
}
}
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
if step.Request.Venue != "" && !validVenues[step.Request.Venue] {
return fmt.Errorf("step %q: unsupported venue %q", step.ID, step.Request.Venue)
}
if step.Request.FromYYYYMMDD == "" {
return fmt.Errorf("step %q: import_daily_bars requires request.from_yyyymmdd", step.ID)
}
if !validateYYYYMMDD(step.Request.FromYYYYMMDD) {
return fmt.Errorf("step %q: request.from_yyyymmdd must be in YYYYMMDD format", step.ID)
}
if step.Request.ToYYYYMMDD == "" {
return fmt.Errorf("step %q: import_daily_bars requires request.to_yyyymmdd", step.ID)
}
if !validateYYYYMMDD(step.Request.ToYYYYMMDD) {
return fmt.Errorf("step %q: request.to_yyyymmdd must be in YYYYMMDD format", step.ID)
}
if step.Request.FromYYYYMMDD > step.Request.ToYYYYMMDD {
return fmt.Errorf("step %q: request.from_yyyymmdd cannot be after request.to_yyyymmdd", step.ID)
}
}
return nil
}
func validateYYYYMMDD(s string) bool {
if len(s) != 8 {
return false
}
_, err := time.Parse("20060102", s)
return err == nil
}