alt/apps/cli/internal/operator/runner_live_test.go
toki c23fea2342 feat(live-trading): 실거래 risk guard를 추가한다
실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
2026-06-08 05:36:59 +09:00

440 lines
12 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"
)
func submittedLiveOrder(id string) *altv1.LiveOrder {
return &altv1.LiveOrder{
Id: id,
AccountId: "live-acct-1",
InstrumentId: "KRX:005930",
Side: "buy",
Type: "kis_best_limit",
Status: "submitted",
BrokerId: "broker-lo-1",
BrokerStatus: "SUBMITTED",
}
}
func canceledLiveOrder(id string) *altv1.LiveOrder {
o := submittedLiveOrder(id)
o.Status = "canceled"
o.BrokerStatus = "CANCELED"
return o
}
func TestRunSubmitLiveOrderConfirmationRequiredTypedError(t *testing.T) {
api := &fakeAPI{submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "operator confirmation is required"},
}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_order_lifecycle",
Steps: []Step{
{
ID: "submit",
Action: ActionSubmitLiveOrder,
Request: Request{
AccountID: "live-acct-1",
InstrumentID: "KRX:005930",
Side: "buy",
Quantity: "1",
OrderType: "kis_best_limit",
OperatorConfirmed: false,
},
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 TestRunSubmitLiveOrderPreservesCustomOrderType(t *testing.T) {
order := submittedLiveOrder("lo-live-acct-1-1")
order.Type = "kis_best_limit"
api := &fakeAPI{submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{Order: order}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_order_lifecycle",
Steps: []Step{
{
ID: "submit",
Action: ActionSubmitLiveOrder,
Request: Request{
AccountID: "live-acct-1",
InstrumentID: "KRX:005930",
Side: "buy",
Quantity: "1",
OrderType: "kis_best_limit",
OperatorConfirmed: true,
OperatorID: "operator-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, "live_order_id=lo-live-acct-1-1") {
t.Errorf("output %q missing live_order_id=lo-live-acct-1-1", out)
}
got := api.lastSubmitLiveOrderReq()
if got == nil {
t.Fatal("server did not receive a submit live order request")
}
if got.GetIntent().GetType() != "kis_best_limit" {
t.Errorf("server received order type = %q, want kis_best_limit", got.GetIntent().GetType())
}
if !got.GetOperatorConfirmation().GetConfirmed() {
t.Error("server received confirmed = false, want true")
}
}
func TestRunSubmitLiveOrderCancelGetInterpolation(t *testing.T) {
orderID := "lo-live-acct-1-1"
api := &fakeAPI{
submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{Order: submittedLiveOrder(orderID)},
getLiveOrderResp: &altv1.GetLiveOrderResponse{Order: submittedLiveOrder(orderID)},
cancelLiveOrderResp: &altv1.CancelLiveOrderResponse{Order: canceledLiveOrder(orderID)},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_order_lifecycle",
Steps: []Step{
{
ID: "submit",
Action: ActionSubmitLiveOrder,
Request: Request{
AccountID: "live-acct-1",
InstrumentID: "KRX:005930",
Side: "buy",
Quantity: "1",
OrderType: "kis_best_limit",
OperatorConfirmed: true,
OperatorID: "operator-1",
},
Expect: Expect{Status: "ok", LiveOrderStatus: "submitted"},
},
{
ID: "get",
Action: ActionGetLiveOrder,
Request: Request{
AccountID: "live-acct-1",
LiveOrderID: "{{steps.submit.live_order.id}}",
},
Expect: Expect{Status: "ok"},
},
{
ID: "cancel",
Action: ActionCancelLiveOrder,
Request: Request{
AccountID: "live-acct-1",
LiveOrderID: "{{steps.submit.live_order.id}}",
},
Expect: Expect{Status: "ok", LiveOrderStatus: "canceled"},
},
},
}
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_live_order status=ok live_order_id=lo-live-acct-1-1 live_order_status=submitted",
"step=get action=get_live_order status=ok live_order_id=lo-live-acct-1-1",
"step=cancel action=cancel_live_order status=ok live_order_id=lo-live-acct-1-1 live_order_status=canceled",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
// cancel step must receive the interpolated order id, not the raw placeholder
got := api.lastCancelLiveOrderReq()
if got == nil {
t.Fatal("server did not receive a cancel live order request")
}
if got.GetLiveOrderId() != orderID {
t.Errorf("cancel received live_order_id = %q, want interpolated %q", got.GetLiveOrderId(), orderID)
}
}
func TestRunGetLiveRiskPolicy(t *testing.T) {
api := &fakeAPI{
getLiveRiskPolicyResp: &altv1.GetLiveRiskPolicyResponse{
Policy: &altv1.LiveRiskPolicy{MaxDailyOrders: 5, MaxOpenOrders: 3, AllowShortSelling: false},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_risk_kill_switch",
Steps: []Step{
{
ID: "get_policy",
Action: ActionGetLiveRiskPolicy,
Request: Request{AccountID: "live-acct-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, "max_daily_orders=5") {
t.Errorf("output %q missing max_daily_orders=5", out)
}
if !strings.Contains(out, "max_open_orders=3") {
t.Errorf("output %q missing max_open_orders=3", out)
}
if !strings.Contains(out, "allow_short_selling=false") {
t.Errorf("output %q missing allow_short_selling=false", out)
}
}
func TestRunGetLiveKillSwitch(t *testing.T) {
api := &fakeAPI{
getLiveKillSwitchResp: &altv1.GetLiveKillSwitchResponse{
State: &altv1.LiveKillSwitchState{Halted: true, Reason: "kill switch active by default"},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_risk_kill_switch",
Steps: []Step{
{
ID: "get_ks",
Action: ActionGetLiveKillSwitch,
Request: Request{AccountID: "live-acct-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, "kill_switch_halted=true") {
t.Errorf("output %q missing kill_switch_halted=true", out)
}
}
func TestRunSetLiveKillSwitchResume(t *testing.T) {
api := &fakeAPI{
setLiveKillSwitchResp: &altv1.SetLiveKillSwitchResponse{
State: &altv1.LiveKillSwitchState{Halted: false, Reason: "operator cleared"},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_risk_kill_switch",
Steps: []Step{
{
ID: "set_ks",
Action: ActionSetLiveKillSwitch,
Request: Request{
AccountID: "live-acct-1",
Halted: false,
KillSwitchReason: "operator cleared",
},
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, "kill_switch_halted=false") {
t.Errorf("output %q missing kill_switch_halted=false", out)
}
got := api.lastSetLiveKillSwitchReq()
if got == nil {
t.Fatal("server did not receive a set_live_kill_switch request")
}
if got.GetHalted() != false {
t.Errorf("server received halted = true, want false")
}
if got.GetReason() != "operator cleared" {
t.Errorf("server received reason = %q, want %q", got.GetReason(), "operator cleared")
}
}
func TestLiveRiskKillSwitchFixtureIsValid(t *testing.T) {
yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_risk_kill_switch.yaml")
if _, err := LoadScenario(yamlPath); err != nil {
t.Fatalf("live_risk_kill_switch.yaml failed validation: %v", err)
}
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_risk_kill_switch.jsonl")
f, err := os.Open(expectedPath)
if err != nil {
t.Fatalf("open expected fixture: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
sawPolicy := false
sawKillSwitch := false
sawRiskDecision := 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["action"] == "get_live_risk_policy" && rec["max_daily_orders"] != nil {
sawPolicy = true
}
if rec["kill_switch_halted"] != nil {
sawKillSwitch = true
}
if rec["risk_decision_allowed"] != nil {
sawRiskDecision = true
}
if rec["type"] == "summary" {
sawSummary = true
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan expected fixture: %v", err)
}
if !sawPolicy {
t.Error("expected fixture missing a get_live_risk_policy line with max_daily_orders")
}
if !sawKillSwitch {
t.Error("expected fixture missing a kill_switch_halted field")
}
if !sawRiskDecision {
t.Error("expected fixture missing a risk_decision_allowed field (risk-blocked submit evidence)")
}
if !sawSummary {
t.Error("expected fixture missing a summary line")
}
}
func TestRunSubmitLiveOrderRiskBlockedIncludesDecision(t *testing.T) {
api := &fakeAPI{submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{
Order: &altv1.LiveOrder{
Id: "lo-live-acct-1-1",
Status: "rejected",
BrokerId: "",
},
Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "live order blocked by risk policy: kill switch is active"},
RiskDecision: &altv1.LiveRiskDecision{Allowed: false, Reason: "kill switch is active"},
}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_risk_kill_switch",
Steps: []Step{
{
ID: "submit_risk_blocked",
Action: ActionSubmitLiveOrder,
Request: Request{
AccountID: "live-acct-1",
InstrumentID: "KRX:005930",
Side: "buy",
Quantity: "1",
OrderType: "market",
OperatorConfirmed: true,
OperatorID: "operator-1",
},
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, "live_order_id=lo-live-acct-1-1") {
t.Errorf("output %q missing live_order_id=lo-live-acct-1-1", out)
}
if !strings.Contains(out, "live_order_status=rejected") {
t.Errorf("output %q missing live_order_status=rejected", out)
}
if !strings.Contains(out, "risk_decision_allowed=false") {
t.Errorf("output %q missing risk_decision_allowed=false", out)
}
if !strings.Contains(out, "kill switch is active") {
t.Errorf("output %q missing kill switch reason", out)
}
}
func TestLiveOrderLifecycleFixtureIsValid(t *testing.T) {
yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_order_lifecycle.yaml")
if _, err := LoadScenario(yamlPath); err != nil {
t.Fatalf("live_order_lifecycle.yaml failed validation: %v", err)
}
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_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)
sawLiveOrderID := false
sawCanceled := 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["live_order_id"] != nil {
sawLiveOrderID = true
}
if rec["live_order_status"] == "canceled" {
sawCanceled = true
}
if rec["type"] == "summary" {
sawSummary = true
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan expected fixture: %v", err)
}
if !sawLiveOrderID {
t.Error("expected fixture missing a live_order_id line")
}
if !sawCanceled {
t.Error("expected fixture missing a live_order_status=canceled transition")
}
if !sawSummary {
t.Error("expected fixture missing a summary line")
}
}