// 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" // 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" ) // validActions is the closed set used for strict dry-run validation. var validActions = map[Action]bool{ ActionHello: true, ActionListInstruments: true, ActionListBars: true, ActionStartBacktest: true, ActionListBacktestRuns: true, ActionGetBacktestRunDetail: true, ActionGetBacktestResult: true, ActionCompareBacktestRuns: true, ActionPollBacktestRun: 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, } // 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{ "daily": true, "minute_1": true, "minute_5": 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"` // 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"` } // 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 { // 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"` // 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"` // 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"` } // Scenario is the top-level operator scenario document. type Scenario struct { Name string `yaml:"name"` Timeout Duration `yaml:"timeout"` Steps []Step `yaml:"steps"` } // 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 { return fmt.Errorf("scenario %q has no steps", s.Name) } seen := make(map[string]bool, len(s.Steps)) for i, step := range s.Steps { 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 := validateRequest(step); err != nil { return err } if err := validateExpect(step); err != nil { return err } } 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) } 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 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 !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) } 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) } } return nil }