Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
282 lines
10 KiB
Go
282 lines
10 KiB
Go
package socket
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
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/services/worker/internal/marketdata/importer"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
|
|
)
|
|
|
|
// handlerTimeout bounds a single worker-side backtest request. Execution itself
|
|
// is detached through the starter, so this only guards the synchronous record
|
|
// and read work against a wedged store call.
|
|
const handlerTimeout = 10 * time.Second
|
|
|
|
const (
|
|
backtestErrorInvalidRequest = "invalid_request"
|
|
backtestErrorUnavailable = "unavailable"
|
|
backtestErrorNotFound = "not_found"
|
|
backtestErrorTimeout = "timeout"
|
|
backtestErrorInternal = "internal"
|
|
)
|
|
|
|
// BacktestStarter records a pending run and schedules its execution. The worker
|
|
// jobs package provides the concrete implementation; the interface keeps the
|
|
// socket layer testable with a fake.
|
|
type BacktestStarter interface {
|
|
StartBacktest(ctx context.Context, spec backtest.RunSpec) (backtest.Run, error)
|
|
}
|
|
|
|
// DailyBarImporter runs a daily bar import command. The marketdata importer
|
|
// provides the concrete implementation; the narrow interface keeps the socket
|
|
// handler testable with a fake and decoupled from provider/storage wiring.
|
|
type DailyBarImporter interface {
|
|
ImportDailyBars(ctx context.Context, request importer.DailyBarRequest) (importer.Result, error)
|
|
}
|
|
|
|
// Deps bundles the worker-owned capabilities socket handlers need. A nil field
|
|
// disables only its related handler surface so the server can still run a
|
|
// reduced surface (e.g. hello-only) without panicking.
|
|
type Deps struct {
|
|
Starter BacktestStarter
|
|
Analysis storage.BacktestAnalysisStore
|
|
Results storage.BacktestResultStore
|
|
Instruments storage.InstrumentStore
|
|
Bars storage.BarStore
|
|
DailyBarImporter DailyBarImporter
|
|
Paper PaperService
|
|
}
|
|
|
|
// BacktestDeps is retained for existing call sites while the socket dependency
|
|
// bundle grows beyond backtest into market reads.
|
|
type BacktestDeps = Deps
|
|
|
|
// backtestHandlers returns the session handlers for the backtest command and
|
|
// query surface, each closing over the shared dependencies.
|
|
func backtestHandlers(deps Deps) []sessionHandler {
|
|
return []sessionHandler{
|
|
{
|
|
requestType: protoSocket.TypeNameOf(&altv1.StartBacktestRequest{}),
|
|
register: func(client *protoSocket.WsClient) {
|
|
protoSocket.AddRequestListenerTyped[*altv1.StartBacktestRequest, *altv1.StartBacktestResponse](
|
|
&client.Communicator,
|
|
func(req *altv1.StartBacktestRequest) (*altv1.StartBacktestResponse, error) {
|
|
return handleStartBacktest(deps, req)
|
|
},
|
|
)
|
|
},
|
|
},
|
|
{
|
|
requestType: protoSocket.TypeNameOf(&altv1.GetBacktestRunRequest{}),
|
|
register: func(client *protoSocket.WsClient) {
|
|
protoSocket.AddRequestListenerTyped[*altv1.GetBacktestRunRequest, *altv1.GetBacktestRunResponse](
|
|
&client.Communicator,
|
|
func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error) {
|
|
return handleGetBacktestRun(deps, req)
|
|
},
|
|
)
|
|
},
|
|
},
|
|
{
|
|
requestType: protoSocket.TypeNameOf(&altv1.ListBacktestRunsRequest{}),
|
|
register: func(client *protoSocket.WsClient) {
|
|
protoSocket.AddRequestListenerTyped[*altv1.ListBacktestRunsRequest, *altv1.ListBacktestRunsResponse](
|
|
&client.Communicator,
|
|
func(req *altv1.ListBacktestRunsRequest) (*altv1.ListBacktestRunsResponse, error) {
|
|
return handleListBacktestRuns(deps, req)
|
|
},
|
|
)
|
|
},
|
|
},
|
|
{
|
|
requestType: protoSocket.TypeNameOf(&altv1.GetBacktestRunDetailRequest{}),
|
|
register: func(client *protoSocket.WsClient) {
|
|
protoSocket.AddRequestListenerTyped[*altv1.GetBacktestRunDetailRequest, *altv1.GetBacktestRunDetailResponse](
|
|
&client.Communicator,
|
|
func(req *altv1.GetBacktestRunDetailRequest) (*altv1.GetBacktestRunDetailResponse, error) {
|
|
return handleGetBacktestRunDetail(deps, req)
|
|
},
|
|
)
|
|
},
|
|
},
|
|
{
|
|
requestType: protoSocket.TypeNameOf(&altv1.GetBacktestResultRequest{}),
|
|
register: func(client *protoSocket.WsClient) {
|
|
protoSocket.AddRequestListenerTyped[*altv1.GetBacktestResultRequest, *altv1.GetBacktestResultResponse](
|
|
&client.Communicator,
|
|
func(req *altv1.GetBacktestResultRequest) (*altv1.GetBacktestResultResponse, error) {
|
|
return handleGetBacktestResult(deps, req)
|
|
},
|
|
)
|
|
},
|
|
},
|
|
{
|
|
requestType: protoSocket.TypeNameOf(&altv1.CompareBacktestRunsRequest{}),
|
|
register: func(client *protoSocket.WsClient) {
|
|
protoSocket.AddRequestListenerTyped[*altv1.CompareBacktestRunsRequest, *altv1.CompareBacktestRunsResponse](
|
|
&client.Communicator,
|
|
func(req *altv1.CompareBacktestRunsRequest) (*altv1.CompareBacktestRunsResponse, error) {
|
|
return handleCompareBacktestRuns(deps, req)
|
|
},
|
|
)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func handleStartBacktest(deps Deps, req *altv1.StartBacktestRequest) (*altv1.StartBacktestResponse, error) {
|
|
spec, err := runSpecFromProto(req.GetSpec())
|
|
if err != nil {
|
|
return &altv1.StartBacktestResponse{Error: invalidRequest(err.Error())}, nil
|
|
}
|
|
if deps.Starter == nil {
|
|
return &altv1.StartBacktestResponse{Error: unavailableError("backtest start is not available")}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
|
defer cancel()
|
|
|
|
run, err := deps.Starter.StartBacktest(ctx, spec)
|
|
if err != nil {
|
|
return &altv1.StartBacktestResponse{Error: backendErrorInfo(err)}, nil
|
|
}
|
|
return &altv1.StartBacktestResponse{Run: runToProto(run)}, nil
|
|
}
|
|
|
|
// handleGetBacktestRun answers a single-run poll. It reads through the analysis
|
|
// store's run detail so polling reuses the same store-backed read path as
|
|
// list/detail instead of growing a second run-store dependency on Deps.
|
|
func handleGetBacktestRun(deps Deps, req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error) {
|
|
if req.GetRunId() == "" {
|
|
return &altv1.GetBacktestRunResponse{Error: invalidRequest("run_id is required")}, nil
|
|
}
|
|
if deps.Analysis == nil {
|
|
return &altv1.GetBacktestRunResponse{Error: unavailableError("backtest analysis is not available")}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
|
defer cancel()
|
|
|
|
detail, err := deps.Analysis.GetRunDetail(ctx, backtest.RunID(req.GetRunId()))
|
|
if err != nil {
|
|
return &altv1.GetBacktestRunResponse{Error: backendErrorInfo(err)}, nil
|
|
}
|
|
return &altv1.GetBacktestRunResponse{Run: runToProto(detail.Run)}, nil
|
|
}
|
|
|
|
func handleListBacktestRuns(deps Deps, req *altv1.ListBacktestRunsRequest) (*altv1.ListBacktestRunsResponse, error) {
|
|
if deps.Analysis == nil {
|
|
return &altv1.ListBacktestRunsResponse{Error: unavailableError("backtest analysis is not available")}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
|
defer cancel()
|
|
|
|
runs, err := deps.Analysis.ListRuns(ctx, runStatusFromProto(req.GetStatus()))
|
|
if err != nil {
|
|
return &altv1.ListBacktestRunsResponse{Error: backendErrorInfo(err)}, nil
|
|
}
|
|
return &altv1.ListBacktestRunsResponse{Runs: runsToProto(runs)}, nil
|
|
}
|
|
|
|
func handleGetBacktestRunDetail(deps Deps, req *altv1.GetBacktestRunDetailRequest) (*altv1.GetBacktestRunDetailResponse, error) {
|
|
if req.GetRunId() == "" {
|
|
return &altv1.GetBacktestRunDetailResponse{Error: invalidRequest("run_id is required")}, nil
|
|
}
|
|
if deps.Analysis == nil {
|
|
return &altv1.GetBacktestRunDetailResponse{Error: unavailableError("backtest analysis is not available")}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
|
defer cancel()
|
|
|
|
detail, err := deps.Analysis.GetRunDetail(ctx, backtest.RunID(req.GetRunId()))
|
|
if err != nil {
|
|
return &altv1.GetBacktestRunDetailResponse{Error: backendErrorInfo(err)}, nil
|
|
}
|
|
|
|
resp := &altv1.GetBacktestRunDetailResponse{Run: runToProto(detail.Run)}
|
|
// A run can exist before it produces a result; leave the result field nil in
|
|
// that case so callers can distinguish "no result yet" from an empty result.
|
|
if detail.HasResult {
|
|
resp.Result = resultToProto(detail.Result)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func handleGetBacktestResult(deps Deps, req *altv1.GetBacktestResultRequest) (*altv1.GetBacktestResultResponse, error) {
|
|
if req.GetRunId() == "" {
|
|
return &altv1.GetBacktestResultResponse{Error: invalidRequest("run_id is required")}, nil
|
|
}
|
|
if deps.Results == nil {
|
|
return &altv1.GetBacktestResultResponse{Error: unavailableError("backtest results are not available")}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
|
defer cancel()
|
|
|
|
result, err := deps.Results.GetResult(ctx, backtest.RunID(req.GetRunId()))
|
|
if err != nil {
|
|
return &altv1.GetBacktestResultResponse{Error: backendErrorInfo(err)}, nil
|
|
}
|
|
return &altv1.GetBacktestResultResponse{Result: resultToProto(result)}, nil
|
|
}
|
|
|
|
func handleCompareBacktestRuns(deps Deps, req *altv1.CompareBacktestRunsRequest) (*altv1.CompareBacktestRunsResponse, error) {
|
|
ids := make([]backtest.RunID, 0, len(req.GetRunIds()))
|
|
for _, id := range req.GetRunIds() {
|
|
if id == "" {
|
|
return &altv1.CompareBacktestRunsResponse{Error: invalidRequest("run_ids must not contain empty values")}, nil
|
|
}
|
|
ids = append(ids, backtest.RunID(id))
|
|
}
|
|
// An empty compare request is a no-op rather than an error: the response just
|
|
// carries an empty result list.
|
|
if len(ids) == 0 {
|
|
return &altv1.CompareBacktestRunsResponse{}, nil
|
|
}
|
|
if deps.Analysis == nil {
|
|
return &altv1.CompareBacktestRunsResponse{Error: unavailableError("backtest analysis is not available")}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
|
defer cancel()
|
|
|
|
results, err := deps.Analysis.CompareResults(ctx, ids)
|
|
if err != nil {
|
|
return &altv1.CompareBacktestRunsResponse{Error: backendErrorInfo(err)}, nil
|
|
}
|
|
return &altv1.CompareBacktestRunsResponse{Results: resultsToProto(results)}, nil
|
|
}
|
|
|
|
func invalidRequest(reason string) *altv1.ErrorInfo {
|
|
return errorInfo(backtestErrorInvalidRequest, "invalid backtest request: "+reason)
|
|
}
|
|
|
|
func unavailableError(message string) *altv1.ErrorInfo {
|
|
return errorInfo(backtestErrorUnavailable, message)
|
|
}
|
|
|
|
func backendErrorInfo(err error) *altv1.ErrorInfo {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, storage.ErrRunNotFound), errors.Is(err, storage.ErrResultNotFound):
|
|
return errorInfo(backtestErrorNotFound, err.Error())
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
return errorInfo(backtestErrorTimeout, err.Error())
|
|
default:
|
|
return errorInfo(backtestErrorInternal, err.Error())
|
|
}
|
|
}
|
|
|
|
func errorInfo(code, message string) *altv1.ErrorInfo {
|
|
return &altv1.ErrorInfo{Code: code, Message: message}
|
|
}
|