""" Backtest runner service that wraps the existing Backtester engine. """ import uuid from datetime import datetime, timezone from typing import Any import pandas as pd from api.models.schemas import ( BacktestMetrics, BacktestRequest, BacktestResult, EquityPoint, TradeRecord, ) from engine.backtester import Backtester from engine.data_manager import DataManager from engine.logging_config import get_logger from engine.market import MarketType from strategies.factory import get_strategy logger = get_logger(__name__) class BacktestRunner: """ Service for executing backtests via the API. Wraps the existing Backtester engine and converts results to API response format. """ def __init__(self): self.dm = DataManager() self.bt = Backtester(self.dm) def run(self, request: BacktestRequest) -> BacktestResult: """ Execute a backtest and return structured results. Args: request: BacktestRequest with strategy, symbol, and parameters Returns: BacktestResult with metrics, equity curve, and trades """ # Get strategy instance strategy, default_params = get_strategy(request.strategy, is_grid=False) # Merge default params with request params params = {**default_params, **request.params} # Convert market type string to enum market_type = MarketType(request.market_type) # Override strategy market type if specified strategy.default_market_type = market_type logger.info( "Running backtest: %s on %s (%s), params=%s", request.strategy, request.symbol, request.timeframe, params ) # Execute backtest result = self.bt.run_strategy( strategy=strategy, exchange_id=request.exchange, symbol=request.symbol, timeframe=request.timeframe, start_date=request.start_date, end_date=request.end_date, init_cash=request.init_cash, fees=request.fees, slippage=request.slippage, sl_stop=request.sl_stop, tp_stop=request.tp_stop, sl_trail=request.sl_trail, leverage=request.leverage, **params ) # Extract data from portfolio portfolio = result.portfolio # Build trade records trades = self._build_trade_records(portfolio) # Build equity curve (trimmed to trading period) equity_curve = self._build_equity_curve(portfolio) # Build metrics metrics = self._build_metrics(result, portfolio) # Get date range from actual trading period (first trade to end) idx = portfolio.wrapper.index end_date = idx[-1].strftime("%Y-%m-%d %H:%M") # Use first trade time as start if trades exist trades_df = portfolio.trades.records_readable if not trades_df.empty: first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time' if first_entry_col in trades_df.columns: first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0]) start_date = first_trade_time.strftime("%Y-%m-%d %H:%M") else: start_date = idx[0].strftime("%Y-%m-%d %H:%M") else: start_date = idx[0].strftime("%Y-%m-%d %H:%M") return BacktestResult( run_id=str(uuid.uuid4()), strategy=request.strategy, symbol=request.symbol, market_type=result.market_type.value, timeframe=request.timeframe, start_date=start_date, end_date=end_date, leverage=result.leverage, params=params, metrics=metrics, equity_curve=equity_curve, trades=trades, created_at=datetime.now(timezone.utc).isoformat(), ) def _build_equity_curve(self, portfolio) -> list[EquityPoint]: """Extract equity curve with drawdown from portfolio, starting from first trade.""" value_series = portfolio.value() drawdown_series = portfolio.drawdown() # Handle multi-column case (from grid search) if hasattr(value_series, 'columns') and len(value_series.columns) > 1: value_series = value_series.iloc[:, 0] drawdown_series = drawdown_series.iloc[:, 0] elif hasattr(value_series, 'columns'): value_series = value_series.iloc[:, 0] drawdown_series = drawdown_series.iloc[:, 0] # Find first trade time to trim equity curve first_trade_idx = 0 trades_df = portfolio.trades.records_readable if not trades_df.empty: first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time' if first_entry_col in trades_df.columns: first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0]) # Find index in value_series closest to first trade if hasattr(value_series.index, 'get_indexer'): first_trade_idx = value_series.index.get_indexer([first_trade_time], method='nearest')[0] # Start a few bars before first trade for context first_trade_idx = max(0, first_trade_idx - 5) # Slice from first trade onwards value_series = value_series.iloc[first_trade_idx:] drawdown_series = drawdown_series.iloc[first_trade_idx:] points = [] for i, (ts, val) in enumerate(value_series.items()): dd = drawdown_series.iloc[i] if i < len(drawdown_series) else 0.0 points.append(EquityPoint( timestamp=ts.isoformat(), value=float(val), drawdown=float(dd) * 100, # Convert to percentage )) return points def _build_trade_records(self, portfolio) -> list[TradeRecord]: """Extract trade records from portfolio.""" trades_df = portfolio.trades.records_readable if trades_df.empty: return [] records = [] for _, row in trades_df.iterrows(): # Handle different column names in vectorbt entry_time = row.get('Entry Timestamp', row.get('Entry Time', '')) exit_time = row.get('Exit Timestamp', row.get('Exit Time', '')) records.append(TradeRecord( entry_time=str(entry_time) if pd.notna(entry_time) else "", exit_time=str(exit_time) if pd.notna(exit_time) else None, entry_price=float(row.get('Avg Entry Price', row.get('Entry Price', 0))), exit_price=float(row.get('Avg Exit Price', row.get('Exit Price', 0))) if pd.notna(row.get('Avg Exit Price', row.get('Exit Price'))) else None, size=float(row.get('Size', 0)), direction=str(row.get('Direction', 'Long')), pnl=float(row.get('PnL', 0)) if pd.notna(row.get('PnL')) else None, return_pct=float(row.get('Return', 0)) * 100 if pd.notna(row.get('Return')) else None, status="closed" if pd.notna(exit_time) else "open", )) return records def _build_metrics(self, result, portfolio) -> BacktestMetrics: """Build metrics from backtest result.""" stats = portfolio.stats() # Extract values, handling potential multi-column results def get_stat(key: str, default: float = 0.0) -> float: val = stats.get(key, default) if hasattr(val, 'mean'): return float(val.mean()) return float(val) if pd.notna(val) else default total_return = portfolio.total_return() if hasattr(total_return, 'mean'): total_return = total_return.mean() # Calculate benchmark return from first trade to end (not full period) # This gives accurate comparison when strategy has training period close = portfolio.close benchmark_return = 0.0 if hasattr(close, 'iloc'): # Find first trade entry time trades_df = portfolio.trades.records_readable if not trades_df.empty: # Get the first trade entry timestamp first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time' if first_entry_col in trades_df.columns: first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0]) # Find the price at first trade if hasattr(close.index, 'get_indexer'): # Find closest index to first trade time idx = close.index.get_indexer([first_trade_time], method='nearest')[0] start_price = close.iloc[idx] else: start_price = close.iloc[0] end_price = close.iloc[-1] if hasattr(start_price, 'mean'): start_price = start_price.mean() if hasattr(end_price, 'mean'): end_price = end_price.mean() benchmark_return = ((end_price - start_price) / start_price) else: # No trades - use full period start_price = close.iloc[0] end_price = close.iloc[-1] if hasattr(start_price, 'mean'): start_price = start_price.mean() if hasattr(end_price, 'mean'): end_price = end_price.mean() benchmark_return = ((end_price - start_price) / start_price) # Alpha = strategy return - benchmark return alpha = float(total_return) - float(benchmark_return) sharpe = portfolio.sharpe_ratio() if hasattr(sharpe, 'mean'): sharpe = sharpe.mean() max_dd = portfolio.max_drawdown() if hasattr(max_dd, 'mean'): max_dd = max_dd.mean() win_rate = portfolio.trades.win_rate() if hasattr(win_rate, 'mean'): win_rate = win_rate.mean() trade_count = portfolio.trades.count() if hasattr(trade_count, 'mean'): trade_count = int(trade_count.mean()) else: trade_count = int(trade_count) return BacktestMetrics( total_return=float(total_return) * 100, benchmark_return=float(benchmark_return) * 100, alpha=float(alpha) * 100, sharpe_ratio=float(sharpe) if pd.notna(sharpe) else 0.0, max_drawdown=float(max_dd) * 100, win_rate=float(win_rate) * 100 if pd.notna(win_rate) else 0.0, total_trades=trade_count, profit_factor=get_stat('Profit Factor'), avg_trade_return=get_stat('Avg Winning Trade [%]'), total_fees=get_stat('Total Fees Paid'), total_funding=result.total_funding_paid, liquidation_count=result.liquidation_count, liquidation_loss=result.total_liquidation_loss, adjusted_return=result.adjusted_return, ) # Singleton instance _runner: BacktestRunner | None = None def get_runner() -> BacktestRunner: """Get or create the backtest runner instance.""" global _runner if _runner is None: _runner = BacktestRunner() return _runner