- 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
284 lines
8.3 KiB
Go
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
|
|
}
|