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 sawEquity := 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 _, ok := rec["equity_point_count"]; ok { if _, ok := rec["latest_equity"]; ok { sawEquity = 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") } if !sawEquity { t.Error("expected fixture missing equity_point_count/latest_equity on a paper 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"}}}, }, EquityCurve: []*altv1.BacktestEquityPoint{ {TimestampUnixMs: 1746057600000, Equity: &altv1.Price{Amount: &altv1.Decimal{Value: "10000000"}}}, {TimestampUnixMs: 1747267200000, Equity: &altv1.Price{Amount: &altv1.Decimal{Value: "10000100"}}}, }, } } 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", "risk=clear", "equity_point_count=2", "latest_equity=10000100", } { 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") || !strings.Contains(out, "risk=clear") { t.Errorf("output %q missing paper state fields", out) } if !strings.Contains(out, "equity_point_count=2") || !strings.Contains(out, "latest_equity=10000100") { t.Errorf("output %q missing equity summary 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) } } // blockedPaperState mirrors paperState but carries a risk rejection so the // operator surface reports a blocked risk summary instead of a clear one. func blockedPaperState(accountID string) *altv1.PaperTradingState { state := paperState(accountID) state.RiskRejections = []*altv1.PaperRiskRejection{ { InstrumentId: "KRX:000660", Side: "sell", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "2"}}, Reason: "insufficient position quantity for sell order", TimestampUnixMs: 1746230400000, }, } return state } func TestRunGetPaperTradingStateOutputsRiskFields(t *testing.T) { api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{State: blockedPaperState("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) } for _, want := range []string{ "risk=blocked", "risk_rejected_count=1", `risk_reason="insufficient position quantity for sell order"`, } { if !strings.Contains(out, want) { t.Errorf("output %q missing %q", out, want) } } if strings.Contains(out, "risk=clear") { t.Errorf("output %q should not report risk=clear for a blocked run", out) } } // --- order lifecycle --- func pendingOrder(orderID string) *altv1.PaperOrder { return &altv1.PaperOrder{ OrderId: orderID, AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, Type: "market", Status: "pending", } } func filledOrder(orderID, price string) *altv1.PaperOrder { order := pendingOrder(orderID) order.Status = "filled" order.Fill = &altv1.BacktestTrade{ InstrumentId: "KRX:005930", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, Price: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: price}}, } return order } // TestRunPaperOrderLifecycle exercises submit -> fill across steps, asserting the // order id is surfaced, the status transitions pending -> filled, the fill // summary is emitted, and the order id is interpolated into the fill step. func TestRunPaperOrderLifecycle(t *testing.T) { api := &fakeAPI{ submitPaperOrderResp: &altv1.SubmitPaperOrderResponse{Order: pendingOrder("paper-order-paper-1-1")}, fillPaperOrderResp: &altv1.FillPaperOrderResponse{ Order: filledOrder("paper-order-paper-1-1", "1000"), State: paperState("paper-1"), }, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_order_lifecycle", Steps: []Step{ { ID: "submit", Action: ActionSubmitPaperOrder, Request: Request{ AccountID: "paper-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "1", OrderType: "market", Market: "kr", }, Expect: Expect{Status: "ok", OrderStatus: "pending"}, }, { ID: "fill", Action: ActionFillPaperOrder, Request: Request{ AccountID: "paper-1", OrderID: "{{steps.submit.order.id}}", Market: "kr", FillPrice: "1000", }, Expect: Expect{Status: "ok", OrderStatus: "filled"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } for _, want := range []string{ "step=submit action=submit_paper_order status=ok order_id=paper-order-paper-1-1 order_status=pending", "step=fill action=fill_paper_order status=ok order_id=paper-order-paper-1-1 order_status=filled fill_count=1 fill_price=1000", } { if !strings.Contains(out, want) { t.Errorf("output %q missing %q", out, want) } } // The fill step must have received the order id interpolated from the submit // response, not the raw placeholder. got := api.lastFillPaperOrderReq() if got == nil { t.Fatal("server did not receive a fill paper order request") } if got.GetOrderId() != "paper-order-paper-1-1" { t.Errorf("fill received order_id = %q, want interpolated paper-order-paper-1-1", got.GetOrderId()) } if got.GetFillPrice().GetCurrency() != altv1.Currency_CURRENCY_KRW { t.Errorf("fill received currency = %v, want KRW (derived from kr market)", got.GetFillPrice().GetCurrency()) } } func TestRunSubmitPaperOrderLimitForwardsLimitPrice(t *testing.T) { api := &fakeAPI{submitPaperOrderResp: &altv1.SubmitPaperOrderResponse{Order: pendingOrder("paper-order-paper-1-1")}} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_order_lifecycle", Steps: []Step{ { ID: "submit", Action: ActionSubmitPaperOrder, Request: Request{ AccountID: "paper-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "1", OrderType: "limit", LimitPrice: "900", Market: "kr", }, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } got := api.lastSubmitPaperOrderReq() if got == nil { t.Fatal("server did not receive a submit paper order request") } if got.GetType() != "limit" { t.Errorf("submit received type = %q, want limit", got.GetType()) } if got.GetLimitPrice().GetAmount().GetValue() != "900" || got.GetLimitPrice().GetCurrency() != altv1.Currency_CURRENCY_KRW { t.Errorf("submit received limit price = %+v, want 900 KRW", got.GetLimitPrice()) } } // TestRunSubmitPaperOrderTypedError confirms the CLI surfaces a worker-returned // typed invalid_request for an order lifecycle step (e.g. malformed quantity). // The worker owns decimal validation; this asserts the thin CLI forward path // reports the typed error code rather than treating it as transport failure. func TestRunSubmitPaperOrderTypedError(t *testing.T) { api := &fakeAPI{submitPaperOrderResp: &altv1.SubmitPaperOrderResponse{ Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "invalid paper trading request: invalid paper order input: quantity must be greater than zero"}, }} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_order_lifecycle", Steps: []Step{ { ID: "submit", Action: ActionSubmitPaperOrder, Request: Request{ AccountID: "paper-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "0", OrderType: "market", Market: "kr", }, Expect: Expect{Status: "error", ErrorCode: "invalid_request"}, }, }, } 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=invalid_request") { t.Errorf("output %q missing error.code=invalid_request", out) } } func TestRunCancelPaperOrder(t *testing.T) { canceled := pendingOrder("paper-order-paper-1-1") canceled.Status = "canceled" api := &fakeAPI{cancelPaperOrderResp: &altv1.CancelPaperOrderResponse{Order: canceled}} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_order_lifecycle", Steps: []Step{ { ID: "cancel", Action: ActionCancelPaperOrder, Request: Request{AccountID: "paper-1", OrderID: "paper-order-paper-1-1"}, Expect: Expect{Status: "ok", OrderStatus: "canceled"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "order_id=paper-order-paper-1-1 order_status=canceled") { t.Errorf("output %q missing canceled order line", out) } if strings.Contains(out, "fill_count") { t.Errorf("cancel output %q should not emit fill_count", out) } } // TestRunFillPaperOrderRejectedSurfacesStatus confirms a risk/portfolio denial is // a successful response carrying order_status=rejected with fill_count=0, not a // transport or typed error. func TestRunFillPaperOrderRejectedSurfacesStatus(t *testing.T) { rejected := pendingOrder("paper-order-paper-1-1") rejected.Status = "rejected" rejected.Reason = "insufficient cash for fill" api := &fakeAPI{fillPaperOrderResp: &altv1.FillPaperOrderResponse{Order: rejected, State: paperState("paper-1")}} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "paper_order_lifecycle", Steps: []Step{ { ID: "fill", Action: ActionFillPaperOrder, Request: Request{AccountID: "paper-1", OrderID: "paper-order-paper-1-1", Market: "kr", FillPrice: "1000"}, Expect: Expect{Status: "ok", OrderStatus: "rejected"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } for _, want := range []string{ "order_status=rejected", "fill_count=0", `order_reason="insufficient cash for fill"`, } { if !strings.Contains(out, want) { t.Errorf("output %q missing %q", out, want) } } } // TestPaperOrderLifecycleFixtureIsValid guards the order lifecycle scenario // fixture and its expected output: the YAML must pass dry-run validation and the // expected JSONL must carry the order id and a filled status transition. func TestPaperOrderLifecycleFixtureIsValid(t *testing.T) { yamlPath := filepath.Join("..", "..", "testdata", "operator", "paper_order_lifecycle.yaml") if _, err := LoadScenario(yamlPath); err != nil { t.Fatalf("paper_order_lifecycle.yaml failed validation: %v", err) } expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "paper_order_lifecycle.jsonl") f, err := os.Open(expectedPath) if err != nil { t.Fatalf("open expected fixture: %v", err) } defer f.Close() scanner := bufio.NewScanner(f) sawOrderID := false sawFilled := 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["order_id"] != nil { sawOrderID = true } if rec["order_status"] == "filled" { sawFilled = true } if rec["type"] == "summary" { sawSummary = true } } if err := scanner.Err(); err != nil { t.Fatalf("scan expected fixture: %v", err) } if !sawOrderID { t.Error("expected fixture missing an order_id line") } if !sawFilled { t.Error("expected fixture missing an order_status=filled transition") } if !sawSummary { t.Error("expected fixture missing a summary line") } } // TestRunStartPaperTradingClearRiskOmitsRejectionFields confirms a clear run // keeps minimal output: risk=clear with no risk_rejected_count/risk_reason keys. func TestRunStartPaperTradingClearRiskOmitsRejectionFields(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) } if !strings.Contains(out, "risk=clear") { t.Errorf("output %q missing risk=clear", out) } if strings.Contains(out, "risk_rejected_count") || strings.Contains(out, "risk_reason") { t.Errorf("clear run output %q should omit risk rejection detail keys", out) } }