2174 lines
52 KiB
Go
2174 lines
52 KiB
Go
package operator
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
|
)
|
|
|
|
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 TestValidateRejectsMinImportedBarCountOnWrongAction(t *testing.T) {
|
|
data := []byte("name: bad_min_bars\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n status: ok\n min_imported_bar_count: 1\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for min_imported_bar_count on non-scheduler action, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "min_imported_bar_count") {
|
|
t.Errorf("error = %v, want it to mention min_imported_bar_count", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsNegativeMinImportedBarCount(t *testing.T) {
|
|
data := []byte("name: bad_min_bars_neg\ntimeout: 1s\nsteps:\n - id: refresh\n action: scheduler_refresh_status\n expect:\n status: ok\n min_imported_bar_count: -1\n")
|
|
_, err := ParseScenario(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for negative min_imported_bar_count, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "min_imported_bar_count") {
|
|
t.Errorf("error = %v, want it to mention min_imported_bar_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 TestTimeframeVocabularyIncludesMonthlyAndMinuteBaseline(t *testing.T) {
|
|
cases := []struct {
|
|
timeframe string
|
|
proto altv1.Timeframe
|
|
}{
|
|
{"monthly", altv1.Timeframe_TIMEFRAME_MONTHLY},
|
|
{"daily", altv1.Timeframe_TIMEFRAME_DAILY},
|
|
{"minute_1", altv1.Timeframe_TIMEFRAME_MINUTE_1},
|
|
{"minute_5", altv1.Timeframe_TIMEFRAME_MINUTE_5},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.timeframe, func(t *testing.T) {
|
|
if !validTimeframes[c.timeframe] {
|
|
t.Fatalf("validTimeframes[%q] = false", c.timeframe)
|
|
}
|
|
if got := timeframeByName[c.timeframe]; got != c.proto {
|
|
t.Fatalf("timeframeByName[%q] = %v, want %v", c.timeframe, got, c.proto)
|
|
}
|
|
|
|
yamlText := fmt.Sprintf(`
|
|
name: valid_%s_timeframe
|
|
steps:
|
|
- id: step1
|
|
action: start_backtest
|
|
request:
|
|
strategy_id: strat1
|
|
market: kr
|
|
timeframe: %s
|
|
from_unix_ms: 100
|
|
to_unix_ms: 200
|
|
symbols: ["005930"]
|
|
`, c.timeframe, c.timeframe)
|
|
if _, err := ParseScenario([]byte(yamlText)); err != nil {
|
|
t.Fatalf("ParseScenario(%q) returned error: %v", c.timeframe, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestValidateBacktestScenarioMatrix(t *testing.T) {
|
|
yamlText := `
|
|
name: valid_matrix_scenario
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1", "strat2"]
|
|
timeframes: ["monthly", "daily", "minute_1", "minute_5"]
|
|
periods:
|
|
- id: p1
|
|
from_unix_ms: 1000
|
|
to_unix_ms: 2000
|
|
- id: p2
|
|
from_unix_ms: 3000
|
|
to_unix_ms: 4000
|
|
`
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if sc.Name != "valid_matrix_scenario" {
|
|
t.Errorf("name = %q, want valid_matrix_scenario", sc.Name)
|
|
}
|
|
if sc.Matrix == nil {
|
|
t.Fatalf("matrix is nil")
|
|
}
|
|
if len(sc.Matrix.Universes) != 1 || sc.Matrix.Universes[0] != "univ1" {
|
|
t.Errorf("matrix universes = %v, want [univ1]", sc.Matrix.Universes)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsInvalidBacktestScenarioMatrix(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
yaml string
|
|
expectedErr string
|
|
}{
|
|
{
|
|
name: "no steps and no matrix",
|
|
yaml: `
|
|
name: no_steps_no_matrix
|
|
`,
|
|
expectedErr: "has no steps and no matrix",
|
|
},
|
|
{
|
|
name: "empty universe list in matrix",
|
|
yaml: `
|
|
name: empty_univ
|
|
matrix:
|
|
universes: []
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "matrix universes is empty",
|
|
},
|
|
{
|
|
name: "unknown universe",
|
|
yaml: `
|
|
name: unknown_univ
|
|
matrix:
|
|
universes: ["univ_unknown"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "referenced universe \"univ_unknown\" not found",
|
|
},
|
|
{
|
|
name: "invalid timeframe",
|
|
yaml: `
|
|
name: invalid_tf
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["invalid_timeframe"]
|
|
periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "unsupported timeframe",
|
|
},
|
|
{
|
|
name: "reversed period",
|
|
yaml: `
|
|
name: reversed_period
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 200, to_unix_ms: 100}]
|
|
`,
|
|
expectedErr: "cannot be after to_unix_ms",
|
|
},
|
|
{
|
|
name: "whitespace in generated ID",
|
|
yaml: `
|
|
name: whitespace_id
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat 1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "contains whitespace",
|
|
},
|
|
{
|
|
name: "duplicate period ID",
|
|
yaml: `
|
|
name: duplicate_period_id
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods:
|
|
- id: p1
|
|
from_unix_ms: 100
|
|
to_unix_ms: 200
|
|
- id: p1
|
|
from_unix_ms: 300
|
|
to_unix_ms: 400
|
|
`,
|
|
expectedErr: "matrix generated duplicate ID",
|
|
},
|
|
{
|
|
name: "universe with empty market",
|
|
yaml: `
|
|
name: empty_market
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "requires a concrete market for backtest runs",
|
|
},
|
|
{
|
|
name: "universe with unspecified market",
|
|
yaml: `
|
|
name: unspecified_market
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: unspecified
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "requires a concrete market for backtest runs",
|
|
},
|
|
{
|
|
name: "missing from_unix_ms",
|
|
yaml: `
|
|
name: missing_from_unix_ms
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", to_unix_ms: 200}]
|
|
`,
|
|
expectedErr: "from_unix_ms and to_unix_ms are required",
|
|
},
|
|
{
|
|
name: "missing to_unix_ms",
|
|
yaml: `
|
|
name: missing_to_unix_ms
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
matrix:
|
|
universes: ["univ1"]
|
|
strategies: ["strat1"]
|
|
timeframes: ["daily"]
|
|
periods: [{id: "p1", from_unix_ms: 100}]
|
|
`,
|
|
expectedErr: "from_unix_ms and to_unix_ms are required",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := ParseScenario([]byte(tc.yaml))
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.expectedErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.expectedErr) {
|
|
t.Errorf("error = %v, want it to contain %q", err, tc.expectedErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateImportDailyBarsTimeframeValidation(t *testing.T) {
|
|
// Empty timeframe should pass (defaults to daily at worker boundary).
|
|
t.Run("emptyTimeframe", func(t *testing.T) {
|
|
yamlText := `
|
|
name: import_empty_timeframe
|
|
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"
|
|
timeframe: ""
|
|
expect:
|
|
status: ok
|
|
`
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for empty timeframe: %v", err)
|
|
}
|
|
if sc.Steps[0].Request.Timeframe != "" {
|
|
t.Errorf("timeframe = %q, want empty", sc.Steps[0].Request.Timeframe)
|
|
}
|
|
})
|
|
|
|
// daily timeframe should pass validation.
|
|
t.Run("dailyTimeframe", func(t *testing.T) {
|
|
yamlText := `
|
|
name: import_daily_timeframe
|
|
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"
|
|
timeframe: daily
|
|
expect:
|
|
status: ok
|
|
`
|
|
_, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for daily timeframe: %v", err)
|
|
}
|
|
})
|
|
|
|
// minute_1 timeframe should pass dry-run validation (rejected at worker as invalid_request).
|
|
t.Run("minute_1Timeframe", func(t *testing.T) {
|
|
yamlText := `
|
|
name: import_minute_1_timeframe
|
|
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"
|
|
timeframe: minute_1
|
|
expect:
|
|
status: error
|
|
error_code: invalid_request
|
|
`
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for minute_1 timeframe: %v", err)
|
|
}
|
|
if sc.Steps[0].Request.Timeframe != "minute_1" {
|
|
t.Errorf("timeframe = %q, want minute_1", sc.Steps[0].Request.Timeframe)
|
|
}
|
|
})
|
|
|
|
// minute_5 timeframe should pass dry-run validation.
|
|
t.Run("minute_5Timeframe", func(t *testing.T) {
|
|
yamlText := `
|
|
name: import_minute_5_timeframe
|
|
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"
|
|
timeframe: minute_5
|
|
expect:
|
|
status: error
|
|
error_code: invalid_request
|
|
`
|
|
_, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for minute_5 timeframe: %v", err)
|
|
}
|
|
})
|
|
|
|
// invalid timeframe string should fail dry-run validation.
|
|
t.Run("invalidTimeframe", func(t *testing.T) {
|
|
yamlText := `
|
|
name: import_invalid_timeframe
|
|
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"
|
|
timeframe: invalid_tf
|
|
`
|
|
_, err := ParseScenario([]byte(yamlText))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid timeframe, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported timeframe") {
|
|
t.Errorf("error = %v, want it to mention unsupported timeframe", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBacktestScenarioMatrixRunsExpandsInDeterministicOrder(t *testing.T) {
|
|
yamlText := `
|
|
name: expansion_order
|
|
universes:
|
|
- name: univ1
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["005930"]
|
|
- name: univ2
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
market: kr
|
|
symbols: ["000660"]
|
|
matrix:
|
|
universes: ["univ1", "univ2"]
|
|
strategies: ["strat1", "strat2"]
|
|
timeframes: ["monthly", "daily", "minute_1", "minute_5"]
|
|
periods:
|
|
- id: p1
|
|
from_unix_ms: 1000
|
|
to_unix_ms: 2000
|
|
- id: p2
|
|
from_unix_ms: 3000
|
|
to_unix_ms: 4000
|
|
`
|
|
sc, err := ParseScenario([]byte(yamlText))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing matrix scenario: %v", err)
|
|
}
|
|
|
|
runs, err := sc.MatrixRuns()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error expanding matrix runs: %v", err)
|
|
}
|
|
|
|
wantIDs := []string{
|
|
"univ1__strat1__monthly__p1",
|
|
"univ1__strat1__monthly__p2",
|
|
"univ1__strat1__daily__p1",
|
|
"univ1__strat1__daily__p2",
|
|
"univ1__strat1__minute_1__p1",
|
|
"univ1__strat1__minute_1__p2",
|
|
"univ1__strat1__minute_5__p1",
|
|
"univ1__strat1__minute_5__p2",
|
|
"univ1__strat2__monthly__p1",
|
|
"univ1__strat2__monthly__p2",
|
|
"univ1__strat2__daily__p1",
|
|
"univ1__strat2__daily__p2",
|
|
"univ1__strat2__minute_1__p1",
|
|
"univ1__strat2__minute_1__p2",
|
|
"univ1__strat2__minute_5__p1",
|
|
"univ1__strat2__minute_5__p2",
|
|
"univ2__strat1__monthly__p1",
|
|
"univ2__strat1__monthly__p2",
|
|
"univ2__strat1__daily__p1",
|
|
"univ2__strat1__daily__p2",
|
|
"univ2__strat1__minute_1__p1",
|
|
"univ2__strat1__minute_1__p2",
|
|
"univ2__strat1__minute_5__p1",
|
|
"univ2__strat1__minute_5__p2",
|
|
"univ2__strat2__monthly__p1",
|
|
"univ2__strat2__monthly__p2",
|
|
"univ2__strat2__daily__p1",
|
|
"univ2__strat2__daily__p2",
|
|
"univ2__strat2__minute_1__p1",
|
|
"univ2__strat2__minute_1__p2",
|
|
"univ2__strat2__minute_5__p1",
|
|
"univ2__strat2__minute_5__p2",
|
|
}
|
|
|
|
if len(runs) != len(wantIDs) {
|
|
t.Fatalf("runs = %d, want %d", len(runs), len(wantIDs))
|
|
}
|
|
|
|
for i, run := range runs {
|
|
if run.ID != wantIDs[i] {
|
|
t.Errorf("runs[%d].ID = %q, want %q", i, run.ID, wantIDs[i])
|
|
}
|
|
parts := strings.Split(run.ID, "__")
|
|
if run.Universe != parts[0] {
|
|
t.Errorf("runs[%d].Universe = %q, want %q", i, run.Universe, parts[0])
|
|
}
|
|
if run.StrategyID != parts[1] {
|
|
t.Errorf("runs[%d].StrategyID = %q, want %q", i, run.StrategyID, parts[1])
|
|
}
|
|
if run.Timeframe != parts[2] {
|
|
t.Errorf("runs[%d].Timeframe = %q, want %q", i, run.Timeframe, parts[2])
|
|
}
|
|
if parts[3] == "p1" {
|
|
if run.FromUnixMs != 1000 || run.ToUnixMs != 2000 {
|
|
t.Errorf("runs[%d] period p1 from/to = %d/%d, want 1000/2000", i, run.FromUnixMs, run.ToUnixMs)
|
|
}
|
|
} else if parts[3] == "p2" {
|
|
if run.FromUnixMs != 3000 || run.ToUnixMs != 4000 {
|
|
t.Errorf("runs[%d] period p2 from/to = %d/%d, want 3000/4000", i, run.FromUnixMs, run.ToUnixMs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateExpectedBuckets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
expectedErr string
|
|
}{
|
|
{
|
|
name: "valid_monthly_buckets",
|
|
yaml: `
|
|
name: valid_monthly
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: monthly
|
|
from_unix_ms: 1735689600000 # 2025-01-01 00:00:00 UTC
|
|
to_unix_ms: 1740787200000 # 2025-03-01 00:00:00 UTC
|
|
expected_buckets: ["202501", "202502"]
|
|
`,
|
|
expectedErr: "",
|
|
},
|
|
{
|
|
name: "invalid_monthly_bucket_format",
|
|
yaml: `
|
|
name: invalid_monthly
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: monthly
|
|
from_unix_ms: 1735689600000
|
|
to_unix_ms: 1740787200000
|
|
expected_buckets: ["2025-01"]
|
|
`,
|
|
expectedErr: "must be in 200601 format",
|
|
},
|
|
{
|
|
name: "out_of_window_monthly_bucket",
|
|
yaml: `
|
|
name: out_of_window_monthly
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: monthly
|
|
from_unix_ms: 1735689600000
|
|
to_unix_ms: 1740787200000
|
|
expected_buckets: ["202504"]
|
|
`,
|
|
expectedErr: "is out of request window",
|
|
},
|
|
{
|
|
name: "unordered_monthly_buckets",
|
|
yaml: `
|
|
name: unordered_monthly
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: monthly
|
|
from_unix_ms: 1735689600000
|
|
to_unix_ms: 1740787200000
|
|
expected_buckets: ["202502", "202501"]
|
|
`,
|
|
expectedErr: "must be in ascending order",
|
|
},
|
|
{
|
|
name: "duplicate_monthly_buckets",
|
|
yaml: `
|
|
name: duplicate_monthly
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: monthly
|
|
from_unix_ms: 1735689600000
|
|
to_unix_ms: 1740787200000
|
|
expected_buckets: ["202501", "202501"]
|
|
`,
|
|
expectedErr: "contains duplicate bucket",
|
|
},
|
|
{
|
|
name: "valid_minute_buckets",
|
|
yaml: `
|
|
name: valid_minute
|
|
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: 1735689600000 # 2025-01-01 00:00:00 UTC
|
|
to_unix_ms: 1735693200000 # 2025-01-01 01:00:00 UTC
|
|
expected_buckets: ["202501010005", "202501010030"]
|
|
`,
|
|
expectedErr: "",
|
|
},
|
|
{
|
|
name: "invalid_minute_bucket_format",
|
|
yaml: `
|
|
name: invalid_minute
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: minute_5
|
|
from_unix_ms: 1735689600000
|
|
to_unix_ms: 1735693200000
|
|
expected_buckets: ["20250101-00:05"]
|
|
`,
|
|
expectedErr: "must be in 200601021504 format",
|
|
},
|
|
{
|
|
name: "expected_yyyymmdd_normalization_success",
|
|
yaml: `
|
|
name: normalized_daily
|
|
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: 1735689600000 # 2025-01-01 00:00:00 UTC
|
|
to_unix_ms: 1735862400000 # 2025-01-03 00:00:00 UTC
|
|
expected_yyyymmdd: ["20250101", "20250102"]
|
|
`,
|
|
expectedErr: "",
|
|
},
|
|
{
|
|
name: "expected_yyyymmdd_non_daily_fail",
|
|
yaml: `
|
|
name: normalized_daily_fail
|
|
universes:
|
|
- name: kr-core
|
|
provider: kis
|
|
selector_kind: watchlist
|
|
symbols: ["005930"]
|
|
steps:
|
|
- id: step1
|
|
action: collection_freshness
|
|
request:
|
|
universe: kr-core
|
|
timeframe: monthly
|
|
from_unix_ms: 1735689600000
|
|
to_unix_ms: 1740787200000
|
|
expected_yyyymmdd: ["20250101"]
|
|
`,
|
|
expectedErr: "expected_yyyymmdd is only supported for timeframe",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sc, err := ParseScenario([]byte(tt.yaml))
|
|
if tt.expectedErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Verify normalization
|
|
if tt.name == "expected_yyyymmdd_normalization_success" {
|
|
if len(sc.Steps[0].Request.ExpectedBuckets) != 2 || sc.Steps[0].Request.ExpectedBuckets[0] != "20250101" {
|
|
t.Errorf("ExpectedBuckets not normalized: %v", sc.Steps[0].Request.ExpectedBuckets)
|
|
}
|
|
}
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|