Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
552 lines
13 KiB
Go
552 lines
13 KiB
Go
package operator
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func fixture(t *testing.T, name string) string {
|
|
t.Helper()
|
|
return filepath.Join("..", "..", "testdata", "operator", name)
|
|
}
|
|
|
|
func TestLoadScenarioValidatesExample(t *testing.T) {
|
|
sc, err := LoadScenario(fixture(t, "api_connection_smoke.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("LoadScenario returned error: %v", err)
|
|
}
|
|
if sc.Name != "api_connection_smoke" {
|
|
t.Errorf("name = %q, want api_connection_smoke", sc.Name)
|
|
}
|
|
if len(sc.Steps) != 1 {
|
|
t.Fatalf("steps = %d, want 1", len(sc.Steps))
|
|
}
|
|
if sc.Steps[0].Action != ActionHello {
|
|
t.Errorf("step action = %q, want %q", sc.Steps[0].Action, ActionHello)
|
|
}
|
|
}
|
|
|
|
func TestLoadScenarioRejectsUnknownAction(t *testing.T) {
|
|
data := []byte("name: unknown_action\ntimeout: 1s\nsteps:\n - id: unknown\n action: send_unsupported_request\n expect:\n status: error\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown action, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown action") {
|
|
t.Errorf("error = %v, want it to mention unknown action", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadScenarioRejectsMissingStepID(t *testing.T) {
|
|
data := []byte("name: missing_id\ntimeout: 1s\nsteps:\n - action: hello\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing step id, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "id is required") {
|
|
t.Errorf("error = %v, want it to mention id is required", err)
|
|
}
|
|
}
|
|
|
|
func TestParseScenarioRejectsInvalidDuration(t *testing.T) {
|
|
data := []byte("name: invalid_timeout\ntimeout: not-a-duration\nsteps:\n - id: hello\n action: hello\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid timeout, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid timeout") {
|
|
t.Errorf("error = %v, want it to mention invalid timeout", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsUnknownExpectStatus(t *testing.T) {
|
|
data := []byte("name: bad_expect\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n status: maybe\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown expect.status, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "expect.status") {
|
|
t.Errorf("error = %v, want it to mention expect.status", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsErrorCodeWithoutErrorStatus(t *testing.T) {
|
|
data := []byte("name: bad_code\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n status: ok\n error_code: invalid_request\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for error_code without error status, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "error_code") {
|
|
t.Errorf("error = %v, want it to mention error_code", err)
|
|
}
|
|
}
|
|
func TestInvalidRequestMatrixDryRunCoverage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
expectedErr string
|
|
}{
|
|
{
|
|
name: "missing_run_id",
|
|
yaml: `
|
|
name: missing_run_id
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: get_backtest_result
|
|
request: {}
|
|
`,
|
|
expectedErr: "requires request.run_id",
|
|
},
|
|
{
|
|
name: "missing_strategy_id",
|
|
yaml: `
|
|
name: missing_strategy_id
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: start_backtest
|
|
request:
|
|
market: kr
|
|
timeframe: daily
|
|
from_unix_ms: 100
|
|
to_unix_ms: 200
|
|
`,
|
|
expectedErr: "requires request.strategy_id",
|
|
},
|
|
{
|
|
name: "empty_compare_id",
|
|
yaml: `
|
|
name: empty_compare_id
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: compare_backtest_runs
|
|
request:
|
|
run_ids: [""]
|
|
`,
|
|
expectedErr: "cannot contain empty or blank values",
|
|
},
|
|
{
|
|
name: "invalid_market",
|
|
yaml: `
|
|
name: invalid_market
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: start_backtest
|
|
request:
|
|
strategy_id: strat1
|
|
market: invalid_market
|
|
timeframe: daily
|
|
from_unix_ms: 100
|
|
to_unix_ms: 200
|
|
`,
|
|
expectedErr: "unsupported market",
|
|
},
|
|
{
|
|
name: "invalid_timeframe",
|
|
yaml: `
|
|
name: invalid_timeframe
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: start_backtest
|
|
request:
|
|
strategy_id: strat1
|
|
market: kr
|
|
timeframe: invalid_timeframe
|
|
from_unix_ms: 100
|
|
to_unix_ms: 200
|
|
`,
|
|
expectedErr: "unsupported timeframe",
|
|
},
|
|
{
|
|
name: "invalid_date_range",
|
|
yaml: `
|
|
name: invalid_date_range
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: start_backtest
|
|
request:
|
|
strategy_id: strat1
|
|
market: kr
|
|
timeframe: daily
|
|
from_unix_ms: 200
|
|
to_unix_ms: 100
|
|
`,
|
|
expectedErr: "cannot be after request.to_unix_ms",
|
|
},
|
|
{
|
|
name: "import_missing_provider",
|
|
yaml: `
|
|
name: import_missing_provider
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "requires request.provider",
|
|
},
|
|
{
|
|
name: "import_invalid_provider",
|
|
yaml: `
|
|
name: import_invalid_provider
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: invalid
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "unsupported provider",
|
|
},
|
|
{
|
|
name: "import_missing_selector_kind",
|
|
yaml: `
|
|
name: import_missing_selector_kind
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "requires request.selector_kind",
|
|
},
|
|
{
|
|
name: "import_invalid_selector_kind",
|
|
yaml: `
|
|
name: import_invalid_selector_kind
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: invalid
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "unsupported selector_kind",
|
|
},
|
|
{
|
|
name: "import_missing_symbols",
|
|
yaml: `
|
|
name: import_missing_symbols
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "requires request.symbols",
|
|
},
|
|
{
|
|
name: "import_empty_symbol",
|
|
yaml: `
|
|
name: import_empty_symbol
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930", " "]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "symbols cannot contain empty or blank values",
|
|
},
|
|
{
|
|
name: "import_invalid_market",
|
|
yaml: `
|
|
name: import_invalid_market
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
market: invalid
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "unsupported market",
|
|
},
|
|
{
|
|
name: "import_invalid_venue",
|
|
yaml: `
|
|
name: import_invalid_venue
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
venue: invalid
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "unsupported venue",
|
|
},
|
|
{
|
|
name: "import_invalid_from_yyyymmdd",
|
|
yaml: `
|
|
name: import_invalid_from_yyyymmdd
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "2024-05"
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "must be in YYYYMMDD format",
|
|
},
|
|
{
|
|
name: "import_invalid_to_yyyymmdd",
|
|
yaml: `
|
|
name: import_invalid_to_yyyymmdd
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240532"
|
|
`,
|
|
expectedErr: "must be in YYYYMMDD format",
|
|
},
|
|
{
|
|
name: "import_reversed_dates",
|
|
yaml: `
|
|
name: import_reversed_dates
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240528"
|
|
to_yyyymmdd: "20240527"
|
|
`,
|
|
expectedErr: "cannot be after request.to_yyyymmdd",
|
|
},
|
|
{
|
|
name: "import_missing_from_yyyymmdd",
|
|
yaml: `
|
|
name: import_missing_from_yyyymmdd
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
to_yyyymmdd: "20240528"
|
|
`,
|
|
expectedErr: "requires request.from_yyyymmdd",
|
|
},
|
|
{
|
|
name: "import_missing_to_yyyymmdd",
|
|
yaml: `
|
|
name: import_missing_to_yyyymmdd
|
|
timeout: 1s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
`,
|
|
expectedErr: "requires request.to_yyyymmdd",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := ParseScenario([]byte(tt.yaml))
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tt.expectedErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.expectedErr) {
|
|
t.Errorf("error = %v, want it to contain %q", err, tt.expectedErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateImportDailyBars(t *testing.T) {
|
|
yamlText := `
|
|
name: valid_import
|
|
timeout: 5s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
venue: krx
|
|
symbols: ["005930"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
expect:
|
|
status: ok
|
|
`
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing valid import scenario: %v", err)
|
|
}
|
|
if len(sc.Steps) != 1 {
|
|
t.Fatalf("steps = %d, want 1", len(sc.Steps))
|
|
}
|
|
step := sc.Steps[0]
|
|
if step.Action != ActionImportDailyBars {
|
|
t.Errorf("action = %q, want %q", step.Action, ActionImportDailyBars)
|
|
}
|
|
if step.Request.Provider != "kis" {
|
|
t.Errorf("provider = %q, want kis", step.Request.Provider)
|
|
}
|
|
if step.Request.SelectorKind != "watchlist" {
|
|
t.Errorf("selector_kind = %q, want watchlist", step.Request.SelectorKind)
|
|
}
|
|
if step.Request.Market != "kr" {
|
|
t.Errorf("market = %q, want kr", step.Request.Market)
|
|
}
|
|
if step.Request.Venue != "krx" {
|
|
t.Errorf("venue = %q, want krx", step.Request.Venue)
|
|
}
|
|
if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" {
|
|
t.Errorf("symbols = %v, want [005930]", step.Request.Symbols)
|
|
}
|
|
if step.Request.FromYYYYMMDD != "20240527" {
|
|
t.Errorf("from_yyyymmdd = %q, want 20240527", step.Request.FromYYYYMMDD)
|
|
}
|
|
if step.Request.ToYYYYMMDD != "20240528" {
|
|
t.Errorf("to_yyyymmdd = %q, want 20240528", step.Request.ToYYYYMMDD)
|
|
}
|
|
}
|
|
|
|
func TestValidatePaperTradingScenario(t *testing.T) {
|
|
yamlText := `
|
|
name: valid_paper
|
|
timeout: 5s
|
|
steps:
|
|
- id: start
|
|
action: start_paper_trading
|
|
request:
|
|
account_id: paper-1
|
|
strategy_id: strategy-v1
|
|
market: kr
|
|
timeframe: daily
|
|
from_unix_ms: 1746057600000
|
|
to_unix_ms: 1747267200000
|
|
starting_cash: "10000000"
|
|
expect:
|
|
status: ok
|
|
- id: state
|
|
action: get_paper_trading_state
|
|
request:
|
|
account_id: paper-1
|
|
expect:
|
|
status: ok
|
|
`
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing valid paper scenario: %v", err)
|
|
}
|
|
if len(sc.Steps) != 2 {
|
|
t.Fatalf("steps = %d, want 2", len(sc.Steps))
|
|
}
|
|
if sc.Steps[0].Action != ActionStartPaperTrading {
|
|
t.Errorf("action = %q, want %q", sc.Steps[0].Action, ActionStartPaperTrading)
|
|
}
|
|
if sc.Steps[0].Request.AccountID != "paper-1" {
|
|
t.Errorf("account_id = %q, want paper-1", sc.Steps[0].Request.AccountID)
|
|
}
|
|
if sc.Steps[0].Request.StartingCash != "10000000" {
|
|
t.Errorf("starting_cash = %q, want 10000000", sc.Steps[0].Request.StartingCash)
|
|
}
|
|
}
|
|
|
|
func TestValidatePaperTradingRejectsMissingFields(t *testing.T) {
|
|
cases := map[string]string{
|
|
"start missing account_id": `
|
|
name: bad
|
|
steps:
|
|
- id: start
|
|
action: start_paper_trading
|
|
request:
|
|
strategy_id: strategy-v1
|
|
market: kr
|
|
timeframe: daily
|
|
from_unix_ms: 1
|
|
to_unix_ms: 2
|
|
starting_cash: "100"
|
|
`,
|
|
"start missing starting_cash": `
|
|
name: bad
|
|
steps:
|
|
- id: start
|
|
action: start_paper_trading
|
|
request:
|
|
account_id: paper-1
|
|
strategy_id: strategy-v1
|
|
market: kr
|
|
timeframe: daily
|
|
from_unix_ms: 1
|
|
to_unix_ms: 2
|
|
`,
|
|
"state missing account_id": `
|
|
name: bad
|
|
steps:
|
|
- id: state
|
|
action: get_paper_trading_state
|
|
request: {}
|
|
`,
|
|
}
|
|
for name, yamlText := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := ParseScenario([]byte(yamlText)); err == nil {
|
|
t.Fatalf("expected validation error for %q", name)
|
|
}
|
|
})
|
|
}
|
|
}
|