Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
1194 lines
38 KiB
Go
1194 lines
38 KiB
Go
package livetrading
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
"git.toki-labs.com/toki/alt/packages/domain/trading"
|
|
)
|
|
|
|
// fakeBroker implements BrokerPort for testing and records every call so tests
|
|
// can assert call counts and the exact intent that reached the broker.
|
|
type fakeBroker struct {
|
|
submitResult OrderResult
|
|
submitErr error
|
|
cancelResult OrderResult
|
|
cancelErr error
|
|
statusResult OrderResult
|
|
statusErr error
|
|
caps trading.BrokerCapability
|
|
capsErr error
|
|
snapshot trading.AccountSnapshot
|
|
snapshotErr error
|
|
submitCalls int
|
|
lastAccountID string
|
|
lastIntent trading.OrderIntent
|
|
}
|
|
|
|
func (f *fakeBroker) GetCapabilities(_ context.Context, _ string) (trading.BrokerCapability, error) {
|
|
return f.caps, f.capsErr
|
|
}
|
|
|
|
func (f *fakeBroker) SubmitOrder(_ context.Context, accountID string, intent trading.OrderIntent) (OrderResult, error) {
|
|
f.submitCalls++
|
|
f.lastAccountID = accountID
|
|
f.lastIntent = intent
|
|
return f.submitResult, f.submitErr
|
|
}
|
|
|
|
func (f *fakeBroker) CancelOrder(_ context.Context, _, _ string) (OrderResult, error) {
|
|
return f.cancelResult, f.cancelErr
|
|
}
|
|
|
|
func (f *fakeBroker) GetOrderStatus(_ context.Context, _, _ string) (OrderResult, error) {
|
|
return f.statusResult, f.statusErr
|
|
}
|
|
|
|
func (f *fakeBroker) FetchAccountSnapshot(_ context.Context, _ string) (trading.AccountSnapshot, error) {
|
|
return f.snapshot, f.snapshotErr
|
|
}
|
|
|
|
func confirmedReq(accountID, instrumentID, orderType string) SubmitRequest {
|
|
return SubmitRequest{
|
|
AccountID: accountID,
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID(instrumentID),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "100"}},
|
|
Type: trading.OrderType(orderType),
|
|
},
|
|
Confirmation: OperatorConfirmation{
|
|
Confirmed: true,
|
|
OperatorID: "test-op",
|
|
Reason: "test submit",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderRequiresOperatorConfirmation(t *testing.T) {
|
|
svc := NewService(&fakeBroker{})
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
req.Confirmation.Confirmed = false
|
|
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if !errors.Is(err, ErrConfirmationRequired) {
|
|
t.Fatalf("expected ErrConfirmationRequired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderPreservesCustomOrderType(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{
|
|
BrokerOrderID: "broker-001",
|
|
Status: trading.OrderStatusSubmitted,
|
|
BrokerStatus: "ACCEPTED",
|
|
},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
req := confirmedReq("acct-1", "KRX:005930", "kis_best_limit")
|
|
|
|
order, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(order.Intent.Type) != "kis_best_limit" {
|
|
t.Errorf("order type not preserved: %q", order.Intent.Type)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderCallsBrokerOnlyAfterConfirmation(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
// Unconfirmed: broker must NOT be called.
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
req.Confirmation.Confirmed = false
|
|
_, _ = svc.SubmitLiveOrder(context.Background(), req)
|
|
if broker.submitCalls != 0 {
|
|
t.Fatalf("broker should not be called without confirmation, got %d calls", broker.submitCalls)
|
|
}
|
|
|
|
// Confirmed: broker must be called exactly once with the full intent.
|
|
req.Confirmation.Confirmed = true
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if broker.submitCalls != 1 {
|
|
t.Fatalf("expected exactly 1 broker call after confirmation, got %d", broker.submitCalls)
|
|
}
|
|
if broker.lastAccountID != "acct-1" {
|
|
t.Errorf("broker received account_id = %q, want acct-1", broker.lastAccountID)
|
|
}
|
|
if string(broker.lastIntent.Type) != "market" {
|
|
t.Errorf("broker received order type = %q, want market", broker.lastIntent.Type)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderMalformedIntentDoesNotReachBroker(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
|
|
cases := []struct {
|
|
name string
|
|
req SubmitRequest
|
|
}{
|
|
{
|
|
name: "missing instrument_id",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "invalid side",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSide("long"),
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "missing quantity",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "missing order type",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "empty account_id",
|
|
req: SubmitRequest{
|
|
AccountID: "",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "zero quantity (0.0)",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "0.0"}},
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "negative quantity",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "-1"}},
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "nonnumeric quantity",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "abc"}},
|
|
Type: trading.OrderType("market"),
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
{
|
|
name: "negative limit price",
|
|
req: SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
|
|
Type: trading.OrderType("limit"),
|
|
LimitPrice: market.Price{Amount: market.Decimal{Value: "-100"}},
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
broker.submitCalls = 0
|
|
_, err := svc.SubmitLiveOrder(context.Background(), tc.req)
|
|
if !errors.Is(err, ErrInvalidIntent) {
|
|
t.Errorf("expected ErrInvalidIntent, got %v", err)
|
|
}
|
|
if broker.submitCalls != 0 {
|
|
t.Errorf("broker should not be called for malformed intent, got %d calls", broker.submitCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderBrokerUnavailable(t *testing.T) {
|
|
svc := NewService(nil)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if !errors.Is(err, ErrBrokerUnavailable) {
|
|
t.Fatalf("expected ErrBrokerUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCancelLiveOrderTransitionsStatus(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "broker-001", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
cancelResult: OrderResult{BrokerOrderID: "broker-001", Status: trading.OrderStatusCanceled, BrokerStatus: "CANCELED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
order, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "limit"))
|
|
if err != nil {
|
|
t.Fatalf("submit error: %v", err)
|
|
}
|
|
|
|
canceled, err := svc.CancelLiveOrder(context.Background(), "acct-1", order.ID)
|
|
if err != nil {
|
|
t.Fatalf("cancel error: %v", err)
|
|
}
|
|
if canceled.Status != trading.OrderStatusCanceled {
|
|
t.Errorf("expected status %q, got %q", trading.OrderStatusCanceled, canceled.Status)
|
|
}
|
|
if string(canceled.BrokerStatus) != "CANCELED" {
|
|
t.Errorf("expected broker_status CANCELED, got %q", canceled.BrokerStatus)
|
|
}
|
|
}
|
|
|
|
func TestCancelLiveOrderNotFound(t *testing.T) {
|
|
svc := NewService(&fakeBroker{})
|
|
|
|
_, err := svc.CancelLiveOrder(context.Background(), "acct-1", "nonexistent")
|
|
if !errors.Is(err, ErrOrderNotFound) {
|
|
t.Fatalf("expected ErrOrderNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetLiveOrderReturnsBrokerStatus(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "broker-001", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
statusResult: OrderResult{BrokerOrderID: "broker-001", Status: trading.OrderStatusFilled, BrokerStatus: "FILLED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
order, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if err != nil {
|
|
t.Fatalf("submit error: %v", err)
|
|
}
|
|
|
|
got, err := svc.GetLiveOrder(context.Background(), "acct-1", order.ID)
|
|
if err != nil {
|
|
t.Fatalf("get error: %v", err)
|
|
}
|
|
if got.Status != trading.OrderStatusFilled {
|
|
t.Errorf("expected status %q, got %q", trading.OrderStatusFilled, got.Status)
|
|
}
|
|
if string(got.BrokerStatus) != "FILLED" {
|
|
t.Errorf("expected broker_status FILLED, got %q", got.BrokerStatus)
|
|
}
|
|
}
|
|
|
|
func TestGetLiveBrokerCapabilitiesUnavailable(t *testing.T) {
|
|
svc := NewService(nil)
|
|
_, err := svc.GetLiveBrokerCapabilities(context.Background(), "acct-1")
|
|
if !errors.Is(err, ErrBrokerUnavailable) {
|
|
t.Fatalf("expected ErrBrokerUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderKillSwitchBlocksBeforeBroker(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: "submitted", BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
// NewService starts with kill switch halted=true.
|
|
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if !errors.Is(err, ErrRiskBlocked) {
|
|
t.Fatalf("expected ErrRiskBlocked when kill switch is halted, got %v", err)
|
|
}
|
|
if broker.submitCalls != 0 {
|
|
t.Errorf("broker must not be called when kill switch is halted, got %d calls", broker.submitCalls)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderMaxNotionalBlocks(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: "submitted", BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
// 20 * 75000 = 1500000 > 1000000 (default KRW limit)
|
|
req := SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "20"}},
|
|
Type: trading.OrderType("limit"),
|
|
LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "75000"}},
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
}
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if !errors.Is(err, ErrRiskBlocked) {
|
|
t.Fatalf("expected ErrRiskBlocked for excessive notional, got %v", err)
|
|
}
|
|
if broker.submitCalls != 0 {
|
|
t.Errorf("broker must not be called when notional exceeds limit, got %d calls", broker.submitCalls)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderRiskAllowedCallsBroker(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: "submitted", BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
// 1 * 75000 = 75000 < 1000000
|
|
req := SubmitRequest{
|
|
AccountID: "acct-1",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
Type: trading.OrderType("limit"),
|
|
LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "75000"}},
|
|
},
|
|
Confirmation: OperatorConfirmation{Confirmed: true, OperatorID: "op-1"},
|
|
}
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("expected success for allowed order, got %v", err)
|
|
}
|
|
if broker.submitCalls != 1 {
|
|
t.Errorf("expected exactly 1 broker call, got %d", broker.submitCalls)
|
|
}
|
|
}
|
|
|
|
func TestSetAndGetKillSwitch(t *testing.T) {
|
|
svc := NewService(nil)
|
|
got := svc.GetKillSwitch()
|
|
if !got.Halted {
|
|
t.Error("default kill switch must be halted")
|
|
}
|
|
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false, Reason: "test"})
|
|
got = svc.GetKillSwitch()
|
|
if got.Halted {
|
|
t.Error("expected kill switch unhalted after SetKillSwitch")
|
|
}
|
|
if got.Reason != "test" {
|
|
t.Errorf("expected reason %q, got %q", "test", got.Reason)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderMaxDailyOrdersBlocksBeforeBroker(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
// Lower limit to 2 so we hit it quickly in the test.
|
|
policy := svc.GetRiskPolicy()
|
|
policy.MaxDailyOrders = 2
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
// First two submits must reach the broker.
|
|
for i := 0; i < 2; i++ {
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if err != nil {
|
|
t.Fatalf("submit %d unexpected error: %v", i+1, err)
|
|
}
|
|
}
|
|
if broker.submitCalls != 2 {
|
|
t.Fatalf("expected 2 broker calls before limit, got %d", broker.submitCalls)
|
|
}
|
|
|
|
// Third submit must be blocked before the broker.
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if !errors.Is(err, ErrRiskBlocked) {
|
|
t.Fatalf("expected ErrRiskBlocked at daily limit, got %v", err)
|
|
}
|
|
if broker.submitCalls != 2 {
|
|
t.Errorf("broker must not be called when daily limit is reached, got %d calls", broker.submitCalls)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderMaxOpenOrdersBlocksBeforeBroker(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
policy := svc.GetRiskPolicy()
|
|
policy.MaxOpenOrders = 2
|
|
policy.MaxDailyOrders = 0 // disable daily limit to isolate open-order check
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
// Submit two open orders.
|
|
for i := 0; i < 2; i++ {
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if err != nil {
|
|
t.Fatalf("submit %d unexpected error: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
// Third submit must be blocked before the broker.
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if !errors.Is(err, ErrRiskBlocked) {
|
|
t.Fatalf("expected ErrRiskBlocked at open-order limit, got %v", err)
|
|
}
|
|
if broker.submitCalls != 2 {
|
|
t.Errorf("broker must not be called when open-order limit is reached, got %d calls", broker.submitCalls)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderRiskRejectedOrderNotCountedAsOpen(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
svc := NewService(broker)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
policy := svc.GetRiskPolicy()
|
|
policy.MaxOpenOrders = 1
|
|
policy.MaxDailyOrders = 0
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
// Submit one open order — at limit now.
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if err != nil {
|
|
t.Fatalf("first submit unexpected error: %v", err)
|
|
}
|
|
|
|
// Second attempt is blocked → creates a rejected order record.
|
|
rejOrder, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if !errors.Is(err, ErrRiskBlocked) {
|
|
t.Fatalf("expected ErrRiskBlocked, got %v", err)
|
|
}
|
|
if rejOrder.Status != trading.OrderStatusRejected {
|
|
t.Errorf("expected rejected order, got status %q", rejOrder.Status)
|
|
}
|
|
|
|
// After a rejected order, open count must still be 1 (rejected is terminal).
|
|
// Temporarily set limit to 2 to allow a new open order.
|
|
policy.MaxOpenOrders = 2
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
_, err = svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-1", "KRX:005930", "market"))
|
|
if err != nil {
|
|
t.Errorf("expected allowed after policy relaxation (rejected order doesn't count as open), got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetRiskPolicyReturnsDefault(t *testing.T) {
|
|
svc := NewService(nil)
|
|
p := svc.GetRiskPolicy()
|
|
if p.MaxDailyOrders != 5 {
|
|
t.Errorf("expected default MaxDailyOrders=5, got %d", p.MaxDailyOrders)
|
|
}
|
|
if p.MaxOpenOrders != 3 {
|
|
t.Errorf("expected default MaxOpenOrders=3, got %d", p.MaxOpenOrders)
|
|
}
|
|
if p.AllowShortSelling {
|
|
t.Error("expected default AllowShortSelling=false")
|
|
}
|
|
}
|
|
|
|
// blockingFakeBroker is a BrokerPort whose SubmitOrder blocks until released.
|
|
// It signals brokerEntered when the first call arrives so tests can synchronize.
|
|
type blockingFakeBroker struct {
|
|
result OrderResult
|
|
mu sync.Mutex
|
|
callCount int
|
|
brokerEntered chan struct{}
|
|
releaseCh chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func newBlockingFakeBroker(result OrderResult) *blockingFakeBroker {
|
|
return &blockingFakeBroker{
|
|
result: result,
|
|
brokerEntered: make(chan struct{}),
|
|
releaseCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (b *blockingFakeBroker) GetCapabilities(_ context.Context, _ string) (trading.BrokerCapability, error) {
|
|
return trading.BrokerCapability{}, nil
|
|
}
|
|
|
|
func (b *blockingFakeBroker) SubmitOrder(_ context.Context, _ string, _ trading.OrderIntent) (OrderResult, error) {
|
|
b.mu.Lock()
|
|
b.callCount++
|
|
b.mu.Unlock()
|
|
b.once.Do(func() { close(b.brokerEntered) })
|
|
<-b.releaseCh
|
|
return b.result, nil
|
|
}
|
|
|
|
func (b *blockingFakeBroker) CancelOrder(_ context.Context, _, _ string) (OrderResult, error) {
|
|
return OrderResult{}, nil
|
|
}
|
|
|
|
func (b *blockingFakeBroker) GetOrderStatus(_ context.Context, _, _ string) (OrderResult, error) {
|
|
return OrderResult{}, nil
|
|
}
|
|
|
|
func (b *blockingFakeBroker) FetchAccountSnapshot(_ context.Context, _ string) (trading.AccountSnapshot, error) {
|
|
return trading.AccountSnapshot{}, nil
|
|
}
|
|
|
|
func (b *blockingFakeBroker) calls() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.callCount
|
|
}
|
|
|
|
func TestSubmitLiveOrderConcurrentMaxOpenOrdersBlocksBeforeBroker(t *testing.T) {
|
|
blocking := newBlockingFakeBroker(OrderResult{
|
|
BrokerOrderID: "b-1",
|
|
Status: trading.OrderStatusSubmitted,
|
|
BrokerStatus: "ACCEPTED",
|
|
})
|
|
svc := NewService(blocking)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
policy := svc.GetRiskPolicy()
|
|
policy.MaxOpenOrders = 1
|
|
policy.MaxDailyOrders = 0
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
|
|
var wg sync.WaitGroup
|
|
orders := make([]LiveOrder, 2)
|
|
errs := make([]error, 2)
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
orders[0], errs[0] = svc.SubmitLiveOrder(context.Background(), req)
|
|
}()
|
|
|
|
// Wait until submit 1 is inside the broker (holding the account mutex).
|
|
<-blocking.brokerEntered
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
orders[1], errs[1] = svc.SubmitLiveOrder(context.Background(), req)
|
|
}()
|
|
|
|
// Release the broker so submit 1 stores its order and releases the account mutex.
|
|
close(blocking.releaseCh)
|
|
wg.Wait()
|
|
|
|
if blocking.calls() != 1 {
|
|
t.Errorf("expected 1 broker call, got %d", blocking.calls())
|
|
}
|
|
if errs[0] != nil {
|
|
t.Errorf("submit 1: expected success, got %v", errs[0])
|
|
}
|
|
if !errors.Is(errs[1], ErrRiskBlocked) {
|
|
t.Errorf("submit 2: expected ErrRiskBlocked, got %v", errs[1])
|
|
}
|
|
if orders[1].Status != trading.OrderStatusRejected {
|
|
t.Errorf("submit 2: expected rejected order status, got %q", orders[1].Status)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderConcurrentMaxDailyOrdersBlocksBeforeBroker(t *testing.T) {
|
|
blocking := newBlockingFakeBroker(OrderResult{
|
|
BrokerOrderID: "b-1",
|
|
Status: trading.OrderStatusSubmitted,
|
|
BrokerStatus: "ACCEPTED",
|
|
})
|
|
svc := NewService(blocking)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
policy := svc.GetRiskPolicy()
|
|
policy.MaxDailyOrders = 1
|
|
policy.MaxOpenOrders = 0
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
|
|
var wg sync.WaitGroup
|
|
orders := make([]LiveOrder, 2)
|
|
errs := make([]error, 2)
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
orders[0], errs[0] = svc.SubmitLiveOrder(context.Background(), req)
|
|
}()
|
|
|
|
<-blocking.brokerEntered
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
orders[1], errs[1] = svc.SubmitLiveOrder(context.Background(), req)
|
|
}()
|
|
|
|
close(blocking.releaseCh)
|
|
wg.Wait()
|
|
|
|
if blocking.calls() != 1 {
|
|
t.Errorf("expected 1 broker call, got %d", blocking.calls())
|
|
}
|
|
if errs[0] != nil {
|
|
t.Errorf("submit 1: expected success, got %v", errs[0])
|
|
}
|
|
if !errors.Is(errs[1], ErrRiskBlocked) {
|
|
t.Errorf("submit 2: expected ErrRiskBlocked, got %v", errs[1])
|
|
}
|
|
if orders[1].Status != trading.OrderStatusRejected {
|
|
t.Errorf("submit 2: expected rejected order status, got %q", orders[1].Status)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderQueuedKillSwitchBlocksBeforeBroker(t *testing.T) {
|
|
blocking := newBlockingFakeBroker(OrderResult{
|
|
BrokerOrderID: "b-1",
|
|
Status: trading.OrderStatusSubmitted,
|
|
BrokerStatus: "ACCEPTED",
|
|
})
|
|
svc := NewService(blocking)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
policy := svc.GetRiskPolicy()
|
|
policy.MaxOpenOrders = 0
|
|
policy.MaxDailyOrders = 0
|
|
svc.SetRiskPolicy(policy)
|
|
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
|
|
var wg sync.WaitGroup
|
|
orders := make([]LiveOrder, 2)
|
|
errs := make([]error, 2)
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
orders[0], errs[0] = svc.SubmitLiveOrder(context.Background(), req)
|
|
}()
|
|
|
|
// Wait until submit 1 is inside the broker (holding the account mutex).
|
|
<-blocking.brokerEntered
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
orders[1], errs[1] = svc.SubmitLiveOrder(context.Background(), req)
|
|
}()
|
|
|
|
// Give submit 2 time to reach the account mutex before changing kill switch state.
|
|
time.Sleep(time.Millisecond)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: true, Reason: "operator halt"})
|
|
close(blocking.releaseCh)
|
|
wg.Wait()
|
|
|
|
if blocking.calls() != 1 {
|
|
t.Errorf("expected 1 broker call, got %d", blocking.calls())
|
|
}
|
|
if errs[0] != nil {
|
|
t.Errorf("submit 1: expected success, got %v", errs[0])
|
|
}
|
|
if !errors.Is(errs[1], ErrRiskBlocked) {
|
|
t.Errorf("submit 2: expected ErrRiskBlocked, got %v", errs[1])
|
|
}
|
|
if orders[1].Status != trading.OrderStatusRejected {
|
|
t.Errorf("submit 2: expected rejected order, got %q", orders[1].Status)
|
|
}
|
|
if orders[1].RejectionReason != "operator halt" {
|
|
t.Errorf("submit 2: expected rejection reason 'operator halt', got %q", orders[1].RejectionReason)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderBrokerUnavailableWithDefaultKillSwitch(t *testing.T) {
|
|
// NewService(nil) default kill switch is halted=true. ErrBrokerUnavailable
|
|
// must take precedence over risk evaluation — no rejected order is created.
|
|
svc := NewService(nil)
|
|
req := confirmedReq("acct-1", "KRX:005930", "market")
|
|
|
|
_, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if !errors.Is(err, ErrBrokerUnavailable) {
|
|
t.Fatalf("expected ErrBrokerUnavailable even with default halted kill switch, got %v", err)
|
|
}
|
|
if errors.Is(err, ErrRiskBlocked) {
|
|
t.Error("ErrBrokerUnavailable must take precedence over ErrRiskBlocked for nil broker")
|
|
}
|
|
}
|
|
|
|
func sampleAccountSnapshot(accountID string) trading.AccountSnapshot {
|
|
return trading.AccountSnapshot{
|
|
Broker: trading.BrokerKIS,
|
|
AccountID: accountID,
|
|
Cash: []trading.CashBalance{
|
|
{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5000000"}},
|
|
},
|
|
Positions: []trading.PositionSnapshot{
|
|
{
|
|
InstrumentID: "KRX:005930",
|
|
Side: trading.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
|
|
},
|
|
},
|
|
SyncedAt: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC),
|
|
Stale: false,
|
|
}
|
|
}
|
|
|
|
func TestSyncLiveAccountSuccess(t *testing.T) {
|
|
snap := sampleAccountSnapshot("op-alias-001")
|
|
broker := &fakeBroker{snapshot: snap}
|
|
svc := NewService(broker)
|
|
|
|
got, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.AccountID != "op-alias-001" {
|
|
t.Errorf("account_id mismatch: %q", got.AccountID)
|
|
}
|
|
if got.Broker != trading.BrokerKIS {
|
|
t.Errorf("broker mismatch: %q", got.Broker)
|
|
}
|
|
if len(got.Cash) != 1 {
|
|
t.Fatalf("expected 1 cash entry, got %d", len(got.Cash))
|
|
}
|
|
if got.Stale {
|
|
t.Error("expected Stale=false")
|
|
}
|
|
// Raw account number must not appear in AccountID
|
|
if got.AccountID == "1234567890" {
|
|
t.Error("raw account number must not be exposed in AccountID")
|
|
}
|
|
}
|
|
|
|
func TestSyncLiveAccountNilBrokerReturnsUnavailable(t *testing.T) {
|
|
svc := NewService(nil)
|
|
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
|
|
if !errors.Is(err, ErrBrokerUnavailable) {
|
|
t.Fatalf("expected ErrBrokerUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSyncLiveAccountBrokerErrorPropagates(t *testing.T) {
|
|
broker := &fakeBroker{snapshotErr: errors.New("connection refused")}
|
|
svc := NewService(broker)
|
|
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
|
|
if err == nil {
|
|
t.Fatal("expected error from broker, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetLiveAccountSnapshotNotFoundBeforeSync(t *testing.T) {
|
|
svc := NewService(&fakeBroker{})
|
|
_, err := svc.GetLiveAccountSnapshot(context.Background(), "op-alias-001")
|
|
if !errors.Is(err, ErrAccountSnapshotNotFound) {
|
|
t.Fatalf("expected ErrAccountSnapshotNotFound before sync, got %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeAuditStore records all appended audit events and can be configured to
|
|
// return an error on the next AppendLiveAuditEvent call.
|
|
type fakeAuditStore struct {
|
|
events []trading.AuditEvent
|
|
appendErr error
|
|
listErr error
|
|
}
|
|
|
|
func (f *fakeAuditStore) AppendLiveAuditEvent(_ context.Context, ev trading.AuditEvent) error {
|
|
if f.appendErr != nil {
|
|
return f.appendErr
|
|
}
|
|
f.events = append(f.events, ev)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAuditStore) ListLiveAuditEvents(_ context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
var out []trading.AuditEvent
|
|
for _, ev := range f.events {
|
|
if filter.AccountID != "" && ev.AccountID != filter.AccountID {
|
|
continue
|
|
}
|
|
if filter.OrderID != "" && ev.OrderID != filter.OrderID {
|
|
continue
|
|
}
|
|
if filter.EventType != "" && ev.Type != filter.EventType {
|
|
continue
|
|
}
|
|
out = append(out, ev)
|
|
if filter.Limit > 0 && len(out) >= filter.Limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func auditStoreEvents(store *fakeAuditStore) []trading.AuditEventType {
|
|
types := make([]trading.AuditEventType, len(store.events))
|
|
for i, ev := range store.events {
|
|
types[i] = ev.Type
|
|
}
|
|
return types
|
|
}
|
|
|
|
func TestSubmitLiveOrderEmitsSubmitConfirmedAudit(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
req := confirmedReq("acct-audit", "KRX:005930", "market")
|
|
req.Confirmation.OperatorID = "op-1"
|
|
order, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// A successful submit emits submit_confirmed (operator gate) then broker_accepted.
|
|
evsByType := make(map[trading.AuditEventType][]trading.AuditEvent)
|
|
for _, ev := range audit.events {
|
|
evsByType[ev.Type] = append(evsByType[ev.Type], ev)
|
|
}
|
|
|
|
submitEvents := evsByType[trading.AuditEventTypeSubmitConfirmed]
|
|
if len(submitEvents) != 1 {
|
|
t.Fatalf("expected 1 submit_confirmed event, got %d (all types: %v)", len(submitEvents), auditStoreEvents(audit))
|
|
}
|
|
sc := submitEvents[0]
|
|
if sc.AccountID != "acct-audit" {
|
|
t.Errorf("submit_confirmed account_id mismatch: %q", sc.AccountID)
|
|
}
|
|
if sc.Actor != "op-1" {
|
|
t.Errorf("submit_confirmed actor mismatch: %q", sc.Actor)
|
|
}
|
|
if sc.EventID == "" {
|
|
t.Error("submit_confirmed event_id must be non-empty")
|
|
}
|
|
// submit_confirmed is emitted before broker call, so OrderID is not yet set.
|
|
if sc.OrderID != "" {
|
|
t.Errorf("submit_confirmed should not carry order_id (emitted before broker), got %q", sc.OrderID)
|
|
}
|
|
|
|
acceptedEvents := evsByType[trading.AuditEventTypeBrokerAccepted]
|
|
if len(acceptedEvents) != 1 {
|
|
t.Fatalf("expected 1 broker_accepted event, got %d", len(acceptedEvents))
|
|
}
|
|
ba := acceptedEvents[0]
|
|
if ba.OrderID != order.ID {
|
|
t.Errorf("broker_accepted order_id mismatch: got %q, want %q", ba.OrderID, order.ID)
|
|
}
|
|
if ba.AccountID != "acct-audit" {
|
|
t.Errorf("broker_accepted account_id mismatch: %q", ba.AccountID)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderBrokerRejectedEmitsBothAuditEvents(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "", Status: trading.OrderStatusRejected, BrokerStatus: "REJECTED", RejectionReason: "insufficient balance"},
|
|
}
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
req := confirmedReq("acct-audit", "KRX:005930", "market")
|
|
req.Confirmation.OperatorID = "op-1"
|
|
order, err := svc.SubmitLiveOrder(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if order.Status != trading.OrderStatusRejected {
|
|
t.Fatalf("expected rejected order, got %q", order.Status)
|
|
}
|
|
|
|
evsByType := make(map[trading.AuditEventType][]trading.AuditEvent)
|
|
for _, ev := range audit.events {
|
|
evsByType[ev.Type] = append(evsByType[ev.Type], ev)
|
|
}
|
|
|
|
if len(evsByType[trading.AuditEventTypeSubmitConfirmed]) != 1 {
|
|
t.Fatalf("expected 1 submit_confirmed, got %d", len(evsByType[trading.AuditEventTypeSubmitConfirmed]))
|
|
}
|
|
rejectedEvents := evsByType[trading.AuditEventTypeBrokerRejected]
|
|
if len(rejectedEvents) != 1 {
|
|
t.Fatalf("expected 1 broker_rejected event, got %d", len(rejectedEvents))
|
|
}
|
|
br := rejectedEvents[0]
|
|
if br.OrderID != order.ID {
|
|
t.Errorf("broker_rejected order_id mismatch: got %q, want %q", br.OrderID, order.ID)
|
|
}
|
|
if br.Reason != "insufficient balance" {
|
|
t.Errorf("broker_rejected reason mismatch: %q", br.Reason)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderRiskBlockedEmitsRiskBlockedAudit(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
// Kill switch halted by default → ErrRiskBlocked
|
|
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-audit", "KRX:005930", "market"))
|
|
if !errors.Is(err, ErrRiskBlocked) {
|
|
t.Fatalf("expected ErrRiskBlocked, got %v", err)
|
|
}
|
|
|
|
var riskEvents []trading.AuditEvent
|
|
for _, ev := range audit.events {
|
|
if ev.Type == trading.AuditEventTypeRiskBlocked {
|
|
riskEvents = append(riskEvents, ev)
|
|
}
|
|
}
|
|
if len(riskEvents) != 1 {
|
|
t.Fatalf("expected 1 risk_blocked event, got %d", len(riskEvents))
|
|
}
|
|
if riskEvents[0].AccountID != "acct-audit" {
|
|
t.Errorf("account_id mismatch: %q", riskEvents[0].AccountID)
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderAuditFailureReturnsErrAuditFailed(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
}
|
|
audit := &fakeAuditStore{appendErr: errors.New("db down")}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-audit", "KRX:005930", "market"))
|
|
if !errors.Is(err, ErrAuditFailed) {
|
|
t.Fatalf("expected ErrAuditFailed when audit store errors, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCancelLiveOrderEmitsCancelConfirmedAudit(t *testing.T) {
|
|
broker := &fakeBroker{
|
|
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
|
|
cancelResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusCanceled, BrokerStatus: "CANCELED"},
|
|
}
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
|
|
|
|
order, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-audit", "KRX:005930", "limit"))
|
|
if err != nil {
|
|
t.Fatalf("submit error: %v", err)
|
|
}
|
|
_, err = svc.CancelLiveOrder(context.Background(), "acct-audit", order.ID)
|
|
if err != nil {
|
|
t.Fatalf("cancel error: %v", err)
|
|
}
|
|
|
|
var cancelEvents []trading.AuditEvent
|
|
for _, ev := range audit.events {
|
|
if ev.Type == trading.AuditEventTypeCancelConfirmed {
|
|
cancelEvents = append(cancelEvents, ev)
|
|
}
|
|
}
|
|
if len(cancelEvents) != 1 {
|
|
t.Fatalf("expected 1 cancel_confirmed event, got %d", len(cancelEvents))
|
|
}
|
|
if cancelEvents[0].OrderID != order.ID {
|
|
t.Errorf("order_id mismatch: got %q, want %q", cancelEvents[0].OrderID, order.ID)
|
|
}
|
|
}
|
|
|
|
func TestSyncLiveAccountEmitsAccountSyncSuccessAudit(t *testing.T) {
|
|
snap := sampleAccountSnapshot("op-alias-001")
|
|
broker := &fakeBroker{snapshot: snap}
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
|
|
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
var syncEvents []trading.AuditEvent
|
|
for _, ev := range audit.events {
|
|
if ev.Type == trading.AuditEventTypeAccountSyncSuccess {
|
|
syncEvents = append(syncEvents, ev)
|
|
}
|
|
}
|
|
if len(syncEvents) != 1 {
|
|
t.Fatalf("expected 1 account_sync_success event, got %d", len(syncEvents))
|
|
}
|
|
if syncEvents[0].AccountID != "op-alias-001" {
|
|
t.Errorf("account_id mismatch: %q", syncEvents[0].AccountID)
|
|
}
|
|
if syncEvents[0].Broker != trading.BrokerKIS {
|
|
t.Errorf("broker mismatch: %q", syncEvents[0].Broker)
|
|
}
|
|
}
|
|
|
|
func TestSyncLiveAccountBrokerErrorEmitsAccountSyncFailureAudit(t *testing.T) {
|
|
broker := &fakeBroker{snapshotErr: errors.New("network timeout")}
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(broker)
|
|
svc.SetAuditStore(audit)
|
|
|
|
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
|
|
if err == nil {
|
|
t.Fatal("expected error from broker")
|
|
}
|
|
|
|
var failEvents []trading.AuditEvent
|
|
for _, ev := range audit.events {
|
|
if ev.Type == trading.AuditEventTypeAccountSyncFailure {
|
|
failEvents = append(failEvents, ev)
|
|
}
|
|
}
|
|
if len(failEvents) != 1 {
|
|
t.Fatalf("expected 1 account_sync_failure event, got %d", len(failEvents))
|
|
}
|
|
}
|
|
|
|
func TestAppendAuditRejectsForbiddenPayloadKey(t *testing.T) {
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(nil)
|
|
svc.SetAuditStore(audit)
|
|
|
|
ev := trading.AuditEvent{
|
|
EventID: "aud-bad",
|
|
Type: trading.AuditEventTypeSubmitConfirmed,
|
|
AccountID: "acct-1",
|
|
Payload: map[string]string{"token": "raw-secret"},
|
|
}
|
|
err := svc.appendAudit(context.Background(), ev)
|
|
if !errors.Is(err, ErrAuditFailed) {
|
|
t.Fatalf("expected ErrAuditFailed for forbidden payload key, got %v", err)
|
|
}
|
|
if len(audit.events) != 0 {
|
|
t.Error("store must not be called when payload contains forbidden key")
|
|
}
|
|
}
|
|
|
|
func TestListLiveAuditEventsNoStore(t *testing.T) {
|
|
svc := NewService(nil)
|
|
events, err := svc.ListLiveAuditEvents(context.Background(), trading.AuditFilter{AccountID: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("expected nil error when no store, got %v", err)
|
|
}
|
|
if events != nil {
|
|
t.Errorf("expected nil events when no store, got %v", events)
|
|
}
|
|
}
|
|
|
|
func TestListLiveAuditEventsDelegatesFilter(t *testing.T) {
|
|
audit := &fakeAuditStore{}
|
|
svc := NewService(nil)
|
|
svc.SetAuditStore(audit)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
_ = audit.AppendLiveAuditEvent(context.Background(), trading.AuditEvent{
|
|
EventID: fmt.Sprintf("aud-%d", i),
|
|
Type: trading.AuditEventTypeSubmitConfirmed,
|
|
AccountID: "acct-1",
|
|
OrderID: fmt.Sprintf("lo-acct-1-%d", i),
|
|
})
|
|
}
|
|
|
|
events, err := svc.ListLiveAuditEvents(context.Background(), trading.AuditFilter{AccountID: "acct-1", Limit: 2})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(events) != 2 {
|
|
t.Errorf("expected 2 events with limit=2, got %d", len(events))
|
|
}
|
|
}
|
|
|
|
func TestGetLiveAccountSnapshotAfterSync(t *testing.T) {
|
|
snap := sampleAccountSnapshot("op-alias-001")
|
|
broker := &fakeBroker{snapshot: snap}
|
|
svc := NewService(broker)
|
|
|
|
if _, err := svc.SyncLiveAccount(context.Background(), "op-alias-001"); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
got, err := svc.GetLiveAccountSnapshot(context.Background(), "op-alias-001")
|
|
if err != nil {
|
|
t.Fatalf("get failed: %v", err)
|
|
}
|
|
if got.AccountID != "op-alias-001" {
|
|
t.Errorf("account_id mismatch: %q", got.AccountID)
|
|
}
|
|
if len(got.Positions) != 1 {
|
|
t.Errorf("expected 1 position, got %d", len(got.Positions))
|
|
}
|
|
}
|