실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
440 lines
12 KiB
Go
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")
|
|
}
|
|
}
|