- 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
224 lines
8.4 KiB
Go
224 lines
8.4 KiB
Go
package operator
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
|
protoSocket "git.toki-labs.com/toki/proto-socket/go"
|
|
)
|
|
|
|
// ErrTransport marks a connect/disconnect/timeout failure that happens before a
|
|
// typed ALT response is received. The runner maps it to the transport exit code
|
|
// so scripts can tell a dead socket apart from a typed application error.
|
|
var ErrTransport = errors.New("api transport error")
|
|
|
|
// APIClient is a thin proto-socket client for the ALT API control plane. It
|
|
// dials the same ws/wss rail the Flutter client and worker client use, with the
|
|
// CLI-local ParserMap so the operator runner can issue hello and market reads
|
|
// without importing the API's internal packages.
|
|
type APIClient struct {
|
|
ws *protoSocket.WsClient
|
|
}
|
|
|
|
// Dial connects to the API at apiURL (ws:// or wss://). A connection failure is
|
|
// returned wrapped in ErrTransport so callers can distinguish it from a typed
|
|
// ErrorInfo response.
|
|
func Dial(ctx context.Context, apiURL string) (*APIClient, error) {
|
|
host, port, path, secure, err := parseAPIURL(apiURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ws *protoSocket.WsClient
|
|
if secure {
|
|
ws, err = protoSocket.DialWss(ctx, host, port, path, nil, ParserMap())
|
|
} else {
|
|
ws, err = protoSocket.DialWs(ctx, host, port, path, ParserMap())
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTransport, err)
|
|
}
|
|
return &APIClient{ws: ws}, nil
|
|
}
|
|
|
|
// parseAPIURL splits an ws/wss URL into dial parameters, mirroring the worker
|
|
// client's parsing (services/api/internal/workerclient/client.go): default port
|
|
// 443 for wss and 80 for ws, and a "/" path when none is given.
|
|
func parseAPIURL(apiURL string) (host string, port int, path string, secure bool, err error) {
|
|
u, err := url.Parse(apiURL)
|
|
if err != nil {
|
|
return "", 0, "", false, fmt.Errorf("invalid api URL %q: %w", apiURL, err)
|
|
}
|
|
if u.Scheme != "ws" && u.Scheme != "wss" {
|
|
return "", 0, "", false, fmt.Errorf("api URL %q must use ws:// or wss://", apiURL)
|
|
}
|
|
secure = u.Scheme == "wss"
|
|
|
|
host, portStr, splitErr := net.SplitHostPort(u.Host)
|
|
if splitErr != nil {
|
|
host = u.Host
|
|
if secure {
|
|
portStr = "443"
|
|
} else {
|
|
portStr = "80"
|
|
}
|
|
}
|
|
if host == "" {
|
|
return "", 0, "", false, fmt.Errorf("api URL %q is missing a host", apiURL)
|
|
}
|
|
|
|
port, err = strconv.Atoi(portStr)
|
|
if err != nil {
|
|
return "", 0, "", false, fmt.Errorf("invalid api port %q: %w", portStr, err)
|
|
}
|
|
|
|
path = u.Path
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
return host, port, path, secure, nil
|
|
}
|
|
|
|
// Close releases the underlying socket. It is safe to call on a nil-socket
|
|
// client.
|
|
func (c *APIClient) Close() error {
|
|
if c == nil || c.ws == nil {
|
|
return nil
|
|
}
|
|
return c.ws.Close()
|
|
}
|
|
|
|
// Hello performs the API handshake.
|
|
func (c *APIClient) Hello(ctx context.Context, req *altv1.HelloRequest) (*altv1.HelloResponse, error) {
|
|
return sendTyped[*altv1.HelloRequest, *altv1.HelloResponse](c, ctx, req)
|
|
}
|
|
|
|
// ListInstruments issues a market instrument list query.
|
|
func (c *APIClient) ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
|
|
return sendTyped[*altv1.ListInstrumentsRequest, *altv1.ListInstrumentsResponse](c, ctx, req)
|
|
}
|
|
|
|
// ListBars issues a market bar list query.
|
|
func (c *APIClient) ListBars(ctx context.Context, req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
|
|
return sendTyped[*altv1.ListBarsRequest, *altv1.ListBarsResponse](c, ctx, req)
|
|
}
|
|
|
|
// StartBacktest starts a new backtest run.
|
|
func (c *APIClient) StartBacktest(ctx context.Context, req *altv1.StartBacktestRequest) (*altv1.StartBacktestResponse, error) {
|
|
return sendTyped[*altv1.StartBacktestRequest, *altv1.StartBacktestResponse](c, ctx, req)
|
|
}
|
|
|
|
// GetBacktestRun retrieves backtest run status/info.
|
|
func (c *APIClient) GetBacktestRun(ctx context.Context, req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error) {
|
|
return sendTyped[*altv1.GetBacktestRunRequest, *altv1.GetBacktestRunResponse](c, ctx, req)
|
|
}
|
|
|
|
// ListBacktestRuns lists backtest runs.
|
|
func (c *APIClient) ListBacktestRuns(ctx context.Context, req *altv1.ListBacktestRunsRequest) (*altv1.ListBacktestRunsResponse, error) {
|
|
return sendTyped[*altv1.ListBacktestRunsRequest, *altv1.ListBacktestRunsResponse](c, ctx, req)
|
|
}
|
|
|
|
// GetBacktestRunDetail gets detailed info (run + result) for a backtest run.
|
|
func (c *APIClient) GetBacktestRunDetail(ctx context.Context, req *altv1.GetBacktestRunDetailRequest) (*altv1.GetBacktestRunDetailResponse, error) {
|
|
return sendTyped[*altv1.GetBacktestRunDetailRequest, *altv1.GetBacktestRunDetailResponse](c, ctx, req)
|
|
}
|
|
|
|
// GetBacktestResult gets backtest summary, trades, etc.
|
|
func (c *APIClient) GetBacktestResult(ctx context.Context, req *altv1.GetBacktestResultRequest) (*altv1.GetBacktestResultResponse, error) {
|
|
return sendTyped[*altv1.GetBacktestResultRequest, *altv1.GetBacktestResultResponse](c, ctx, req)
|
|
}
|
|
|
|
// CompareBacktestRuns compares multiple backtest runs.
|
|
func (c *APIClient) CompareBacktestRuns(ctx context.Context, req *altv1.CompareBacktestRunsRequest) (*altv1.CompareBacktestRunsResponse, error) {
|
|
return sendTyped[*altv1.CompareBacktestRunsRequest, *altv1.CompareBacktestRunsResponse](c, ctx, req)
|
|
}
|
|
|
|
// ImportDailyBars requests the import of KIS daily bars into the worker storage.
|
|
func (c *APIClient) ImportDailyBars(ctx context.Context, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) {
|
|
return sendTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](c, ctx, req)
|
|
}
|
|
|
|
// StartPaperTrading starts a paper trading account run.
|
|
func (c *APIClient) StartPaperTrading(ctx context.Context, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
|
|
return sendTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](c, ctx, req)
|
|
}
|
|
|
|
// GetPaperTradingState reads a paper trading account state.
|
|
func (c *APIClient) GetPaperTradingState(ctx context.Context, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
|
|
return sendTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](c, ctx, req)
|
|
}
|
|
|
|
// SubmitPaperOrder submits a virtual paper order.
|
|
func (c *APIClient) SubmitPaperOrder(ctx context.Context, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
|
return sendTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](c, ctx, req)
|
|
}
|
|
|
|
// CancelPaperOrder cancels a pending virtual paper order.
|
|
func (c *APIClient) CancelPaperOrder(ctx context.Context, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
|
return sendTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](c, ctx, req)
|
|
}
|
|
|
|
// FillPaperOrder simulates the fill of a pending virtual paper order.
|
|
func (c *APIClient) FillPaperOrder(ctx context.Context, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
|
return sendTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](c, ctx, req)
|
|
}
|
|
|
|
// sendTyped is the shared request path for every API call. It derives the
|
|
// request timeout from the context deadline and maps a dropped connection to
|
|
// ErrTransport so each method stays a one-line forwarder and the transport vs.
|
|
// typed-error distinction cannot drift between them.
|
|
func sendTyped[Req proto.Message, Res proto.Message](c *APIClient, ctx context.Context, req Req) (Res, error) {
|
|
var zero Res
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return zero, mapContextError(err)
|
|
}
|
|
if c == nil || c.ws == nil || !c.ws.IsAlive() {
|
|
return zero, ErrTransport
|
|
}
|
|
|
|
timeout := 5 * time.Second
|
|
if dl, ok := ctx.Deadline(); ok {
|
|
timeout = time.Until(dl)
|
|
if timeout <= 0 {
|
|
return zero, fmt.Errorf("%w: deadline already exceeded", ErrTransport)
|
|
}
|
|
}
|
|
|
|
type result struct {
|
|
res Res
|
|
err error
|
|
}
|
|
ch := make(chan result, 1)
|
|
go func() {
|
|
res, err := protoSocket.SendRequestTyped[Req, Res](&c.ws.Communicator, req, timeout)
|
|
ch <- result{res: res, err: err}
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return zero, mapContextError(ctx.Err())
|
|
case r := <-ch:
|
|
if r.err != nil {
|
|
return zero, fmt.Errorf("%w: %v", ErrTransport, r.err)
|
|
}
|
|
return r.res, nil
|
|
}
|
|
}
|
|
|
|
// mapContextError normalises context errors: cancellation propagates as-is,
|
|
// deadlines surface as ErrTransport because the typed response never arrived.
|
|
func mapContextError(err error) error {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return fmt.Errorf("%w: %v", ErrTransport, err)
|
|
}
|
|
return err
|
|
}
|