alt/services/worker/internal/livetrading/service_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

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))
}
}