package operator import ( "bufio" "encoding/json" "os" "path/filepath" "strings" "testing" altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" ) // TestPaperTradingFixtureIsValid guards the paper trading scenario fixture and // its expected output: the YAML must pass dry-run validation and every expected // JSONL line must parse and carry the paper account_id key the operator relies // on for monitoring. func TestPaperTradingFixtureIsValid(t *testing.T) { yamlPath := filepath.Join("..", "..", "testdata", "operator", "paper_trading_state.yaml") if _, err := LoadScenario(yamlPath); err != nil { t.Fatalf("paper_trading_state.yaml failed validation: %v", err) } expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "paper_trading_state.jsonl") f, err := os.Open(expectedPath) if err != nil { t.Fatalf("open expected fixture: %v", err) } defer f.Close() scanner := bufio.NewScanner(f) sawAccount := false sawSummary := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("expected fixture line not valid JSON: %v", err) } if rec["scenario"] != "paper_trading_state" { t.Errorf("line scenario = %v, want paper_trading_state", rec["scenario"]) } if rec["account_id"] == "paper-1" { sawAccount = true } if rec["type"] == "summary" { sawSummary = true } } if err := scanner.Err(); err != nil { t.Fatalf("scan expected fixture: %v", err) } if !sawAccount { t.Error("expected fixture missing an account_id=paper-1 line") } if !sawSummary { t.Error("expected fixture missing a summary line") } } func paperState(accountID string) *altv1.PaperTradingState { return &altv1.PaperTradingState{ AccountId: accountID, Run: &altv1.BacktestRun{ Id: "paper-run-1", Status: altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED, }, Cash: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "9998950"}}, Positions: []*altv1.BacktestPosition{ {InstrumentId: "KRX:005930", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, LastPrice: &altv1.Price{Amount: &altv1.Decimal{Value: "1150"}}}, }, Fills: []*altv1.BacktestTrade{ {InstrumentId: "KRX:005930", Side: "buy", Price: &altv1.Price{Amount: &altv1.Decimal{Value: "1050"}}}, }, } } func TestRunStartPaperTradingOutputsAccountState(t *testing.T) { api := &fakeAPI{startPaperResp: &altv1.StartPaperTradingResponse{State: paperState("paper-1")}} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_trading_state", Steps: []Step{ { ID: "start", Action: ActionStartPaperTrading, Request: Request{ AccountID: "paper-1", StrategyID: "strategy-v1", Market: "kr", Timeframe: "daily", FromUnixMs: 1746057600000, ToUnixMs: 1747267200000, StartingCash: "10000000", }, Expect: Expect{Status: "ok", RunStatus: "succeeded"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } for _, want := range []string{ "account_id=paper-1", "run.status=succeeded", "cash=9998950", "position_count=1", "fill_count=1", } { if !strings.Contains(out, want) { t.Errorf("output %q missing %q", out, want) } } got := api.lastStartPaperReq() if got == nil { t.Fatal("server did not receive a start paper trading request") } if got.GetAccountId() != "paper-1" { t.Errorf("server received account_id = %q, want paper-1", got.GetAccountId()) } if got.GetStartingCash().GetCurrency() != altv1.Currency_CURRENCY_KRW { t.Errorf("server received currency = %v, want KRW (derived from kr market)", got.GetStartingCash().GetCurrency()) } if got.GetStartingCash().GetAmount().GetValue() != "10000000" { t.Errorf("server received starting cash = %q, want 10000000", got.GetStartingCash().GetAmount().GetValue()) } } func TestRunGetPaperTradingStateOutputsFields(t *testing.T) { api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{State: paperState("paper-1")}} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_trading_state", Steps: []Step{ { ID: "state", Action: ActionGetPaperTradingState, Request: Request{AccountID: "paper-1"}, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "account_id=paper-1") || !strings.Contains(out, "position_count=1") { t.Errorf("output %q missing paper state fields", out) } got := api.lastGetPaperStateReq() if got == nil { t.Fatal("server did not receive a get paper state request") } if got.GetAccountId() != "paper-1" { t.Errorf("server received account_id = %q, want paper-1", got.GetAccountId()) } } func TestRunGetPaperTradingStateNotFoundTypedError(t *testing.T) { api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{ Error: &altv1.ErrorInfo{Code: "not_found", Message: "paper trading account not found"}, }} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_trading_state", Steps: []Step{ { ID: "state", Action: ActionGetPaperTradingState, Request: Request{AccountID: "missing"}, Expect: Expect{Status: "error", ErrorCode: "not_found"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "error.code=not_found") { t.Errorf("output %q missing error.code=not_found", out) } }