alt/services/worker/internal/socket/paper.go
toki daa29d6807 feat: paper trading command workflow completed (G08, G09)
- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle)
- Add paper_order_lifecycle test data and expected output
- Update paper trading proto and regenerate code (Dart, Go)
- Fix order lifecycle handling in CLI operator, API socket, worker socket
- Update parser maps across CLI, API, and worker services
- Update backtest and paper trading tests
2026-06-06 11:36:50 +09:00

226 lines
9.1 KiB
Go

package socket
import (
"context"
"errors"
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/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/papertrading"
)
// PaperService is the worker-owned paper trading runtime the socket layer drives.
// The papertrading package provides the concrete process-local implementation;
// the narrow interface keeps the handlers testable with a fake.
type PaperService interface {
StartPaperTrading(ctx context.Context, req papertrading.StartRequest) (papertrading.State, error)
GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (papertrading.State, error)
// Virtual order lifecycle: submit records a pending order, cancel transitions
// a pending order, fill simulates execution against the account portfolio.
SubmitPaperOrder(ctx context.Context, req papertrading.SubmitOrderRequest) (papertrading.PaperOrder, error)
CancelPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string) (papertrading.PaperOrder, error)
FillPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string, fillPrice market.Price) (papertrading.PaperOrder, papertrading.State, error)
}
// paperHandlers returns the session handlers for the paper trading command and
// query surface, each closing over the shared dependencies.
func paperHandlers(deps Deps) []sessionHandler {
return []sessionHandler{
{
requestType: protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
&client.Communicator,
func(req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
return handleStartPaperTrading(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](
&client.Communicator,
func(req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
return handleGetPaperTradingState(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.SubmitPaperOrderRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](
&client.Communicator,
func(req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
return handleSubmitPaperOrder(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.CancelPaperOrderRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](
&client.Communicator,
func(req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
return handleCancelPaperOrder(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.FillPaperOrderRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](
&client.Communicator,
func(req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
return handleFillPaperOrder(deps, req)
},
)
},
},
}
}
func handleStartPaperTrading(deps Deps, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
startReq, err := startPaperRequestFromProto(req)
if err != nil {
return &altv1.StartPaperTradingResponse{Error: invalidPaperRequest(err.Error())}, nil
}
if deps.Paper == nil {
return &altv1.StartPaperTradingResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
state, err := deps.Paper.StartPaperTrading(ctx, startReq)
if err != nil {
return &altv1.StartPaperTradingResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.StartPaperTradingResponse{State: paperStateToProto(startReq.AccountID, state)}, nil
}
func handleGetPaperTradingState(deps Deps, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetPaperTradingStateResponse{Error: invalidPaperRequest("account_id is required")}, nil
}
if deps.Paper == nil {
return &altv1.GetPaperTradingStateResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
accountID := backtest.PaperAccountID(req.GetAccountId())
state, err := deps.Paper.GetPaperTradingState(ctx, accountID)
if err != nil {
return &altv1.GetPaperTradingStateResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.GetPaperTradingStateResponse{State: paperStateToProto(accountID, state)}, nil
}
func handleSubmitPaperOrder(deps Deps, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
submitReq, err := submitPaperOrderRequestFromProto(req)
if err != nil {
return &altv1.SubmitPaperOrderResponse{Error: invalidPaperRequest(err.Error())}, nil
}
if deps.Paper == nil {
return &altv1.SubmitPaperOrderResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
order, err := deps.Paper.SubmitPaperOrder(ctx, submitReq)
if err != nil {
return &altv1.SubmitPaperOrderResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.SubmitPaperOrderResponse{Order: paperOrderToProto(order)}, nil
}
func handleCancelPaperOrder(deps Deps, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
if req.GetAccountId() == "" {
return &altv1.CancelPaperOrderResponse{Error: invalidPaperRequest("account_id is required")}, nil
}
if req.GetOrderId() == "" {
return &altv1.CancelPaperOrderResponse{Error: invalidPaperRequest("order_id is required")}, nil
}
if deps.Paper == nil {
return &altv1.CancelPaperOrderResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
order, err := deps.Paper.CancelPaperOrder(ctx, backtest.PaperAccountID(req.GetAccountId()), req.GetOrderId())
if err != nil {
return &altv1.CancelPaperOrderResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.CancelPaperOrderResponse{Order: paperOrderToProto(order)}, nil
}
func handleFillPaperOrder(deps Deps, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
if req.GetAccountId() == "" {
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest("account_id is required")}, nil
}
if req.GetOrderId() == "" {
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest("order_id is required")}, nil
}
var fillPrice market.Price
if req.GetFillPrice() != nil {
price, err := priceFromProto(req.GetFillPrice())
if err != nil {
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest("fill_price: " + err.Error())}, nil
}
if err := papertrading.ValidateOrderDecimal("fill_price", price.Amount.Value, false); err != nil {
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest(err.Error())}, nil
}
fillPrice = price
}
if deps.Paper == nil {
return &altv1.FillPaperOrderResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
accountID := backtest.PaperAccountID(req.GetAccountId())
order, state, err := deps.Paper.FillPaperOrder(ctx, accountID, req.GetOrderId(), fillPrice)
if err != nil {
return &altv1.FillPaperOrderResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.FillPaperOrderResponse{
Order: paperOrderToProto(order),
State: paperStateToProto(accountID, state),
}, nil
}
func invalidPaperRequest(reason string) *altv1.ErrorInfo {
return errorInfo(backtestErrorInvalidRequest, "invalid paper trading request: "+reason)
}
// paperBackendErrorInfo maps paper runtime errors onto the shared typed error
// vocabulary: a missing account is not_found, a deadline is timeout, everything
// else is internal.
func paperBackendErrorInfo(err error) *altv1.ErrorInfo {
if err == nil {
return nil
}
switch {
case errors.Is(err, papertrading.ErrAccountNotFound), errors.Is(err, papertrading.ErrOrderNotFound):
return errorInfo(backtestErrorNotFound, err.Error())
case errors.Is(err, papertrading.ErrOrderNotPending), errors.Is(err, papertrading.ErrFillPriceRequired), errors.Is(err, papertrading.ErrInvalidOrderInput):
return errorInfo(backtestErrorInvalidRequest, err.Error())
case errors.Is(err, context.DeadlineExceeded):
return errorInfo(backtestErrorTimeout, err.Error())
default:
return errorInfo(backtestErrorInternal, err.Error())
}
}