alt/apps/cli/internal/operator/runner_paper_test.go
toki daa29d6807 feat: paper trading command workflow completed (G08, G09)
- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle)
- Add paper_order_lifecycle test data and expected output
- Update paper trading proto and regenerate code (Dart, Go)
- Fix order lifecycle handling in CLI operator, API socket, worker socket
- Update parser maps across CLI, API, and worker services
- Update backtest and paper trading tests
2026-06-06 11:36:50 +09:00

585 lines
18 KiB
Go

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)
}
}