Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
822 lines
26 KiB
Go
822 lines
26 KiB
Go
package socket
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
"git.toki-labs.com/toki/alt/packages/domain/trading"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/livetrading"
|
|
protoSocket "git.toki-labs.com/toki/proto-socket/go"
|
|
)
|
|
|
|
type fakeLiveService struct {
|
|
cap trading.BrokerCapability
|
|
capErr error
|
|
submitOrder livetrading.LiveOrder
|
|
submitErr error
|
|
cancelOrder livetrading.LiveOrder
|
|
cancelErr error
|
|
getOrder livetrading.LiveOrder
|
|
getErr error
|
|
policy trading.RiskPolicy
|
|
killSwitch trading.KillSwitchState
|
|
syncSnap trading.AccountSnapshot
|
|
syncSnapErr error
|
|
getSnap trading.AccountSnapshot
|
|
getSnapErr error
|
|
listAuditEvents []trading.AuditEvent
|
|
listAuditErr error
|
|
}
|
|
|
|
func (f *fakeLiveService) GetLiveBrokerCapabilities(ctx context.Context, accountID string) (trading.BrokerCapability, error) {
|
|
return f.cap, f.capErr
|
|
}
|
|
|
|
func (f *fakeLiveService) SubmitLiveOrder(ctx context.Context, req livetrading.SubmitRequest) (livetrading.LiveOrder, error) {
|
|
return f.submitOrder, f.submitErr
|
|
}
|
|
|
|
func (f *fakeLiveService) CancelLiveOrder(ctx context.Context, accountID, liveOrderID string) (livetrading.LiveOrder, error) {
|
|
return f.cancelOrder, f.cancelErr
|
|
}
|
|
|
|
func (f *fakeLiveService) GetLiveOrder(ctx context.Context, accountID, liveOrderID string) (livetrading.LiveOrder, error) {
|
|
return f.getOrder, f.getErr
|
|
}
|
|
|
|
func (f *fakeLiveService) GetRiskPolicy() trading.RiskPolicy {
|
|
return f.policy
|
|
}
|
|
|
|
func (f *fakeLiveService) GetKillSwitch() trading.KillSwitchState {
|
|
return f.killSwitch
|
|
}
|
|
|
|
func (f *fakeLiveService) SetKillSwitch(ks trading.KillSwitchState) trading.KillSwitchState {
|
|
f.killSwitch = ks
|
|
return ks
|
|
}
|
|
|
|
func sampleLiveCapability() trading.BrokerCapability {
|
|
return trading.BrokerCapability{
|
|
Broker: trading.BrokerKIS,
|
|
Markets: map[market.Market]bool{market.MarketKR: true},
|
|
Venues: map[market.Venue]bool{market.VenueKRX: true},
|
|
OrderTypes: []trading.OrderType{trading.OrderTypeMarket, trading.OrderTypeLimit},
|
|
SupportsCancel: true,
|
|
SupportsAccount: true,
|
|
SupportsAuditTags: false,
|
|
CheckedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveBrokerCapabilitiesSuccess(t *testing.T) {
|
|
svc := &fakeLiveService{cap: sampleLiveCapability()}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleGetLiveBrokerCapabilities(deps, &altv1.GetLiveBrokerCapabilitiesRequest{AccountId: "test"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
cap := resp.GetBrokerCapabilities()
|
|
if cap == nil {
|
|
t.Fatal("expected broker capabilities in response")
|
|
}
|
|
if cap.GetBroker() != "kis" {
|
|
t.Errorf("broker mismatch: %q", cap.GetBroker())
|
|
}
|
|
if !cap.GetSupportsCancel() {
|
|
t.Error("expected supports_cancel to be true")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveBrokerCapabilitiesUnavailable(t *testing.T) {
|
|
deps := Deps{}
|
|
|
|
resp, err := handleGetLiveBrokerCapabilities(deps, &altv1.GetLiveBrokerCapabilitiesRequest{AccountId: "test"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed unavailable response, got error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorUnavailable {
|
|
t.Errorf("expected unavailable code, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveBrokerCapabilitiesEmptyAccountID(t *testing.T) {
|
|
svc := &fakeLiveService{cap: sampleLiveCapability()}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleGetLiveBrokerCapabilities(deps, &altv1.GetLiveBrokerCapabilitiesRequest{AccountId: ""})
|
|
if err != nil {
|
|
t.Fatalf("expected typed invalid_request response, got error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request code, got %q", resp.GetError().GetCode())
|
|
}
|
|
if resp.GetError().GetMessage() != "invalid live trading request: account_id is required" {
|
|
t.Errorf("unexpected error message: %q", resp.GetError().GetMessage())
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveBrokerCapabilitiesServiceError(t *testing.T) {
|
|
svc := &fakeLiveService{capErr: errors.New("broker connection failed")}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleGetLiveBrokerCapabilities(deps, &altv1.GetLiveBrokerCapabilitiesRequest{AccountId: "test"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed internal response, got error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInternal {
|
|
t.Errorf("expected internal code, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestLiveHandlersRegistration(t *testing.T) {
|
|
registered := make(map[string]bool)
|
|
for _, h := range liveHandlers(Deps{}) {
|
|
if h.requestType == "" {
|
|
t.Error("handler has empty request type")
|
|
}
|
|
if h.register == nil {
|
|
t.Errorf("handler %q has nil register", h.requestType)
|
|
}
|
|
registered[h.requestType] = true
|
|
}
|
|
|
|
if !registered["alt.v1.GetLiveBrokerCapabilitiesRequest"] {
|
|
t.Error("missing live capability handler registration")
|
|
}
|
|
}
|
|
|
|
func TestLiveHandlersWithNilLiveService(t *testing.T) {
|
|
// liveHandlers should still register even when deps.Live is nil.
|
|
// The handler returns unavailable error at runtime.
|
|
handlers := liveHandlers(Deps{})
|
|
if len(handlers) == 0 {
|
|
t.Error("expected live handlers to be registered even with nil Live service")
|
|
}
|
|
}
|
|
|
|
func TestLiveSocketUnavailableReturnsTypedError(t *testing.T) {
|
|
client := startBacktestWorkerTestClient(t, Deps{})
|
|
|
|
resp, err := protoSocket.SendRequestTyped[*altv1.GetLiveBrokerCapabilitiesRequest, *altv1.GetLiveBrokerCapabilitiesResponse](
|
|
&client.Communicator,
|
|
&altv1.GetLiveBrokerCapabilitiesRequest{AccountId: "test"},
|
|
500*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("request should return typed error response without timeout: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorUnavailable {
|
|
t.Errorf("expected unavailable code, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestLiveSocketRoundTripWithFakeService(t *testing.T) {
|
|
svc := &fakeLiveService{cap: sampleLiveCapability()}
|
|
client := startBacktestWorkerTestClient(t, Deps{Live: svc})
|
|
|
|
resp, err := protoSocket.SendRequestTyped[*altv1.GetLiveBrokerCapabilitiesRequest, *altv1.GetLiveBrokerCapabilitiesResponse](
|
|
&client.Communicator,
|
|
&altv1.GetLiveBrokerCapabilitiesRequest{AccountId: "test"},
|
|
1*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("round trip failed: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
cap := resp.GetBrokerCapabilities()
|
|
if cap == nil {
|
|
t.Fatal("expected broker capabilities in response")
|
|
}
|
|
if cap.GetBroker() != "kis" {
|
|
t.Errorf("broker mismatch: %q", cap.GetBroker())
|
|
}
|
|
}
|
|
|
|
func TestLiveSocketEmptyAccountIDReturnsInvalidRequest(t *testing.T) {
|
|
svc := &fakeLiveService{cap: sampleLiveCapability()}
|
|
client := startBacktestWorkerTestClient(t, Deps{Live: svc})
|
|
|
|
resp, err := protoSocket.SendRequestTyped[*altv1.GetLiveBrokerCapabilitiesRequest, *altv1.GetLiveBrokerCapabilitiesResponse](
|
|
&client.Communicator,
|
|
&altv1.GetLiveBrokerCapabilitiesRequest{AccountId: ""},
|
|
500*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("request should return typed error response without timeout: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request code, got %q", resp.GetError().GetCode())
|
|
}
|
|
if resp.GetBrokerCapabilities() != nil {
|
|
t.Error("expected no broker capabilities when account_id is empty")
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlersIncludeLive(t *testing.T) {
|
|
handlers := sessionHandlers(Deps{})
|
|
seenLive := false
|
|
for _, h := range handlers {
|
|
if h.requestType == "alt.v1.GetLiveBrokerCapabilitiesRequest" {
|
|
seenLive = true
|
|
break
|
|
}
|
|
}
|
|
if !seenLive {
|
|
t.Error("sessionHandlers should include live capability handler")
|
|
}
|
|
}
|
|
|
|
func TestLiveHandlersRegistrationIncludesOrderLifecycle(t *testing.T) {
|
|
required := map[string]bool{
|
|
"alt.v1.GetLiveBrokerCapabilitiesRequest": false,
|
|
"alt.v1.SubmitLiveOrderRequest": false,
|
|
"alt.v1.CancelLiveOrderRequest": false,
|
|
"alt.v1.GetLiveOrderRequest": false,
|
|
}
|
|
for _, h := range liveHandlers(Deps{}) {
|
|
if _, ok := required[h.requestType]; ok {
|
|
required[h.requestType] = true
|
|
}
|
|
}
|
|
for name, seen := range required {
|
|
if !seen {
|
|
t.Errorf("missing live handler registration: %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderConfirmationRequired(t *testing.T) {
|
|
svc := &fakeLiveService{submitErr: livetrading.ErrConfirmationRequired}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{InstrumentId: "KRX:005930", Side: "buy", Type: "market"},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: false},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderUnavailable(t *testing.T) {
|
|
deps := Deps{}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{InstrumentId: "KRX:005930", Side: "buy", Type: "market"},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorUnavailable {
|
|
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderSuccess(t *testing.T) {
|
|
svc := &fakeLiveService{
|
|
submitOrder: livetrading.LiveOrder{
|
|
ID: "lo-acct-1-1",
|
|
AccountID: "acct-1",
|
|
Status: "submitted",
|
|
BrokerStatus: "ACCEPTED",
|
|
BrokerOrderID: "broker-001",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: "KRX:005930",
|
|
Type: "kis_best_limit",
|
|
},
|
|
},
|
|
}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{InstrumentId: "KRX:005930", Side: "buy", Type: "kis_best_limit"},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true, OperatorId: "op-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetId() != "lo-acct-1-1" {
|
|
t.Errorf("unexpected order id: %q", resp.GetOrder().GetId())
|
|
}
|
|
if resp.GetOrder().GetType() != "kis_best_limit" {
|
|
t.Errorf("order type not preserved: %q", resp.GetOrder().GetType())
|
|
}
|
|
}
|
|
|
|
func TestHandleCancelLiveOrderNotFound(t *testing.T) {
|
|
svc := &fakeLiveService{cancelErr: livetrading.ErrOrderNotFound}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleCancelLiveOrder(deps, &altv1.CancelLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
LiveOrderId: "nonexistent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorNotFound {
|
|
t.Errorf("expected not_found, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveOrderSuccess(t *testing.T) {
|
|
svc := &fakeLiveService{
|
|
getOrder: livetrading.LiveOrder{
|
|
ID: "lo-acct-1-1",
|
|
AccountID: "acct-1",
|
|
Status: "filled",
|
|
BrokerStatus: "FILLED",
|
|
},
|
|
}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleGetLiveOrder(deps, &altv1.GetLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
LiveOrderId: "lo-acct-1-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetStatus() != "filled" {
|
|
t.Errorf("expected status filled, got %q", resp.GetOrder().GetStatus())
|
|
}
|
|
if resp.GetOrder().GetBrokerStatus() != "FILLED" {
|
|
t.Errorf("expected broker_status FILLED, got %q", resp.GetOrder().GetBrokerStatus())
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderRiskBlockedReturnsInvalidRequest(t *testing.T) {
|
|
svc := &fakeLiveService{submitErr: livetrading.ErrRiskBlocked}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{InstrumentId: "KRX:005930", Side: "buy", Type: "market"},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request for risk-blocked, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderLimitPriceCurrencyRoundTrip(t *testing.T) {
|
|
svc := &fakeLiveService{
|
|
submitOrder: livetrading.LiveOrder{
|
|
ID: "lo-acct-1-1",
|
|
AccountID: "acct-1",
|
|
Status: "submitted",
|
|
BrokerStatus: "ACCEPTED",
|
|
BrokerOrderID: "broker-001",
|
|
Intent: trading.OrderIntent{
|
|
InstrumentID: "KRX:005930",
|
|
Type: "limit",
|
|
LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "75000"}},
|
|
},
|
|
},
|
|
}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{
|
|
InstrumentId: "KRX:005930",
|
|
Side: "buy",
|
|
Type: "limit",
|
|
LimitPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "75000"}},
|
|
},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true, OperatorId: "op-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
lp := resp.GetOrder().GetLimitPrice()
|
|
if lp == nil {
|
|
t.Fatal("expected limit_price in response")
|
|
}
|
|
if lp.GetCurrency() != altv1.Currency_CURRENCY_KRW {
|
|
t.Errorf("expected CURRENCY_KRW in response, got %q", lp.GetCurrency())
|
|
}
|
|
if lp.GetAmount().GetValue() != "75000" {
|
|
t.Errorf("expected limit_price amount 75000, got %q", lp.GetAmount().GetValue())
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderRiskBlockedIncludesOrderAndDecision(t *testing.T) {
|
|
rejOrder := livetrading.LiveOrder{
|
|
ID: "lo-acct-1-1",
|
|
AccountID: "acct-1",
|
|
Status: trading.OrderStatusRejected,
|
|
RejectionReason: "kill switch is active",
|
|
}
|
|
svc := &fakeLiveService{
|
|
submitOrder: rejOrder,
|
|
submitErr: fmt.Errorf("%w: kill switch is active", livetrading.ErrRiskBlocked),
|
|
}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{InstrumentId: "KRX:005930", Side: "buy", Type: "market"},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error for risk-blocked, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
|
|
}
|
|
if resp.GetOrder() == nil {
|
|
t.Fatal("expected rejected order in response, got nil")
|
|
}
|
|
if resp.GetOrder().GetId() != "lo-acct-1-1" {
|
|
t.Errorf("unexpected order id: %q", resp.GetOrder().GetId())
|
|
}
|
|
if resp.GetRiskDecision() == nil {
|
|
t.Fatal("expected risk decision in response, got nil")
|
|
}
|
|
if resp.GetRiskDecision().GetAllowed() {
|
|
t.Error("expected risk decision allowed=false")
|
|
}
|
|
if resp.GetRiskDecision().GetReason() != "kill switch is active" {
|
|
t.Errorf("unexpected risk decision reason: %q", resp.GetRiskDecision().GetReason())
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitLiveOrderInvalidCurrencyReturnsInvalidRequest(t *testing.T) {
|
|
svc := &fakeLiveService{}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSubmitLiveOrder(deps, &altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{
|
|
InstrumentId: "KRX:005930",
|
|
Side: "buy",
|
|
Type: "limit",
|
|
LimitPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_UNSPECIFIED, Amount: &altv1.Decimal{Value: "75000"}},
|
|
},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true, OperatorId: "op-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected go error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error for unsupported currency, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestSubmitLiveOrderSocketRoundTrip(t *testing.T) {
|
|
svc := &fakeLiveService{
|
|
submitOrder: livetrading.LiveOrder{
|
|
ID: "lo-acct-1-1",
|
|
AccountID: "acct-1",
|
|
Status: "submitted",
|
|
BrokerStatus: "ACCEPTED",
|
|
BrokerOrderID: "broker-001",
|
|
Intent: trading.OrderIntent{InstrumentID: "KRX:005930", Type: "market"},
|
|
},
|
|
}
|
|
client := startBacktestWorkerTestClient(t, Deps{Live: svc})
|
|
|
|
resp, err := protoSocket.SendRequestTyped[*altv1.SubmitLiveOrderRequest, *altv1.SubmitLiveOrderResponse](
|
|
&client.Communicator,
|
|
&altv1.SubmitLiveOrderRequest{
|
|
AccountId: "acct-1",
|
|
Intent: &altv1.LiveOrderIntent{InstrumentId: "KRX:005930", Side: "buy", Type: "market"},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{Confirmed: true, OperatorId: "op-1"},
|
|
},
|
|
1*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("round trip failed: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetId() != "lo-acct-1-1" {
|
|
t.Errorf("unexpected order id: %q", resp.GetOrder().GetId())
|
|
}
|
|
}
|
|
|
|
func (f *fakeLiveService) SyncLiveAccount(ctx context.Context, accountID string) (trading.AccountSnapshot, error) {
|
|
return f.syncSnap, f.syncSnapErr
|
|
}
|
|
|
|
func (f *fakeLiveService) GetLiveAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error) {
|
|
return f.getSnap, f.getSnapErr
|
|
}
|
|
|
|
func (f *fakeLiveService) ListLiveAuditEvents(_ context.Context, _ trading.AuditFilter) ([]trading.AuditEvent, error) {
|
|
return f.listAuditEvents, f.listAuditErr
|
|
}
|
|
|
|
func sampleAccountSnapshotForSocket() trading.AccountSnapshot {
|
|
return trading.AccountSnapshot{
|
|
Broker: trading.BrokerKIS,
|
|
AccountID: "op-alias-001",
|
|
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"}},
|
|
},
|
|
},
|
|
Stale: false,
|
|
}
|
|
}
|
|
|
|
func TestHandleSyncLiveAccountSuccess(t *testing.T) {
|
|
snap := sampleAccountSnapshotForSocket()
|
|
svc := &fakeLiveService{syncSnap: snap}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSyncLiveAccount(deps, &altv1.SyncLiveAccountRequest{AccountId: "op-alias-001"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
s := resp.GetSnapshot()
|
|
if s == nil {
|
|
t.Fatal("expected snapshot in response")
|
|
}
|
|
if s.GetAccountId() != "op-alias-001" {
|
|
t.Errorf("account_id mismatch: %q", s.GetAccountId())
|
|
}
|
|
if s.GetBroker() != "kis" {
|
|
t.Errorf("broker mismatch: %q", s.GetBroker())
|
|
}
|
|
if len(s.GetCash()) != 1 {
|
|
t.Errorf("expected 1 cash entry, got %d", len(s.GetCash()))
|
|
}
|
|
if s.GetStale() {
|
|
t.Error("expected stale=false")
|
|
}
|
|
}
|
|
|
|
func TestHandleSyncLiveAccountEmptyAccountIDReturnsInvalidRequest(t *testing.T) {
|
|
svc := &fakeLiveService{}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleSyncLiveAccount(deps, &altv1.SyncLiveAccountRequest{AccountId: ""})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleSyncLiveAccountNilServiceReturnsUnavailable(t *testing.T) {
|
|
resp, err := handleSyncLiveAccount(Deps{}, &altv1.SyncLiveAccountRequest{AccountId: "op-alias-001"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorUnavailable {
|
|
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveAccountSnapshotNotFound(t *testing.T) {
|
|
svc := &fakeLiveService{getSnapErr: livetrading.ErrAccountSnapshotNotFound}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleGetLiveAccountSnapshot(deps, &altv1.GetLiveAccountSnapshotRequest{AccountId: "op-alias-001"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorNotFound {
|
|
t.Errorf("expected not_found, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleGetLiveAccountSnapshotSuccess(t *testing.T) {
|
|
snap := sampleAccountSnapshotForSocket()
|
|
svc := &fakeLiveService{getSnap: snap}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleGetLiveAccountSnapshot(deps, &altv1.GetLiveAccountSnapshotRequest{AccountId: "op-alias-001"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetSnapshot() == nil {
|
|
t.Fatal("expected snapshot in response")
|
|
}
|
|
if resp.GetSnapshot().GetAccountId() != "op-alias-001" {
|
|
t.Errorf("account_id mismatch: %q", resp.GetSnapshot().GetAccountId())
|
|
}
|
|
}
|
|
|
|
func TestLiveHandlersRegistrationIncludesAccountSync(t *testing.T) {
|
|
required := map[string]bool{
|
|
"alt.v1.SyncLiveAccountRequest": false,
|
|
"alt.v1.GetLiveAccountSnapshotRequest": false,
|
|
}
|
|
for _, h := range liveHandlers(Deps{}) {
|
|
if _, ok := required[h.requestType]; ok {
|
|
required[h.requestType] = true
|
|
}
|
|
}
|
|
for name, seen := range required {
|
|
if !seen {
|
|
t.Errorf("missing live handler registration: %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveHandlersRegistrationIncludesAuditQuery(t *testing.T) {
|
|
found := false
|
|
for _, h := range liveHandlers(Deps{}) {
|
|
if h.requestType == "alt.v1.ListLiveAuditEventsRequest" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("missing live handler registration: alt.v1.ListLiveAuditEventsRequest")
|
|
}
|
|
}
|
|
|
|
func TestHandleListLiveAuditEventsSuccess(t *testing.T) {
|
|
events := []trading.AuditEvent{
|
|
{
|
|
EventID: "aud-1",
|
|
Type: trading.AuditEventTypeSubmitConfirmed,
|
|
Timestamp: time.Now().UTC(),
|
|
AccountID: "acct-1",
|
|
OrderID: "lo-acct-1-1",
|
|
Status: "submitted",
|
|
Actor: "op-001",
|
|
},
|
|
{
|
|
EventID: "aud-2",
|
|
Type: trading.AuditEventTypeRiskBlocked,
|
|
Timestamp: time.Now().UTC(),
|
|
AccountID: "acct-1",
|
|
Status: "blocked",
|
|
},
|
|
}
|
|
svc := &fakeLiveService{listAuditEvents: events}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if len(resp.GetEvents()) != 2 {
|
|
t.Fatalf("expected 2 events, got %d", len(resp.GetEvents()))
|
|
}
|
|
if resp.GetEvents()[0].GetEventId() != "aud-1" {
|
|
t.Errorf("event_id mismatch: %q", resp.GetEvents()[0].GetEventId())
|
|
}
|
|
if resp.GetEvents()[0].GetType() != string(trading.AuditEventTypeSubmitConfirmed) {
|
|
t.Errorf("type mismatch: %q", resp.GetEvents()[0].GetType())
|
|
}
|
|
if resp.GetEvents()[0].GetActor() != "op-001" {
|
|
t.Errorf("actor mismatch: %q", resp.GetEvents()[0].GetActor())
|
|
}
|
|
}
|
|
|
|
func TestHandleListLiveAuditEventsEmptyAccountIDReturnsInvalidRequest(t *testing.T) {
|
|
svc := &fakeLiveService{}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: ""})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
|
|
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleListLiveAuditEventsNilServiceReturnsUnavailable(t *testing.T) {
|
|
resp, err := handleListLiveAuditEvents(Deps{}, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorUnavailable {
|
|
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleListLiveAuditEventsServiceErrorReturnsInternal(t *testing.T) {
|
|
svc := &fakeLiveService{listAuditErr: errors.New("store unavailable")}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() == nil {
|
|
t.Fatal("expected typed error, got nil")
|
|
}
|
|
if resp.GetError().GetCode() != backtestErrorInternal {
|
|
t.Errorf("expected internal, got %q", resp.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleListLiveAuditEventsEmptyResultIsOK(t *testing.T) {
|
|
svc := &fakeLiveService{listAuditEvents: nil}
|
|
deps := Deps{Live: svc}
|
|
|
|
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if len(resp.GetEvents()) != 0 {
|
|
t.Errorf("expected empty events, got %d", len(resp.GetEvents()))
|
|
}
|
|
}
|