alt/services/worker/internal/storage/postgres/mapping.go
toki 38b68315fc feat: backtest engine baseline implementation
- Add backtest proto definitions and generated code
- Update domain types for backtest results and fixtures
- Add PostgreSQL migrations for backtest tables
- Implement storage layer for backtest result persistence
- Add backtest job definitions and execution pipeline
- Remove obsolete agent-task documents for completed items
2026-05-30 12:13:45 +09:00

284 lines
8.3 KiB
Go

package postgres
import (
"encoding/json"
"fmt"
"strconv"
"github.com/jackc/pgx/v5/pgtype"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc"
)
func validateDecimal(val string) error {
if val == "" {
return fmt.Errorf("empty decimal value")
}
if _, err := strconv.ParseFloat(val, 64); err != nil {
return fmt.Errorf("invalid decimal format: %s", val)
}
return nil
}
func stringToNumeric(val string) (pgtype.Numeric, error) {
var num pgtype.Numeric
if err := validateDecimal(val); err != nil {
return num, err
}
if err := num.Scan(val); err != nil {
return num, fmt.Errorf("failed to scan numeric: %w", err)
}
return num, nil
}
func numericToString(num pgtype.Numeric) (string, error) {
if !num.Valid {
return "0", nil
}
val, err := num.Value()
if err != nil {
return "", err
}
switch v := val.(type) {
case string:
return v, nil
case []byte:
return string(v), nil
default:
return fmt.Sprintf("%v", v), nil
}
}
func mapInstrumentToParams(inst market.Instrument) (sqlc.UpsertInstrumentParams, error) {
provBytes, err := json.Marshal(inst.ProviderSymbols)
if err != nil {
return sqlc.UpsertInstrumentParams{}, fmt.Errorf("failed to marshal provider symbols: %w", err)
}
if provBytes == nil {
provBytes = []byte("{}")
}
return sqlc.UpsertInstrumentParams{
ID: string(inst.ID),
Market: string(inst.Market),
Venue: string(inst.Venue),
Symbol: inst.Symbol,
Name: inst.Name,
Currency: string(inst.Currency),
ProviderSymbols: provBytes,
}, nil
}
func mapRowToInstrument(row sqlc.Instrument) (market.Instrument, error) {
var provSymbols map[string]string
if len(row.ProviderSymbols) > 0 {
if err := json.Unmarshal(row.ProviderSymbols, &provSymbols); err != nil {
return market.Instrument{}, fmt.Errorf("failed to unmarshal provider symbols: %w", err)
}
}
if provSymbols == nil {
provSymbols = make(map[string]string)
}
return market.Instrument{
ID: market.InstrumentID(row.ID),
Market: market.Market(row.Market),
Venue: market.Venue(row.Venue),
Symbol: row.Symbol,
Name: row.Name,
Currency: market.Currency(row.Currency),
ProviderSymbols: provSymbols,
}, nil
}
func mapBarToParams(bar market.Bar) (sqlc.UpsertBarParams, error) {
open, err := stringToNumeric(bar.Open.Amount.Value)
if err != nil {
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid open amount: %w", err)
}
high, err := stringToNumeric(bar.High.Amount.Value)
if err != nil {
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid high amount: %w", err)
}
low, err := stringToNumeric(bar.Low.Amount.Value)
if err != nil {
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid low amount: %w", err)
}
closePrice, err := stringToNumeric(bar.Close.Amount.Value)
if err != nil {
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid close amount: %w", err)
}
volume, err := stringToNumeric(bar.Volume.Amount.Value)
if err != nil {
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid volume amount: %w", err)
}
return sqlc.UpsertBarParams{
InstrumentID: string(bar.InstrumentID),
Timeframe: string(bar.Timeframe),
Timestamp: pgtype.Timestamptz{Time: bar.Timestamp, Valid: !bar.Timestamp.IsZero()},
Open: open,
High: high,
Low: low,
Close: closePrice,
Volume: volume,
}, nil
}
func mapRowToBar(row sqlc.Bar, currency market.Currency) (market.Bar, error) {
openStr, err := numericToString(row.Open)
if err != nil {
return market.Bar{}, err
}
highStr, err := numericToString(row.High)
if err != nil {
return market.Bar{}, err
}
lowStr, err := numericToString(row.Low)
if err != nil {
return market.Bar{}, err
}
closeStr, err := numericToString(row.Close)
if err != nil {
return market.Bar{}, err
}
volumeStr, err := numericToString(row.Volume)
if err != nil {
return market.Bar{}, err
}
return market.Bar{
InstrumentID: market.InstrumentID(row.InstrumentID),
Timeframe: market.Timeframe(row.Timeframe),
Timestamp: row.Timestamp.Time,
Open: market.Price{
Currency: currency,
Amount: market.Decimal{Value: openStr},
},
High: market.Price{
Currency: currency,
Amount: market.Decimal{Value: highStr},
},
Low: market.Price{
Currency: currency,
Amount: market.Decimal{Value: lowStr},
},
Close: market.Price{
Currency: currency,
Amount: market.Decimal{Value: closeStr},
},
Volume: market.Quantity{
Amount: market.Decimal{Value: volumeStr},
},
}, nil
}
func mapRunToParams(run backtest.Run) (sqlc.UpsertRunParams, error) {
return sqlc.UpsertRunParams{
ID: string(run.ID),
StrategyID: string(run.Spec.StrategyID),
Market: string(run.Spec.Market),
Timeframe: string(run.Spec.Timeframe),
FromTime: pgtype.Timestamptz{Time: run.Spec.From, Valid: !run.Spec.From.IsZero()},
ToTime: pgtype.Timestamptz{Time: run.Spec.To, Valid: !run.Spec.To.IsZero()},
Status: string(run.Status),
CreatedAt: pgtype.Timestamptz{Time: run.CreatedAt, Valid: !run.CreatedAt.IsZero()},
UpdatedAt: pgtype.Timestamptz{Time: run.UpdatedAt, Valid: !run.UpdatedAt.IsZero()},
}, nil
}
func mapRowToRun(row sqlc.BacktestRun) (backtest.Run, error) {
return backtest.Run{
ID: backtest.RunID(row.ID),
Spec: backtest.RunSpec{
StrategyID: backtest.StrategyID(row.StrategyID),
Market: market.Market(row.Market),
Timeframe: market.Timeframe(row.Timeframe),
From: row.FromTime.Time,
To: row.ToTime.Time,
},
Status: backtest.RunStatus(row.Status),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}, nil
}
func mapResultToParams(res backtest.Result) (sqlc.UpsertResultParams, error) {
startingCashAmount, err := stringToNumeric(res.StartingCash.Amount.Value)
if err != nil {
return sqlc.UpsertResultParams{}, fmt.Errorf("invalid starting cash amount: %w", err)
}
endingEquityAmount, err := stringToNumeric(res.EndingEquity.Amount.Value)
if err != nil {
return sqlc.UpsertResultParams{}, fmt.Errorf("invalid ending equity amount: %w", err)
}
var tradesBytes []byte
if res.Trades == nil {
tradesBytes = []byte("[]")
} else {
tradesBytes, err = json.Marshal(res.Trades)
if err != nil {
return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal trades: %w", err)
}
}
var positionsBytes []byte
if res.Positions == nil {
positionsBytes = []byte("[]")
} else {
positionsBytes, err = json.Marshal(res.Positions)
if err != nil {
return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal positions: %w", err)
}
}
return sqlc.UpsertResultParams{
RunID: string(res.RunID),
StartingCashCurrency: string(res.StartingCash.Currency),
StartingCashAmount: startingCashAmount,
EndingEquityCurrency: string(res.EndingEquity.Currency),
EndingEquityAmount: endingEquityAmount,
Trades: tradesBytes,
Positions: positionsBytes,
}, nil
}
func mapRowToResult(row sqlc.BacktestResult) (backtest.Result, error) {
startingCashAmountStr, err := numericToString(row.StartingCashAmount)
if err != nil {
return backtest.Result{}, fmt.Errorf("failed to parse starting cash amount: %w", err)
}
endingEquityAmountStr, err := numericToString(row.EndingEquityAmount)
if err != nil {
return backtest.Result{}, fmt.Errorf("failed to parse ending equity amount: %w", err)
}
var trades []backtest.TradeSummary
if len(row.Trades) > 0 {
if err := json.Unmarshal(row.Trades, &trades); err != nil {
return backtest.Result{}, fmt.Errorf("failed to unmarshal trades: %w", err)
}
}
var positions []backtest.PositionSummary
if len(row.Positions) > 0 {
if err := json.Unmarshal(row.Positions, &positions); err != nil {
return backtest.Result{}, fmt.Errorf("failed to unmarshal positions: %w", err)
}
}
return backtest.Result{
RunID: backtest.RunID(row.RunID),
StartingCash: market.Price{
Currency: market.Currency(row.StartingCashCurrency),
Amount: market.Decimal{Value: startingCashAmountStr},
},
EndingEquity: market.Price{
Currency: market.Currency(row.EndingEquityCurrency),
Amount: market.Decimal{Value: endingEquityAmountStr},
},
Trades: trades,
Positions: positions,
}, nil
}