Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
428 lines
16 KiB
Go
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()
|
|
}
|
|
}
|