Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
449 lines
17 KiB
Go
449 lines
17 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"
|
|
// 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"
|
|
)
|
|
|
|
// validActions is the closed set used for strict dry-run validation.
|
|
var validActions = map[Action]bool{
|
|
ActionHello: true,
|
|
ActionImportDailyBars: true,
|
|
ActionListInstruments: true,
|
|
ActionListBars: true,
|
|
ActionStartBacktest: true,
|
|
ActionListBacktestRuns: true,
|
|
ActionGetBacktestRunDetail: true,
|
|
ActionGetBacktestResult: true,
|
|
ActionCompareBacktestRuns: true,
|
|
ActionPollBacktestRun: true,
|
|
ActionStartPaperTrading: true,
|
|
ActionGetPaperTradingState: 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,
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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)
|
|
}
|
|
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 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
|
|
}
|