- 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
585 lines
18 KiB
Go
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)
|
|
}
|
|
}
|