alt/apps/cli/internal/operator/runner_live_test.go
toki c0db4b24c1 feat(live-trading): 계좌 동기화와 감사 추적을 추가한다
Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
2026-06-08 11:18:41 +09:00

664 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"
)
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")
}
}
func TestRunSyncLiveAccount(t *testing.T) {
snap := &altv1.LiveAccountSnapshot{
AccountId: "op-alias-001",
Broker: "kis",
SyncedAtUnixMs: 1749340800000,
Cash: []*altv1.LiveCashBalance{
{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "5000000"}},
},
}
api := &fakeAPI{
syncLiveAccountResp: &altv1.SyncLiveAccountResponse{Snapshot: snap},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_account_sync_test",
Steps: []Step{
{
ID: "sync",
Action: ActionSyncLiveAccount,
Request: Request{AccountID: "op-alias-001"},
Expect: Expect{Status: "ok"},
},
},
}
out, code := runScenario(t, sc, url)
if code != 0 {
t.Fatalf("expected exit 0, got %d\noutput:\n%s", code, out)
}
req := api.lastSyncLiveAccountReq()
if req == nil {
t.Fatal("expected server to receive SyncLiveAccountRequest")
}
if req.GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", req.GetAccountId())
}
}
func TestRunGetLiveAccountSnapshot(t *testing.T) {
snap := &altv1.LiveAccountSnapshot{
AccountId: "op-alias-001",
Broker: "kis",
SyncedAtUnixMs: 1749340800000,
}
api := &fakeAPI{
getLiveAccountSnapResp: &altv1.GetLiveAccountSnapshotResponse{Snapshot: snap},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_account_get_test",
Steps: []Step{
{
ID: "get",
Action: ActionGetLiveAccountSnapshot,
Request: Request{AccountID: "op-alias-001"},
Expect: Expect{Status: "ok"},
},
},
}
out, code := runScenario(t, sc, url)
if code != 0 {
t.Fatalf("expected exit 0, got %d\noutput:\n%s", code, out)
}
req := api.lastGetLiveAccountSnapReq()
if req == nil {
t.Fatal("expected server to receive GetLiveAccountSnapshotRequest")
}
if req.GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", req.GetAccountId())
}
}
func TestRunSyncLiveAccountValidationRejectsMissingAccountID(t *testing.T) {
sc := Scenario{
Name: "live_account_missing_id",
Steps: []Step{
{
ID: "sync",
Action: ActionSyncLiveAccount,
},
},
}
err := sc.Validate()
if err == nil {
t.Fatal("expected validation error for missing account_id")
}
}
func TestRunGetLiveAccountSnapshotValidationRejectsMissingAccountID(t *testing.T) {
sc := Scenario{
Name: "live_account_snap_missing_id",
Steps: []Step{
{
ID: "get",
Action: ActionGetLiveAccountSnapshot,
},
},
}
err := sc.Validate()
if err == nil {
t.Fatal("expected validation error for missing account_id")
}
}
func TestRunListLiveAuditEvents(t *testing.T) {
api := &fakeAPI{
listLiveAuditEventsResp: &altv1.ListLiveAuditEventsResponse{
Events: []*altv1.LiveAuditEvent{
{EventId: "aud-1", Type: "submit_confirmed", AccountId: "op-audit-test-001", OrderId: "lo-1"},
},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_audit_query",
Steps: []Step{
{
ID: "list_audit",
Action: ActionListLiveAuditEvents,
Request: Request{AccountID: "op-audit-test-001"},
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, "audit_event_count=1") {
t.Errorf("output %q missing audit_event_count=1", out)
}
req := api.lastListLiveAuditEventsReq()
if req == nil {
t.Fatal("server did not receive ListLiveAuditEventsRequest")
}
if req.GetAccountId() != "op-audit-test-001" {
t.Errorf("account_id mismatch: %q", req.GetAccountId())
}
}
func TestLiveAuditQueryFixtureIsValid(t *testing.T) {
yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_audit_query.yaml")
if _, err := LoadScenario(yamlPath); err != nil {
t.Fatalf("live_audit_query.yaml failed validation: %v", err)
}
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_audit_query.jsonl")
f, err := os.Open(expectedPath)
if err != nil {
t.Fatalf("open expected fixture: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
sawAuditCount := 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"] == "list_live_audit_events" && rec["audit_event_count"] != nil {
sawAuditCount = true
}
if rec["type"] == "summary" {
sawSummary = true
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan expected fixture: %v", err)
}
if !sawAuditCount {
t.Error("expected fixture missing a list_live_audit_events line with audit_event_count")
}
if !sawSummary {
t.Error("expected fixture missing a summary line")
}
}
func TestRunListLiveAuditEventsValidationRejectsMissingAccountID(t *testing.T) {
sc := Scenario{
Name: "live_audit_missing_id",
Steps: []Step{
{
ID: "list",
Action: ActionListLiveAuditEvents,
},
},
}
err := sc.Validate()
if err == nil {
t.Fatal("expected validation error for missing account_id")
}
}
func TestEvaluateLiveAccountTypedError(t *testing.T) {
ev := StepEvent{Action: string(ActionSyncLiveAccount)}
step := Step{Expect: Expect{Status: "ok"}}
errInfo := &altv1.ErrorInfo{Code: "not_found", Message: "snapshot not found"}
ev2, code := evaluateLiveAccount(ev, step, errInfo, nil)
if code == codeOK {
t.Error("expected non-OK code for typed error")
}
if ev2.ErrorCode != "not_found" {
t.Errorf("error_code mismatch: %q", ev2.ErrorCode)
}
}
func TestEvaluateLiveAccountExpectedError(t *testing.T) {
ev := StepEvent{Action: string(ActionGetLiveAccountSnapshot)}
step := Step{Expect: Expect{Status: "error", ErrorCode: "not_found"}}
errInfo := &altv1.ErrorInfo{Code: "not_found", Message: "snapshot not found"}
ev2, code := evaluateLiveAccount(ev, step, errInfo, nil)
if code != codeOK {
t.Errorf("expected codeOK for matched typed error, got %d", code)
}
if ev2.Status != statusOK {
t.Errorf("expected status ok, got %q", ev2.Status)
}
}