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 TestValidateRejectsUnknownFreshnessStatus(t *testing.T) { data := []byte("name: bad_freshness_status\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n freshness_status: invalid_status\n") _, err := ParseScenario(data) if err == nil { t.Fatal("expected error for unknown expect.freshness_status, got nil") } if !strings.Contains(err.Error(), "expect.freshness_status") { t.Errorf("error = %v, want it to mention expect.freshness_status", err) } } func TestValidateRejectsNegativeMissingCount(t *testing.T) { data := []byte("name: bad_missing_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n missing_count: -1\n") _, err := ParseScenario(data) if err == nil { t.Fatal("expected error for negative expect.missing_count, got nil") } if !strings.Contains(err.Error(), "expect.missing_count") { t.Errorf("error = %v, want it to mention expect.missing_count", 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: "start_backtest_blank_symbol", yaml: ` name: start_backtest_blank_symbol timeout: 1s steps: - id: step1 action: start_backtest request: strategy_id: strat1 market: kr timeframe: daily from_unix_ms: 100 to_unix_ms: 200 symbols: [" "] `, expectedErr: "start_backtest request.symbols cannot contain empty or blank values", }, { name: "start_backtest_blank_instrument_id", yaml: ` name: start_backtest_blank_instrument_id timeout: 1s steps: - id: step1 action: start_backtest request: strategy_id: strat1 market: kr timeframe: daily from_unix_ms: 100 to_unix_ms: 200 instrument_ids: [""] `, expectedErr: "start_backtest request.instrument_ids cannot contain empty or blank values", }, { name: "start_backtest_universe_inline_conflict", yaml: ` name: start_backtest_universe_inline_conflict timeout: 1s universes: - name: kr-backtest-smoke provider: kis selector_kind: watchlist market: kr symbols: ["005930"] steps: - id: step1 action: start_backtest request: strategy_id: strat1 universe: kr-backtest-smoke symbols: ["005930"] timeframe: daily from_unix_ms: 100 to_unix_ms: 200 `, expectedErr: "cannot specify both universe reference", }, { 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", }, { name: "freshness_missing_universe", yaml: ` name: freshness_missing_universe timeout: 1s steps: - id: step1 action: collection_freshness request: timeframe: daily from_unix_ms: 100 to_unix_ms: 200 `, expectedErr: "requires request.universe", }, { name: "freshness_invalid_timeframe", yaml: ` name: freshness_invalid_timeframe timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: invalid from_unix_ms: 100 to_unix_ms: 200 `, expectedErr: "unsupported timeframe", }, { name: "freshness_missing_unix_ms", yaml: ` name: freshness_missing_unix_ms timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 100 `, expectedErr: "requires request.from_unix_ms and request.to_unix_ms", }, { name: "freshness_reversed_dates", yaml: ` name: freshness_reversed_dates timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 200 to_unix_ms: 100 `, expectedErr: "cannot be after request.to_unix_ms", }, } 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 TestValidateLiveOrderScenario(t *testing.T) { yamlText := ` name: live_order_lifecycle timeout: 10s steps: - id: submit action: submit_live_order request: account_id: live-acct-1 instrument_id: KRX:005930 side: buy quantity: "1" order_type: kis_best_limit operator_confirmed: true operator_id: operator-1 expect: status: ok live_order_status: submitted - id: cancel action: cancel_live_order request: account_id: live-acct-1 live_order_id: "{{steps.submit.live_order.id}}" expect: status: ok ` sc, err := ParseScenario([]byte(yamlText)) if err != nil { t.Fatalf("unexpected error parsing valid live order scenario: %v", err) } if len(sc.Steps) != 2 { t.Fatalf("steps = %d, want 2", len(sc.Steps)) } if sc.Steps[0].Action != ActionSubmitLiveOrder { t.Errorf("step[0] action = %q, want %q", sc.Steps[0].Action, ActionSubmitLiveOrder) } if sc.Steps[0].Request.OrderType != "kis_best_limit" { t.Errorf("order_type = %q, want kis_best_limit (custom type must be preserved)", sc.Steps[0].Request.OrderType) } if !sc.Steps[0].Request.OperatorConfirmed { t.Error("operator_confirmed = false, want true") } } func TestValidateLiveOrderRejectsMissingFields(t *testing.T) { cases := map[string]string{ "submit missing account_id": ` name: bad steps: - id: submit action: submit_live_order request: instrument_id: KRX:005930 side: buy quantity: "1" order_type: market `, "submit missing instrument_id": ` name: bad steps: - id: submit action: submit_live_order request: account_id: live-1 side: buy quantity: "1" order_type: market `, "submit invalid side": ` name: bad steps: - id: submit action: submit_live_order request: account_id: live-1 instrument_id: KRX:005930 side: long quantity: "1" order_type: market `, "submit missing quantity": ` name: bad steps: - id: submit action: submit_live_order request: account_id: live-1 instrument_id: KRX:005930 side: buy order_type: market `, "submit missing order_type": ` name: bad steps: - id: submit action: submit_live_order request: account_id: live-1 instrument_id: KRX:005930 side: buy quantity: "1" `, "cancel missing account_id": ` name: bad steps: - id: cancel action: cancel_live_order request: live_order_id: lo-1 `, "cancel missing live_order_id": ` name: bad steps: - id: cancel action: cancel_live_order request: account_id: live-1 `, "get missing account_id": ` name: bad steps: - id: get action: get_live_order request: live_order_id: lo-1 `, "get missing live_order_id": ` name: bad steps: - id: get action: get_live_order request: account_id: live-1 `, } 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) } }) } } 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) } }) } } func TestValidateImportDailyBarsUniverseReference(t *testing.T) { yamlText := ` name: valid_universe_ref universes: - name: kr-core provider: kis selector_kind: watchlist market: kr venue: krx symbols: ["005930"] steps: - id: step1 action: import_daily_bars request: universe: kr-core from_yyyymmdd: "20240527" to_yyyymmdd: "20240528" expect: status: ok ` sc, err := ParseScenario([]byte(yamlText)) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(sc.Steps) != 1 { t.Fatalf("steps = %d, want 1", len(sc.Steps)) } step := sc.Steps[0] 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.Name != "kr-core" { t.Errorf("name = %q, want kr-core", step.Request.Name) } } func TestValidateRejectsDuplicateUniverseName(t *testing.T) { yamlText := ` name: dup_universe universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] - name: kr-core provider: kis selector_kind: watchlist symbols: ["000660"] steps: - id: step1 action: hello ` _, err := ParseScenario([]byte(yamlText)) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "duplicate universe name") { t.Errorf("error = %v, want duplicate universe name", err) } } func TestValidateRejectsMissingUniverseReference(t *testing.T) { yamlText := ` name: missing_universe_ref universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: import_daily_bars request: universe: kr-other from_yyyymmdd: "20240527" to_yyyymmdd: "20240528" ` _, err := ParseScenario([]byte(yamlText)) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "referenced universe \"kr-other\" not found") { t.Errorf("error = %v, want referenced universe not found", err) } } func TestValidateRejectsUniverseInlineConflict(t *testing.T) { yamlText := ` name: inline_conflict universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: import_daily_bars request: universe: kr-core provider: kis from_yyyymmdd: "20240527" to_yyyymmdd: "20240528" ` _, err := ParseScenario([]byte(yamlText)) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "cannot specify both universe reference") { t.Errorf("error = %v, want cannot specify both universe reference", err) } } func TestValidateImportDailyBarsUniverseReferenceIsIdempotent(t *testing.T) { yamlText := ` name: valid_universe_ref universes: - name: kr-core provider: kis selector_kind: watchlist market: kr venue: krx symbols: ["005930"] steps: - id: step1 action: import_daily_bars request: universe: kr-core from_yyyymmdd: "20240527" to_yyyymmdd: "20240528" expect: status: ok ` sc, err := ParseScenario([]byte(yamlText)) if err != nil { t.Fatalf("unexpected error on ParseScenario: %v", err) } if err := sc.Validate(); err != nil { t.Errorf("unexpected error on second Validate call: %v", err) } } func TestValidateRejectsUnsupportedGapStatus(t *testing.T) { data := []byte("name: bad_gap_status\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n gap_status: invalid_status\n") _, err := ParseScenario(data) if err == nil { t.Fatal("expected error for unknown expect.gap_status, got nil") } if !strings.Contains(err.Error(), "expect.gap_status") { t.Errorf("error = %v, want it to mention expect.gap_status", err) } } func TestValidateRejectsNegativeGapCounts(t *testing.T) { cases := []struct { name string yaml string err string }{ { name: "negative gap_count", yaml: "name: bad_gap_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n gap_count: -1\n", err: "expect.gap_count", }, { name: "negative duplicate_count", yaml: "name: bad_duplicate_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n duplicate_count: -1\n", err: "expect.duplicate_count", }, { name: "negative provider_delay_days", yaml: "name: bad_provider_delay\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n provider_delay_days: -1\n", err: "expect.provider_delay_days", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := ParseScenario([]byte(tc.yaml)) if err == nil { t.Fatalf("expected error for %s, got nil", tc.name) } if !strings.Contains(err.Error(), tc.err) { t.Errorf("error = %v, want it to contain %q", err, tc.err) } }) } } func TestValidateExpectedYYYYMMDD(t *testing.T) { cases := []struct { name string yaml string expectedErr string }{ { name: "timeframe not daily", yaml: ` name: bad_timeframe timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: minute_1 from_unix_ms: 1716768000000 to_unix_ms: 1716854400000 expected_yyyymmdd: ["20240527"] `, expectedErr: "expected_yyyymmdd is only supported for timeframe \"daily\"", }, { name: "malformed date", yaml: ` name: malformed_date timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 1716768000000 to_unix_ms: 1716854400000 expected_yyyymmdd: ["2024-05-27"] `, expectedErr: "must be in YYYYMMDD format", }, { name: "unsorted dates", yaml: ` name: unsorted_dates timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 1716768000000 to_unix_ms: 1716854400000 expected_yyyymmdd: ["20240528", "20240527"] `, expectedErr: "must be in ascending order", }, { name: "duplicate dates", yaml: ` name: duplicate_dates timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 1716768000000 to_unix_ms: 1716854400000 expected_yyyymmdd: ["20240527", "20240527"] `, expectedErr: "contains duplicate date", }, { name: "date out of window before", yaml: ` name: out_of_window_before timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 1716768000000 # 2024-05-27 00:00:00 UTC to_unix_ms: 1716854400000 # 2024-05-28 00:00:00 UTC expected_yyyymmdd: ["20240526"] `, expectedErr: "is out of request window", }, { name: "date out of window after", yaml: ` name: out_of_window_after timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 1716768000000 # 2024-05-27 00:00:00 UTC to_unix_ms: 1716854400000 # 2024-05-28 00:00:00 UTC expected_yyyymmdd: ["20240529"] `, expectedErr: "is out of request window", }, { name: "valid expected_yyyymmdd and gap expectations", yaml: ` name: valid_gap_contract timeout: 1s universes: - name: kr-core provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: collection_freshness request: universe: kr-core timeframe: daily from_unix_ms: 1716768000000 # 2024-05-27 00:00:00 UTC to_unix_ms: 1716854400000 # 2024-05-28 00:00:00 UTC expected_yyyymmdd: ["20240527", "20240528"] expect: gap_status: clean gap_count: 0 duplicate_count: 0 provider_delay_days: 0 `, expectedErr: "", }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { sc, err := ParseScenario([]byte(tt.yaml)) if tt.expectedErr != "" { 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) } } else { if err != nil { t.Fatalf("unexpected error: %v", err) } step := sc.Steps[0] if len(step.Request.ExpectedYYYYMMDD) != 2 { t.Errorf("expected_yyyymmdd len = %d, want 2", len(step.Request.ExpectedYYYYMMDD)) } if step.Expect.GapStatus != "clean" { t.Errorf("gap_status = %q, want clean", step.Expect.GapStatus) } if step.Expect.GapCount == nil || *step.Expect.GapCount != 0 { t.Errorf("gap_count = %v, want 0", step.Expect.GapCount) } if step.Expect.DuplicateCount == nil || *step.Expect.DuplicateCount != 0 { t.Errorf("duplicate_count = %v, want 0", step.Expect.DuplicateCount) } if step.Expect.ProviderDelayDays == nil || *step.Expect.ProviderDelayDays != 0 { t.Errorf("provider_delay_days = %v, want 0", step.Expect.ProviderDelayDays) } } }) } } func TestValidateStartBacktestUniverseReference(t *testing.T) { yamlText := ` name: valid_start_backtest_universe universes: - name: kr-backtest-smoke provider: kis selector_kind: watchlist market: kr symbols: ["005930"] steps: - id: step1 action: start_backtest request: strategy_id: strat1 universe: kr-backtest-smoke timeframe: daily from_unix_ms: 100 to_unix_ms: 200 expect: status: ok ` sc, err := ParseScenario([]byte(yamlText)) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(sc.Steps) != 1 { t.Fatalf("steps = %d, want 1", len(sc.Steps)) } step := sc.Steps[0] if step.Request.Market != "kr" { t.Errorf("market = %q, want kr", step.Request.Market) } if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" { t.Errorf("symbols = %v, want [005930]", step.Request.Symbols) } if step.Request.Universe != "" { t.Errorf("universe = %q, want empty", step.Request.Universe) } } func TestValidateStartBacktestRejectsMissingMarket(t *testing.T) { for _, tc := range []struct { name string marketLine string }{ {name: "missing", marketLine: ""}, {name: "unspecified", marketLine: " market: unspecified\n"}, } { t.Run(tc.name, func(t *testing.T) { yamlText := ` name: missing_market steps: - id: step1 action: start_backtest request: strategy_id: strat1 ` + tc.marketLine + ` timeframe: daily from_unix_ms: 100 to_unix_ms: 200 symbols: ["005930"] ` _, err := ParseScenario([]byte(yamlText)) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "start_backtest requires request.market") { t.Errorf("error = %v, want it to contain missing market error", err) } }) } } func TestValidateStartBacktestUniverseRejectsMissingMarket(t *testing.T) { yamlText := ` name: missing_universe_market universes: - name: kr-backtest-smoke provider: kis selector_kind: watchlist symbols: ["005930"] steps: - id: step1 action: start_backtest request: strategy_id: strat1 universe: kr-backtest-smoke timeframe: daily from_unix_ms: 100 to_unix_ms: 200 ` _, err := ParseScenario([]byte(yamlText)) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "start_backtest requires request.market") { t.Errorf("error = %v, want it to contain missing market error", err) } } func TestValidateStartBacktestRejectsMissingSelector(t *testing.T) { yamlText := ` name: missing_selector steps: - id: step1 action: start_backtest request: strategy_id: strat1 market: kr timeframe: daily from_unix_ms: 100 to_unix_ms: 200 ` _, err := ParseScenario([]byte(yamlText)) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "start_backtest requires request.universe, request.symbols, or request.instrument_ids") { t.Errorf("error = %v, want it to contain missing selector error", err) } }