- Add asset_type column to instrument table (migration 000003) - Extend market domain types with asset_type and related fields - Update proto definitions for common and market messages - Regenerate Dart and Go protobuf code - Add shared query surface for US market data - Add KIS US daily itemchartprice provider and tests - Update backtest and operator components for US market support - Update socket market mapping for new asset types - Move paper-trading-command-workflow to archive - Add agent-task for m-us-market-expansion (subtasks 02+01, 03+01, 04+02,03)
585 lines
13 KiB
Go
585 lines
13 KiB
Go
package operator
|
|
|
|
import (
|
|
"fmt"
|
|
"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 TestValidateImportDailyBarsUSVenues(t *testing.T) {
|
|
for _, venue := range []string{"nasdaq", "nyse"} {
|
|
t.Run(venue, func(t *testing.T) {
|
|
yamlText := fmt.Sprintf(`
|
|
name: valid_us_import
|
|
timeout: 5s
|
|
steps:
|
|
- id: step1
|
|
action: import_daily_bars
|
|
request:
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: us
|
|
venue: %s
|
|
symbols: ["AAPL"]
|
|
from_yyyymmdd: "20240527"
|
|
to_yyyymmdd: "20240528"
|
|
expect:
|
|
status: ok
|
|
`, venue)
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing valid US import scenario: %v", err)
|
|
}
|
|
got := sc.Steps[0].Request.Venue
|
|
if got != venue {
|
|
t.Errorf("venue = %q, want %q", got, venue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|