- Add backtest proto definitions with selector and state types - Update domain types for backtest session and configuration - Implement backtest selector flow for data range input - Add CLI operator client and handoff test coverage - Update generated code for proto changes - Add worker socket backtest mapping implementations - Include agent-task for milestone tracking
315 lines
9.3 KiB
Go
315 lines
9.3 KiB
Go
package socket
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
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"
|
|
)
|
|
|
|
// backtest_mapping.go owns backtest-specific conversions plus shared scalar
|
|
// enum helpers. Keeping domain-to-proto mapping in worker socket code (and out
|
|
// of the API) honours the rail design where the worker owns persistence shapes
|
|
// while the API stays a thin pass-through.
|
|
|
|
// runSpecFromProto validates an inbound BacktestRunSpec and converts it into the
|
|
// worker-owned domain RunSpec. Validation lives next to the conversion so a
|
|
// malformed start request never reaches the runner.
|
|
func runSpecFromProto(spec *altv1.BacktestRunSpec) (backtest.RunSpec, error) {
|
|
if spec == nil {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec is required")
|
|
}
|
|
if spec.GetStrategyId() == "" {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: strategy_id is required")
|
|
}
|
|
|
|
mkt, err := marketFromProto(spec.GetMarket())
|
|
if err != nil {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: %w", err)
|
|
}
|
|
timeframe, err := timeframeFromProto(spec.GetTimeframe())
|
|
if err != nil {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: %w", err)
|
|
}
|
|
|
|
fromMs := spec.GetFromUnixMs()
|
|
toMs := spec.GetToUnixMs()
|
|
if fromMs == 0 {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: from_unix_ms is required")
|
|
}
|
|
if toMs == 0 {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: to_unix_ms is required")
|
|
}
|
|
from := time.UnixMilli(fromMs).UTC()
|
|
to := time.UnixMilli(toMs).UTC()
|
|
if from.After(to) {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: from cannot be after to")
|
|
}
|
|
|
|
var selector backtest.InputSelector
|
|
if spec.GetSelector() != nil {
|
|
pSel := spec.GetSelector()
|
|
for _, id := range pSel.GetInstrumentIds() {
|
|
trimmed := strings.TrimSpace(id)
|
|
if trimmed == "" {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: selector: instrument_id cannot be blank")
|
|
}
|
|
selector.InstrumentIDs = append(selector.InstrumentIDs, market.InstrumentID(trimmed))
|
|
}
|
|
for _, sym := range pSel.GetSymbols() {
|
|
trimmed := strings.TrimSpace(sym)
|
|
if trimmed == "" {
|
|
return backtest.RunSpec{}, fmt.Errorf("backtest spec: selector: symbol cannot be blank")
|
|
}
|
|
selector.Symbols = append(selector.Symbols, trimmed)
|
|
}
|
|
}
|
|
|
|
return backtest.RunSpec{
|
|
StrategyID: backtest.StrategyID(spec.GetStrategyId()),
|
|
Market: mkt,
|
|
Timeframe: timeframe,
|
|
From: from,
|
|
To: to,
|
|
Selector: selector,
|
|
}, nil
|
|
}
|
|
|
|
func marketFromProto(m altv1.Market) (market.Market, error) {
|
|
switch m {
|
|
case altv1.Market_MARKET_KR:
|
|
return market.MarketKR, nil
|
|
case altv1.Market_MARKET_US:
|
|
return market.MarketUS, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported market %q", m.String())
|
|
}
|
|
}
|
|
|
|
func marketToProto(m market.Market) altv1.Market {
|
|
switch m {
|
|
case market.MarketKR:
|
|
return altv1.Market_MARKET_KR
|
|
case market.MarketUS:
|
|
return altv1.Market_MARKET_US
|
|
default:
|
|
return altv1.Market_MARKET_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func timeframeFromProto(t altv1.Timeframe) (market.Timeframe, error) {
|
|
switch t {
|
|
case altv1.Timeframe_TIMEFRAME_DAILY:
|
|
return market.TimeframeDaily, nil
|
|
case altv1.Timeframe_TIMEFRAME_MINUTE_1:
|
|
return market.TimeframeMin1, nil
|
|
case altv1.Timeframe_TIMEFRAME_MINUTE_5:
|
|
return market.TimeframeMin5, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported timeframe %q", t.String())
|
|
}
|
|
}
|
|
|
|
func timeframeToProto(t market.Timeframe) altv1.Timeframe {
|
|
switch t {
|
|
case market.TimeframeDaily:
|
|
return altv1.Timeframe_TIMEFRAME_DAILY
|
|
case market.TimeframeMin1:
|
|
return altv1.Timeframe_TIMEFRAME_MINUTE_1
|
|
case market.TimeframeMin5:
|
|
return altv1.Timeframe_TIMEFRAME_MINUTE_5
|
|
default:
|
|
return altv1.Timeframe_TIMEFRAME_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
// runStatusFromProto maps a request status filter to the domain status. The
|
|
// unspecified enum maps to an empty status, which the store treats as "list all".
|
|
func runStatusFromProto(s altv1.BacktestRunStatus) backtest.RunStatus {
|
|
switch s {
|
|
case altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_PENDING:
|
|
return backtest.RunStatusPending
|
|
case altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_RUNNING:
|
|
return backtest.RunStatusRunning
|
|
case altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED:
|
|
return backtest.RunStatusSucceeded
|
|
case altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_FAILED:
|
|
return backtest.RunStatusFailed
|
|
case altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_CANCELED:
|
|
return backtest.RunStatusCanceled
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func runStatusToProto(s backtest.RunStatus) altv1.BacktestRunStatus {
|
|
switch s {
|
|
case backtest.RunStatusPending:
|
|
return altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_PENDING
|
|
case backtest.RunStatusRunning:
|
|
return altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_RUNNING
|
|
case backtest.RunStatusSucceeded:
|
|
return altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED
|
|
case backtest.RunStatusFailed:
|
|
return altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_FAILED
|
|
case backtest.RunStatusCanceled:
|
|
return altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_CANCELED
|
|
default:
|
|
return altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func runSpecToProto(spec backtest.RunSpec) *altv1.BacktestRunSpec {
|
|
var pSel *altv1.BacktestInputSelector
|
|
if len(spec.Selector.InstrumentIDs) > 0 || len(spec.Selector.Symbols) > 0 {
|
|
insts := make([]string, len(spec.Selector.InstrumentIDs))
|
|
for i, id := range spec.Selector.InstrumentIDs {
|
|
insts[i] = string(id)
|
|
}
|
|
pSel = &altv1.BacktestInputSelector{
|
|
InstrumentIds: insts,
|
|
Symbols: spec.Selector.Symbols,
|
|
}
|
|
}
|
|
return &altv1.BacktestRunSpec{
|
|
StrategyId: string(spec.StrategyID),
|
|
Market: marketToProto(spec.Market),
|
|
Timeframe: timeframeToProto(spec.Timeframe),
|
|
FromUnixMs: timeToUnixMs(spec.From),
|
|
ToUnixMs: timeToUnixMs(spec.To),
|
|
Selector: pSel,
|
|
}
|
|
}
|
|
|
|
func runToProto(run backtest.Run) *altv1.BacktestRun {
|
|
return &altv1.BacktestRun{
|
|
Id: string(run.ID),
|
|
Spec: runSpecToProto(run.Spec),
|
|
Status: runStatusToProto(run.Status),
|
|
CreatedAtUnixMs: timeToUnixMs(run.CreatedAt),
|
|
UpdatedAtUnixMs: timeToUnixMs(run.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func runsToProto(runs []backtest.Run) []*altv1.BacktestRun {
|
|
out := make([]*altv1.BacktestRun, len(runs))
|
|
for i, run := range runs {
|
|
out[i] = runToProto(run)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resultToProto(result backtest.Result) *altv1.BacktestResult {
|
|
return &altv1.BacktestResult{
|
|
RunId: string(result.RunID),
|
|
StartingCash: priceToProto(result.StartingCash),
|
|
EndingEquity: priceToProto(result.EndingEquity),
|
|
Trades: tradesToProto(result.Trades),
|
|
Positions: positionsToProto(result.Positions),
|
|
Summary: summaryToProto(result.Summary),
|
|
EquityCurve: equityCurveToProto(result.EquityCurve),
|
|
}
|
|
}
|
|
|
|
func resultsToProto(results []backtest.Result) []*altv1.BacktestResult {
|
|
out := make([]*altv1.BacktestResult, len(results))
|
|
for i, result := range results {
|
|
out[i] = resultToProto(result)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tradesToProto(trades []backtest.TradeSummary) []*altv1.BacktestTrade {
|
|
if len(trades) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*altv1.BacktestTrade, len(trades))
|
|
for i, t := range trades {
|
|
out[i] = &altv1.BacktestTrade{
|
|
InstrumentId: string(t.InstrumentID),
|
|
Side: string(t.Side),
|
|
Quantity: quantityToProto(t.Quantity),
|
|
Price: priceToProto(t.Price),
|
|
TimestampUnixMs: timeToUnixMs(t.Timestamp),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func positionsToProto(positions []backtest.PositionSummary) []*altv1.BacktestPosition {
|
|
if len(positions) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*altv1.BacktestPosition, len(positions))
|
|
for i, p := range positions {
|
|
out[i] = &altv1.BacktestPosition{
|
|
InstrumentId: string(p.InstrumentID),
|
|
Quantity: quantityToProto(p.Quantity),
|
|
LastPrice: priceToProto(p.LastPrice),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func summaryToProto(summary backtest.SummaryMetrics) *altv1.BacktestSummaryMetrics {
|
|
return &altv1.BacktestSummaryMetrics{
|
|
StartingCash: priceToProto(summary.StartingCash),
|
|
EndingEquity: priceToProto(summary.EndingEquity),
|
|
TotalReturn: decimalToProto(summary.TotalReturn),
|
|
TradeCount: int32(summary.TradeCount),
|
|
}
|
|
}
|
|
|
|
func equityCurveToProto(points []backtest.EquityPoint) []*altv1.BacktestEquityPoint {
|
|
if len(points) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*altv1.BacktestEquityPoint, len(points))
|
|
for i, p := range points {
|
|
out[i] = &altv1.BacktestEquityPoint{
|
|
TimestampUnixMs: timeToUnixMs(p.Timestamp),
|
|
Equity: priceToProto(p.Equity),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func priceToProto(price market.Price) *altv1.Price {
|
|
return &altv1.Price{
|
|
Currency: currencyToProto(price.Currency),
|
|
Amount: decimalToProto(price.Amount),
|
|
}
|
|
}
|
|
|
|
func quantityToProto(quantity market.Quantity) *altv1.Quantity {
|
|
return &altv1.Quantity{
|
|
Amount: decimalToProto(quantity.Amount),
|
|
}
|
|
}
|
|
|
|
func decimalToProto(decimal market.Decimal) *altv1.Decimal {
|
|
return &altv1.Decimal{Value: decimal.Value}
|
|
}
|
|
|
|
func currencyToProto(c market.Currency) altv1.Currency {
|
|
switch c {
|
|
case market.CurrencyKRW:
|
|
return altv1.Currency_CURRENCY_KRW
|
|
case market.CurrencyUSD:
|
|
return altv1.Currency_CURRENCY_USD
|
|
default:
|
|
return altv1.Currency_CURRENCY_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
// timeToUnixMs converts a domain timestamp to epoch milliseconds. A zero time
|
|
// maps to 0 so empty/unset timestamps stay unambiguous on the wire.
|
|
func timeToUnixMs(t time.Time) int64 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
return t.UTC().UnixMilli()
|
|
}
|