// 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" // ActionAggregateMonthlyBars aggregates stored daily bars into monthly bars. ActionAggregateMonthlyBars Action = "aggregate_monthly_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" // ActionSchedulerRefreshStatus queries the runtime refresh status of one or // all scheduler schedules from the worker. ActionSchedulerRefreshStatus Action = "scheduler_refresh_status" ) // validActions is the closed set used for strict dry-run validation. var validActions = map[Action]bool{ ActionHello: true, ActionCollectionFreshness: true, ActionImportDailyBars: true, ActionAggregateMonthlyBars: 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, ActionSchedulerRefreshStatus: 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, } // validSchedulerStatuses is the closed set of scheduler refresh status values. var validSchedulerStatuses = map[string]bool{ "": true, "success": true, "stale": 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"` // LatestBucket expects a specific latest bucket (timeframe-aware). LatestBucket string `yaml:"latest_bucket"` // SchedulerStatus expects a specific scheduler refresh status ("success", "stale", "error"). SchedulerStatus string `yaml:"scheduler_status"` // MinImportedBarCount asserts the scheduler-backed refresh imported at least // this many bars. It is only meaningful for the scheduler_refresh_status // action and guards backtest readiness against an empty refresh. MinImportedBarCount *int `yaml:"min_imported_bar_count"` // 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 list_bars/start_backtest/paper_trading // and import_daily_bars. For import_daily_bars, an empty value defaults to // daily; minute values are rejected at the worker boundary as typed // invalid_request. 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"` // ExpectedBuckets is the list of expected buckets (timeframe-aware). ExpectedBuckets []string `yaml:"expected_buckets"` // 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..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..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"` // ScheduleName filters scheduler_refresh_status to a single named schedule. // An empty value returns all schedules. ScheduleName string `yaml:"schedule_name"` // 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) } if step.Expect.SchedulerStatus != "" && !validSchedulerStatuses[step.Expect.SchedulerStatus] { return fmt.Errorf("step %q: unsupported expect.scheduler_status %q (want \"success\", \"stale\", or \"error\")", step.ID, step.Expect.SchedulerStatus) } if step.Expect.MinImportedBarCount != nil { if step.Action != ActionSchedulerRefreshStatus { return fmt.Errorf("step %q: expect.min_imported_bar_count is only valid for the %q action", step.ID, ActionSchedulerRefreshStatus) } if *step.Expect.MinImportedBarCount < 0 { return fmt.Errorf("step %q: expect.min_imported_bar_count 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.ExpectedBuckets) == 0 && len(step.Request.ExpectedYYYYMMDD) > 0 { step.Request.ExpectedBuckets = step.Request.ExpectedYYYYMMDD } 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) } } if len(step.Request.ExpectedBuckets) > 0 { var layout string fieldName := "expected_buckets" var formatDesc string dupDesc := "duplicate bucket" isFromYYYYMMDD := len(step.Request.ExpectedYYYYMMDD) > 0 if isFromYYYYMMDD { fieldName = "expected_yyyymmdd" formatDesc = "YYYYMMDD format" dupDesc = "duplicate date" } switch step.Request.Timeframe { case "monthly": layout = "200601" if formatDesc == "" { formatDesc = "200601 format" } case "daily": layout = "20060102" if formatDesc == "" { formatDesc = "YYYYMMDD format" } case "minute_1", "minute_5": layout = "200601021504" if formatDesc == "" { formatDesc = "200601021504 format" } default: return fmt.Errorf("step %q: unsupported timeframe %q for expected_buckets", step.ID, step.Request.Timeframe) } var prevBucket string for idx, b := range step.Request.ExpectedBuckets { t, err := time.ParseInLocation(layout, b, time.UTC) if err != nil { if isFromYYYYMMDD { return fmt.Errorf("step %q: %s[%d] %q must be in %s", step.ID, fieldName, idx, b, formatDesc) } return fmt.Errorf("step %q: %s[%d] %q must be in %s for timeframe %q", step.ID, fieldName, idx, b, formatDesc, step.Request.Timeframe) } if prevBucket != "" { if b < prevBucket { return fmt.Errorf("step %q: %s must be in ascending order", step.ID, fieldName) } if b == prevBucket { return fmt.Errorf("step %q: %s contains %s %q", step.ID, fieldName, dupDesc, b) } } prevBucket = b tMs := t.UnixMilli() if tMs < step.Request.FromUnixMs || tMs > step.Request.ToUnixMs { return fmt.Errorf("step %q: %s[%d] %q is out of request window [%d, %d]", step.ID, fieldName, idx, b, 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) } // import_daily_bars optional timeframe: scenario validation accepts the // shared vocabulary, while the worker returns typed invalid_request for // currently unsupported non-daily import requests. if step.Request.Timeframe != "" && !validTimeframes[step.Request.Timeframe] { return fmt.Errorf("step %q: unsupported timeframe %q for import_daily_bars (want \"daily\", \"minute_1\", \"minute_5\", \"monthly\")", step.ID, step.Request.Timeframe) } 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) } case ActionSchedulerRefreshStatus: // schedule_name is optional: empty means "return all schedules". case ActionAggregateMonthlyBars: // aggregate_monthly_bars derives monthly bars from stored daily bars, so it // shares the import vocabulary (provider/selector/market/venue/symbols/range) // but takes no timeframe: the source is daily and the target is monthly. if step.Request.Provider == "" { return fmt.Errorf("step %q: aggregate_monthly_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: aggregate_monthly_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: aggregate_monthly_bars requires request.symbols", step.ID) } for _, sym := range step.Request.Symbols { if strings.TrimSpace(sym) == "" { return fmt.Errorf("step %q: aggregate_monthly_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: aggregate_monthly_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: aggregate_monthly_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 }