alt/services/worker/internal/socket/live.go
toki c0db4b24c1 feat(live-trading): 계좌 동기화와 감사 추적을 추가한다
Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
2026-06-08 11:18:41 +09:00

428 lines
16 KiB
Go

package socket
import (
"context"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
"git.toki-labs.com/toki/alt/packages/domain/trading"
"git.toki-labs.com/toki/alt/services/worker/internal/livetrading"
)
// LiveService is the narrow interface for live broker capability discovery,
// the live order lifecycle, the risk/kill switch control surface, and account
// snapshot sync. The livetrading package provides the concrete process-local
// implementation; the narrow interface keeps the handlers testable with a fake.
type LiveService interface {
GetLiveBrokerCapabilities(ctx context.Context, accountID string) (trading.BrokerCapability, error)
SubmitLiveOrder(ctx context.Context, req livetrading.SubmitRequest) (livetrading.LiveOrder, error)
CancelLiveOrder(ctx context.Context, accountID, liveOrderID string) (livetrading.LiveOrder, error)
GetLiveOrder(ctx context.Context, accountID, liveOrderID string) (livetrading.LiveOrder, error)
GetRiskPolicy() trading.RiskPolicy
GetKillSwitch() trading.KillSwitchState
SetKillSwitch(ks trading.KillSwitchState) trading.KillSwitchState
SyncLiveAccount(ctx context.Context, accountID string) (trading.AccountSnapshot, error)
GetLiveAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error)
ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error)
}
func liveHandlers(deps Deps) []sessionHandler {
return []sessionHandler{
{
requestType: protoSocket.TypeNameOf(&altv1.SyncLiveAccountRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.SyncLiveAccountRequest, *altv1.SyncLiveAccountResponse](
&client.Communicator,
func(req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
return handleSyncLiveAccount(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveAccountSnapshotRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveAccountSnapshotRequest, *altv1.GetLiveAccountSnapshotResponse](
&client.Communicator,
func(req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
return handleGetLiveAccountSnapshot(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveBrokerCapabilitiesRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveBrokerCapabilitiesRequest, *altv1.GetLiveBrokerCapabilitiesResponse](
&client.Communicator,
func(req *altv1.GetLiveBrokerCapabilitiesRequest) (*altv1.GetLiveBrokerCapabilitiesResponse, error) {
return handleGetLiveBrokerCapabilities(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.SubmitLiveOrderRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.SubmitLiveOrderRequest, *altv1.SubmitLiveOrderResponse](
&client.Communicator,
func(req *altv1.SubmitLiveOrderRequest) (*altv1.SubmitLiveOrderResponse, error) {
return handleSubmitLiveOrder(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.CancelLiveOrderRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.CancelLiveOrderRequest, *altv1.CancelLiveOrderResponse](
&client.Communicator,
func(req *altv1.CancelLiveOrderRequest) (*altv1.CancelLiveOrderResponse, error) {
return handleCancelLiveOrder(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveOrderRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveOrderRequest, *altv1.GetLiveOrderResponse](
&client.Communicator,
func(req *altv1.GetLiveOrderRequest) (*altv1.GetLiveOrderResponse, error) {
return handleGetLiveOrder(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveRiskPolicyRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveRiskPolicyRequest, *altv1.GetLiveRiskPolicyResponse](
&client.Communicator,
func(req *altv1.GetLiveRiskPolicyRequest) (*altv1.GetLiveRiskPolicyResponse, error) {
return handleGetLiveRiskPolicy(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveKillSwitchRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveKillSwitchRequest, *altv1.GetLiveKillSwitchResponse](
&client.Communicator,
func(req *altv1.GetLiveKillSwitchRequest) (*altv1.GetLiveKillSwitchResponse, error) {
return handleGetLiveKillSwitch(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.SetLiveKillSwitchRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.SetLiveKillSwitchRequest, *altv1.SetLiveKillSwitchResponse](
&client.Communicator,
func(req *altv1.SetLiveKillSwitchRequest) (*altv1.SetLiveKillSwitchResponse, error) {
return handleSetLiveKillSwitch(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.ListLiveAuditEventsRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.ListLiveAuditEventsRequest, *altv1.ListLiveAuditEventsResponse](
&client.Communicator,
func(req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
return handleListLiveAuditEvents(deps, req)
},
)
},
},
}
}
func handleGetLiveRiskPolicy(deps Deps, req *altv1.GetLiveRiskPolicyRequest) (*altv1.GetLiveRiskPolicyResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveRiskPolicyResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_risk_policy: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.GetLiveRiskPolicyResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
p := deps.Live.GetRiskPolicy()
return &altv1.GetLiveRiskPolicyResponse{Policy: riskPolicyToProto(p)}, nil
}
func handleGetLiveKillSwitch(deps Deps, req *altv1.GetLiveKillSwitchRequest) (*altv1.GetLiveKillSwitchResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveKillSwitchResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_kill_switch: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.GetLiveKillSwitchResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ks := deps.Live.GetKillSwitch()
return &altv1.GetLiveKillSwitchResponse{State: killSwitchToProto(ks)}, nil
}
func handleSetLiveKillSwitch(deps Deps, req *altv1.SetLiveKillSwitchRequest) (*altv1.SetLiveKillSwitchResponse, error) {
if req.GetAccountId() == "" {
return &altv1.SetLiveKillSwitchResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "set_live_kill_switch: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.SetLiveKillSwitchResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
newKS := trading.KillSwitchState{
Halted: req.GetHalted(),
Reason: req.GetReason(),
}
result := deps.Live.SetKillSwitch(newKS)
return &altv1.SetLiveKillSwitchResponse{State: killSwitchToProto(result)}, nil
}
func handleGetLiveBrokerCapabilities(deps Deps, req *altv1.GetLiveBrokerCapabilitiesRequest) (*altv1.GetLiveBrokerCapabilitiesResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveBrokerCapabilitiesResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "invalid live trading request: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.GetLiveBrokerCapabilitiesResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
cap, err := deps.Live.GetLiveBrokerCapabilities(ctx, req.GetAccountId())
if err != nil {
return &altv1.GetLiveBrokerCapabilitiesResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInternal, Message: err.Error()},
}, nil
}
return &altv1.GetLiveBrokerCapabilitiesResponse{
BrokerCapabilities: liveCapabilityToProto(cap),
}, nil
}
func handleSubmitLiveOrder(deps Deps, req *altv1.SubmitLiveOrderRequest) (*altv1.SubmitLiveOrderResponse, error) {
if req.GetAccountId() == "" {
return &altv1.SubmitLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "submit_live_order: account_id is required"},
}, nil
}
if req.GetIntent() == nil {
return &altv1.SubmitLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "submit_live_order: intent is required"},
}, nil
}
if deps.Live == nil {
return &altv1.SubmitLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
domainReq, err := protoToSubmitRequest(req)
if err != nil {
return &altv1.SubmitLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: err.Error()},
}, nil
}
order, err := deps.Live.SubmitLiveOrder(ctx, domainReq)
if err != nil {
code, msg := liveOrderErrorCode(err)
resp := &altv1.SubmitLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}
// Risk-blocked submissions return both a rejected order record and the
// denial decision so headless operators can see which guard triggered.
if isLiveErr(err, livetrading.ErrRiskBlocked) && order.ID != "" {
resp.Order = liveOrderToProto(order)
resp.RiskDecision = riskDecisionToProto(trading.RiskDecision{
Allowed: false,
Reason: order.RejectionReason,
})
}
return resp, nil
}
return &altv1.SubmitLiveOrderResponse{Order: liveOrderToProto(order)}, nil
}
func handleCancelLiveOrder(deps Deps, req *altv1.CancelLiveOrderRequest) (*altv1.CancelLiveOrderResponse, error) {
if req.GetAccountId() == "" {
return &altv1.CancelLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "cancel_live_order: account_id is required"},
}, nil
}
if req.GetLiveOrderId() == "" {
return &altv1.CancelLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "cancel_live_order: live_order_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.CancelLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
order, err := deps.Live.CancelLiveOrder(ctx, req.GetAccountId(), req.GetLiveOrderId())
if err != nil {
code, msg := liveOrderErrorCode(err)
return &altv1.CancelLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}, nil
}
return &altv1.CancelLiveOrderResponse{Order: liveOrderToProto(order)}, nil
}
func handleGetLiveOrder(deps Deps, req *altv1.GetLiveOrderRequest) (*altv1.GetLiveOrderResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_order: account_id is required"},
}, nil
}
if req.GetLiveOrderId() == "" {
return &altv1.GetLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_order: live_order_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.GetLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
order, err := deps.Live.GetLiveOrder(ctx, req.GetAccountId(), req.GetLiveOrderId())
if err != nil {
code, msg := liveOrderErrorCode(err)
return &altv1.GetLiveOrderResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}, nil
}
return &altv1.GetLiveOrderResponse{Order: liveOrderToProto(order)}, nil
}
// liveOrderErrorCode maps a livetrading service error to the ALT typed error
// vocabulary so the API and CLI receive stable, discriminable codes.
func liveOrderErrorCode(err error) (code, message string) {
switch {
case isLiveErr(err, livetrading.ErrConfirmationRequired):
return backtestErrorInvalidRequest, err.Error()
case isLiveErr(err, livetrading.ErrInvalidIntent):
return backtestErrorInvalidRequest, err.Error()
case isLiveErr(err, livetrading.ErrRiskBlocked):
return backtestErrorInvalidRequest, err.Error()
case isLiveErr(err, livetrading.ErrBrokerUnavailable):
return backtestErrorUnavailable, err.Error()
case isLiveErr(err, livetrading.ErrOrderNotFound):
return backtestErrorNotFound, err.Error()
default:
return backtestErrorInternal, err.Error()
}
}
func handleSyncLiveAccount(deps Deps, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
if req.GetAccountId() == "" {
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "sync_live_account: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
snap, err := deps.Live.SyncLiveAccount(ctx, req.GetAccountId())
if err != nil {
code, msg := liveAccountErrorCode(err)
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}, nil
}
return &altv1.SyncLiveAccountResponse{Snapshot: accountSnapshotToProto(snap)}, nil
}
func handleGetLiveAccountSnapshot(deps Deps, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_account_snapshot: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
snap, err := deps.Live.GetLiveAccountSnapshot(ctx, req.GetAccountId())
if err != nil {
code, msg := liveAccountErrorCode(err)
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}, nil
}
return &altv1.GetLiveAccountSnapshotResponse{Snapshot: accountSnapshotToProto(snap)}, nil
}
func handleListLiveAuditEvents(deps Deps, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
if req.GetAccountId() == "" {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "list_live_audit_events: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
filter := protoToAuditFilter(req)
events, err := deps.Live.ListLiveAuditEvents(ctx, filter)
if err != nil {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInternal, Message: err.Error()},
}, nil
}
protoEvents := make([]*altv1.LiveAuditEvent, 0, len(events))
for _, ev := range events {
protoEvents = append(protoEvents, auditEventToProto(ev))
}
return &altv1.ListLiveAuditEventsResponse{Events: protoEvents}, nil
}
func liveAccountErrorCode(err error) (code, message string) {
switch {
case isLiveErr(err, livetrading.ErrBrokerUnavailable):
return backtestErrorUnavailable, err.Error()
case isLiveErr(err, livetrading.ErrAccountSnapshotNotFound):
return backtestErrorNotFound, err.Error()
default:
return backtestErrorInternal, err.Error()
}
}