alt/services/worker/internal/socket/backtest_mapping.go
toki 9d8dde6dc9 feat(domain/socket): 시장 데이터와 백테스트 소켓에 multi-timeframe 지원을 추가한다
packages/domain/market: timeframemap 타입 추가 및 테스트
services/api/internal/socket/market: multi-timeframe 소켓 메시지 지원
services/worker/internal/socket/backtest_mapping: 백테스트 매핑 함수에 multi-timeframe 반영
2026-06-17 21:34:28 +09:00

319 lines
9.5 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_MONTHLY:
return market.TimeframeMonthly, nil
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.TimeframeMonthly:
return altv1.Timeframe_TIMEFRAME_MONTHLY
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()
}