실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
621 lines
25 KiB
Go
621 lines
25 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"
|
|
// 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"
|
|
)
|
|
|
|
// 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,
|
|
ActionSubmitLiveOrder: true,
|
|
ActionCancelLiveOrder: true,
|
|
ActionGetLiveOrder: true,
|
|
ActionGetLiveRiskPolicy: true,
|
|
ActionGetLiveKillSwitch: true,
|
|
ActionSetLiveKillSwitch: 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{
|
|
"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"`
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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.<id>.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"`
|
|
|
|
// 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"`
|
|
|
|
// 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 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 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
|
|
}
|