- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle) - Add paper_order_lifecycle test data and expected output - Update paper trading proto and regenerate code (Dart, Go) - Fix order lifecycle handling in CLI operator, API socket, worker socket - Update parser maps across CLI, API, and worker services - Update backtest and paper trading tests
538 lines
21 KiB
Go
538 lines
21 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"
|
|
// 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"
|
|
)
|
|
|
|
// 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,
|
|
ActionSubmitPaperOrder: true,
|
|
ActionCancelPaperOrder: true,
|
|
ActionFillPaperOrder: 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,
|
|
}
|
|
|
|
// 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"`
|
|
// OrderStatus expects a specific paper order status after a lifecycle step
|
|
// (e.g. "pending", "filled", "canceled", "rejected").
|
|
OrderStatus string `yaml:"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"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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)
|
|
}
|
|
if step.Expect.OrderStatus != "" && !validPaperOrderStatuses[step.Expect.OrderStatus] {
|
|
return fmt.Errorf("step %q: unsupported expect.order_status %q", step.ID, step.Expect.OrderStatus)
|
|
}
|
|
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 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 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
|
|
}
|