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) } } // Summary defaults to "0" total return when callers persist a result without a // computed summary so the NOT NULL numeric column never receives an empty value. totalReturnValue := res.Summary.TotalReturn.Value if totalReturnValue == "" { totalReturnValue = "0" } totalReturn, err := stringToNumeric(totalReturnValue) if err != nil { return sqlc.UpsertResultParams{}, fmt.Errorf("invalid total return: %w", err) } var equityCurveBytes []byte if res.EquityCurve == nil { equityCurveBytes = []byte("[]") } else { equityCurveBytes, err = json.Marshal(res.EquityCurve) if err != nil { return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal equity curve: %w", err) } } return sqlc.UpsertResultParams{ RunID: string(res.RunID), StartingCashCurrency: string(res.StartingCash.Currency), StartingCashAmount: startingCashAmount, EndingEquityCurrency: string(res.EndingEquity.Currency), EndingEquityAmount: endingEquityAmount, TotalReturn: totalReturn, TradeCount: int32(res.Summary.TradeCount), Trades: tradesBytes, Positions: positionsBytes, EquityCurve: equityCurveBytes, }, 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) } } totalReturnStr, err := numericToString(row.TotalReturn) if err != nil { return backtest.Result{}, fmt.Errorf("failed to parse total return: %w", err) } var equityCurve []backtest.EquityPoint if len(row.EquityCurve) > 0 { if err := json.Unmarshal(row.EquityCurve, &equityCurve); err != nil { return backtest.Result{}, fmt.Errorf("failed to unmarshal equity curve: %w", err) } } 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}, } return backtest.Result{ RunID: backtest.RunID(row.RunID), StartingCash: startingCash, EndingEquity: endingEquity, Trades: trades, Positions: positions, Summary: backtest.SummaryMetrics{ StartingCash: startingCash, EndingEquity: endingEquity, TotalReturn: market.Decimal{Value: totalReturnStr}, TradeCount: int(row.TradeCount), }, EquityCurve: equityCurve, }, nil }