alt/apps/cli/internal/operator/scenario.go
toki 1c11a17eff feat: operator headless workflow validation and backtest support
- Implement backtest workflow validation for headless operator mode
- Add handoff evidence tracking for backtest runs
- Update operator client, runner, scenario, and output modules
- Add test files for handoff, backtest runner, and error handling
- Add test data for backtest scenarios and validation
2026-06-01 13:21:20 +09:00

327 lines
12 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"
// 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
}