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