Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
209 lines
7.6 KiB
Go
209 lines
7.6 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)
|
|
}
|
|
|
|
// 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
|
|
}
|