alt/services/worker/internal/socket/live.go
toki c23fea2342 feat(live-trading): 실거래 risk guard를 추가한다
실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
2026-06-08 05:36:59 +09:00

307 lines
12 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, and the risk/kill switch control surface.
// 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
}
func liveHandlers(deps Deps) []sessionHandler {
return []sessionHandler{
{
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)
},
)
},
},
}
}
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()
}
}