//! Storage layer for backtesting data persistence use anyhow::{Context, Result}; use rust_decimal::{prelude::ToPrimitive, Decimal}; use sqlx::{PgPool, Row}; use tracing::info; use crate::foxhunt::tli::BacktestStatus; use crate::performance::PerformanceMetrics; use crate::strategy_engine::BacktestTrade; use common::database::DatabasePool; use config::structures::BacktestingDatabaseConfig; /// Backtest summary for listing #[derive(Debug, Clone)] pub struct BacktestSummary { /// Backtest ID pub backtest_id: String, /// Strategy name pub strategy_name: String, /// Symbols tested pub symbols: Vec, /// Current status pub status: BacktestStatus, /// Total return percentage pub total_return: f64, /// Sharpe ratio pub sharpe_ratio: f64, /// Maximum drawdown percentage pub max_drawdown: f64, /// Creation timestamp pub created_at: chrono::DateTime, /// Start date of backtest pub start_date: chrono::DateTime, /// End date of backtest pub end_date: chrono::DateTime, /// Description pub description: String, } /// Storage manager for backtesting data #[derive(Debug)] pub struct StorageManager { /// Raw PgPool for compatibility with existing queries pg_pool: PgPool, } impl StorageManager { /// Create a new storage manager pub async fn new(config: &BacktestingDatabaseConfig) -> Result { info!("Initializing storage manager with HFT optimizations"); // Create backtesting-optimized database pool using config conversion // The From implementation handles all field mapping automatically let local_db_config: common::database::LocalDatabaseConfig = config.clone().into(); let db_pool = DatabasePool::new(local_db_config) .await .context("Failed to create HFT-optimized database pool")?; let pg_pool = db_pool.pool().clone(); Ok(Self { pg_pool, }) } /// Save backtest results to storage pub async fn save_backtest_results( &self, backtest_id: &str, trades: &[BacktestTrade], metrics: &PerformanceMetrics, ) -> Result<()> { info!("Saving backtest results for {}", backtest_id); let mut tx = self.pg_pool.begin().await?; // Save individual trades for trade in trades { sqlx::query( r#" INSERT INTO backtest_trades ( backtest_id, trade_id, symbol, side, quantity, entry_price, exit_price, entry_time, exit_time, pnl, return_percent, entry_signal, exit_signal ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) "#, ) .bind(backtest_id) .bind(&trade.trade_id) .bind(&trade.symbol) .bind(trade.side.to_string()) .bind(trade.quantity.to_f64()) .bind(trade.entry_price.to_f64()) .bind(trade.exit_price.to_f64()) .bind(trade.entry_time) .bind(trade.exit_time) .bind(trade.pnl.to_f64()) .bind(trade.return_percent.to_f64()) .bind(&trade.entry_signal) .bind(&trade.exit_signal) .execute(&mut *tx) .await?; } // Save detailed performance metrics sqlx::query( r#" INSERT INTO backtest_metrics ( backtest_id, total_return, annualized_return, sharpe_ratio, sortino_ratio, max_drawdown, volatility, win_rate, profit_factor, total_trades, winning_trades, losing_trades, avg_win, avg_loss, largest_win, largest_loss, calmar_ratio, var_95, expected_shortfall ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) "#, ) .bind(backtest_id) .bind(metrics.total_return) .bind(metrics.annualized_return) .bind(metrics.sharpe_ratio) .bind(metrics.sortino_ratio) .bind(metrics.max_drawdown) .bind(metrics.volatility) .bind(metrics.win_rate) .bind(metrics.profit_factor) .bind(metrics.total_trades as i64) .bind(metrics.winning_trades as i64) .bind(metrics.losing_trades as i64) .bind(metrics.avg_win) .bind(metrics.avg_loss) .bind(metrics.largest_win) .bind(metrics.largest_loss) .bind(metrics.calmar_ratio) .bind(metrics.var_95.unwrap_or(0.0)) .bind(metrics.expected_shortfall.unwrap_or(0.0)) .execute(&mut *tx) .await?; tx.commit().await?; info!( "Successfully saved {} trades and metrics for backtest {}", trades.len(), backtest_id ); Ok(()) } /// Load backtest results from storage pub async fn load_backtest_results( &self, backtest_id: &str, ) -> Result<(Vec, PerformanceMetrics)> { info!("Loading backtest results for {}", backtest_id); // Load trades let trade_rows = sqlx::query( r#" SELECT trade_id, symbol, side, quantity, entry_price, exit_price, entry_time, exit_time, pnl, return_percent, entry_signal, exit_signal FROM backtest_trades WHERE backtest_id = $1 ORDER BY entry_time "#, ) .bind(backtest_id) .fetch_all(&self.pg_pool) .await?; let mut trades = Vec::new(); for row in trade_rows { let side_str: String = row.try_get("side")?; let side = match side_str.as_str() { "Buy" => crate::strategy_engine::TradeSide::Buy, "Sell" => crate::strategy_engine::TradeSide::Sell, _ => continue, // Skip invalid trades }; trades.push(BacktestTrade { trade_id: row.try_get("trade_id")?, symbol: row.try_get("symbol")?, side, quantity: Decimal::from_f64_retain(row.try_get::("quantity")?) .unwrap_or(Decimal::ZERO), entry_price: Decimal::from_f64_retain(row.try_get::("entry_price")?) .unwrap_or(Decimal::ZERO), exit_price: Decimal::from_f64_retain(row.try_get::("exit_price")?) .unwrap_or(Decimal::ZERO), entry_time: row.try_get("entry_time")?, exit_time: row.try_get("exit_time")?, pnl: Decimal::from_f64_retain(row.try_get::("pnl")?) .unwrap_or(Decimal::ZERO), return_percent: Decimal::from_f64_retain(row.try_get::("return_percent")?) .unwrap_or(Decimal::ZERO), entry_signal: row.try_get("entry_signal")?, exit_signal: row.try_get("exit_signal")?, }); } // Load metrics let metrics_row = sqlx::query( r#" SELECT total_return, annualized_return, sharpe_ratio, sortino_ratio, max_drawdown, volatility, win_rate, profit_factor, total_trades, winning_trades, losing_trades, avg_win, avg_loss, largest_win, largest_loss, calmar_ratio, var_95, expected_shortfall FROM backtest_metrics WHERE backtest_id = $1 "#, ) .bind(backtest_id) .fetch_one(&self.pg_pool) .await?; // Calculate backtest duration from trades let backtest_duration_nanos = if !trades.is_empty() { let earliest = trades .iter() .map(|t| t.entry_time) .min() .ok_or_else(|| anyhow::anyhow!("No trades found for earliest time"))?; let latest = trades .iter() .map(|t| t.exit_time) .max() .ok_or_else(|| anyhow::anyhow!("No trades found for latest time"))?; (latest - earliest).num_nanoseconds().unwrap_or(0) as u64 } else { 0 }; let metrics = PerformanceMetrics { total_return: metrics_row.try_get("total_return")?, annualized_return: metrics_row.try_get("annualized_return")?, sharpe_ratio: metrics_row.try_get("sharpe_ratio")?, sortino_ratio: metrics_row.try_get("sortino_ratio")?, max_drawdown: metrics_row.try_get("max_drawdown")?, volatility: metrics_row.try_get("volatility")?, win_rate: metrics_row.try_get("win_rate")?, profit_factor: metrics_row.try_get("profit_factor")?, total_trades: metrics_row.try_get::("total_trades")? as u64, winning_trades: metrics_row.try_get::("winning_trades")? as u64, losing_trades: metrics_row.try_get::("losing_trades")? as u64, avg_win: metrics_row.try_get("avg_win")?, avg_loss: metrics_row.try_get("avg_loss")?, largest_win: metrics_row.try_get("largest_win")?, largest_loss: metrics_row.try_get("largest_loss")?, calmar_ratio: metrics_row.try_get("calmar_ratio")?, backtest_duration_nanos: backtest_duration_nanos.try_into().unwrap_or(0), beta: None, alpha: None, information_ratio: None, var_95: Some(metrics_row.try_get("var_95")?), expected_shortfall: Some(metrics_row.try_get("expected_shortfall")?), }; info!( "Loaded {} trades and metrics for backtest {}", trades.len(), backtest_id ); Ok((trades, metrics)) } /// List backtests with optional filtering pub async fn list_backtests( &self, limit: u32, offset: u32, _strategy_name: Option, _status_filter: Option, ) -> Result> { info!("Listing backtests with limit={}, offset={}", limit, offset); // Simplified query without dynamic parameters for now let rows = sqlx::query( r#" SELECT backtest_id, strategy_name, symbols, status, total_return, sharpe_ratio, max_drawdown, created_at, start_date, end_date, description FROM backtests ORDER BY created_at DESC LIMIT $1 OFFSET $2 "#, ) .bind(limit as i64) .bind(offset as i64) .fetch_all(&self.pg_pool) .await?; let mut summaries = Vec::new(); for row in rows { let status_str: String = row.try_get("status")?; let status = match status_str.as_str() { "queued" => BacktestStatus::Queued, "running" => BacktestStatus::Running, "completed" => BacktestStatus::Completed, "failed" => BacktestStatus::Failed, "cancelled" => BacktestStatus::Cancelled, "paused" => BacktestStatus::Paused, _ => BacktestStatus::Unspecified, }; // Parse symbols JSON array (simplified) let symbols_str: String = row.try_get("symbols")?; let symbols: Vec = serde_json::from_str(&symbols_str).unwrap_or_else(|_| vec![symbols_str.clone()]); summaries.push(BacktestSummary { backtest_id: row.try_get("backtest_id")?, strategy_name: row.try_get("strategy_name")?, symbols, status, total_return: row .try_get::, _>("total_return")? .unwrap_or(0.0), sharpe_ratio: row .try_get::, _>("sharpe_ratio")? .unwrap_or(0.0), max_drawdown: row .try_get::, _>("max_drawdown")? .unwrap_or(0.0), created_at: row.try_get("created_at")?, start_date: row.try_get("start_date")?, end_date: row.try_get("end_date")?, description: row .try_get::, _>("description")? .unwrap_or_default(), }); } info!("Found {} backtest summaries", summaries.len()); Ok(summaries) } } impl From for crate::foxhunt::tli::BacktestSummary { fn from(summary: BacktestSummary) -> Self { Self { backtest_id: summary.backtest_id, strategy_name: summary.strategy_name, symbols: summary.symbols, status: summary.status as i32, total_return: summary.total_return, sharpe_ratio: summary.sharpe_ratio, max_drawdown: summary.max_drawdown, created_at_unix_nanos: summary.created_at.timestamp_nanos_opt().unwrap_or(0), start_date_unix_nanos: summary.start_date.timestamp_nanos_opt().unwrap_or(0), end_date_unix_nanos: summary.end_date.timestamp_nanos_opt().unwrap_or(0), description: summary.description, } } }