import numpy as np import pandas as pd class BacktestEngine: def __init__(self, transaction_cost=0.001, slippage=0.0005, risk_free_rate=0.06): """ transaction_cost: friction per trade (e.g., 0.1% = 0.001) slippage: execution slippage (e.g., 0.05% = 0.0005) risk_free_rate: annual risk-free rate (e.g., 6% = 0.06) """ self.friction = transaction_cost + slippage self.rf_daily = (1 + risk_free_rate) ** (1 / 252) - 1 def run_backtest(self, df: pd.DataFrame, predictions: pd.Series) -> dict: """ df: DataFrame containing daily prices and returns predictions: Series of binary predictions (1 = UP, 0 = DOWN) matched with the df index """ results = df.copy() results['predicted_trend'] = predictions # Verify index alignment if 'target_return' not in results.columns: results['target_return'] = results['daily_return_pct'] / 100 else: results['target_return'] = results['target_return'] / 100 # Implement strategies (positions: 1 = Long, 0 = Cash) positions = {} # 1. Buy and Hold (Always Long) positions['Buy_Hold'] = np.ones(len(results)) # 2. AI Strategy positions['AI_Strategy'] = results['predicted_trend'].fillna(0).values # 3. Always Bullish (same as Buy and Hold) positions['Always_Bullish'] = np.ones(len(results)) # 4. Momentum Baseline (Long if 5-day return > 0, else cash) ma5_ret = results['daily_return_pct'].rolling(window=5).mean().shift(1) positions['Momentum'] = np.where(ma5_ret > 0, 1, 0) # 5. Moving Average Crossover (Long if SMA10 > SMA30, else cash) close = results['close_price'] sma10 = close.rolling(window=10).mean().shift(1) sma30 = close.rolling(window=30).mean().shift(1) positions['MA_Crossover'] = np.where(sma10 > sma30, 1, 0) # 6. Previous-Day Direction (Long if yesterday was positive, else cash) prev_ret = results['daily_return_pct'].shift(1) positions['Prev_Day_Dir'] = np.where(prev_ret > 0, 1, 0) # 7. AI Kelly Sizing Strategy (Scale position using model confidence) probs = results.get('predicted_prob', pd.Series(0.5, index=results.index)).fillna(0.5).values kelly_fraction = np.clip(2 * probs - 1, 0.0, 1.0) positions['AI_Strategy_Kelly'] = results['predicted_trend'].fillna(0).values * kelly_fraction # 8. AI Volatility Target Strategy (Target constant annualized volatility of 15%) # Extract realized_volatility or lag1 (from technical indicators) realized_vol_col = 'realized_volatility_lag1' if 'realized_volatility_lag1' in results.columns else 'realized_volatility' if realized_vol_col in results.columns: realized_vol = results[realized_vol_col].fillna(15.0).values / 100.0 else: realized_vol = np.ones(len(results)) * 0.15 target_vol = 0.15 # Target 15% annualized volatility vol_scalar = np.clip(target_vol / (realized_vol + 1e-9), 0.1, 1.5) positions['AI_Strategy_VolTarget'] = results['predicted_trend'].fillna(0).values * vol_scalar strategy_curves = {} strategy_metrics = {} for name, pos in positions.items(): # Calculate daily returns with transaction costs daily_ret = results['target_return'].values # Position changes (trades) pos_shifted = np.roll(pos, 1) pos_shifted[0] = 0 # Assume starting in cash trades = np.abs(pos - pos_shifted) # Apply transaction costs on trades trade_costs = trades * self.friction # Strategy daily returns strat_ret = pos * daily_ret - trade_costs # Cumulative returns cum_ret = np.cumprod(1 + strat_ret) strategy_curves[name] = cum_ret # Metrics metrics = self.calculate_metrics(strat_ret) strategy_metrics[name] = metrics # Generate comparative dataframe curves_df = pd.DataFrame(strategy_curves, index=results.index) curves_df['date'] = results['date'] return { "curves": curves_df, "metrics": strategy_metrics } def calculate_metrics(self, daily_returns: np.ndarray) -> dict: n_days = len(daily_returns) if n_days == 0: return {} total_return = np.prod(1 + daily_returns) - 1 # CAGR (assuming 252 trading days per year) years = n_days / 252 cagr = (total_return + 1) ** (1 / years) - 1 if years > 0 and total_return > -1 else -1.0 # Volatility vol = np.std(daily_returns) * np.sqrt(252) # Sharpe Ratio (with Rf = 0 for daily returns) mean_ret = np.mean(daily_returns) std_ret = np.std(daily_returns) sharpe = (mean_ret / (std_ret + 1e-9)) * np.sqrt(252) # Sortino Ratio (downside deviation standard deviation * sqrt(252)) downside_returns = daily_returns[daily_returns < 0] downside_std = np.std(downside_returns) * np.sqrt(252) if len(downside_returns) > 0 else 1e-9 sortino = (mean_ret / (np.std(daily_returns[daily_returns < 0]) + 1e-9)) * np.sqrt(252) if len(daily_returns[daily_returns < 0]) > 0 else sharpe # Max Drawdown cum_returns = np.cumprod(1 + daily_returns) running_max = np.maximum.accumulate(cum_returns) drawdowns = (cum_returns - running_max) / (running_max + 1e-9) max_dd = np.min(drawdowns) # Calmar Ratio calmar = cagr / abs(max_dd) if max_dd < 0 else 0.0 # Value at Risk (VaR 95%) and Conditional VaR (CVaR 95%) var_95 = np.percentile(daily_returns, 5) cvar_95 = np.mean(daily_returns[daily_returns <= var_95]) if len(daily_returns[daily_returns <= var_95]) > 0 else var_95 # Win Rate win_rate = np.sum(daily_returns > 0) / np.sum(daily_returns != 0) if np.sum(daily_returns != 0) > 0 else 0.0 # 95% Confidence Interval for Daily Returns (via standard error of mean) sem = std_ret / np.sqrt(n_days) ci_lower = mean_ret - 1.96 * sem ci_upper = mean_ret + 1.96 * sem return { "Total Return": float(total_return), "CAGR": float(cagr), "Annualized Volatility": float(vol), "Sharpe Ratio": float(sharpe), "Sortino Ratio": float(sortino), "Calmar Ratio": float(calmar), "Max Drawdown": float(max_dd), "VaR_95": float(var_95), "CVaR_95": float(cvar_95), "Win Rate": float(win_rate), "CI_Lower_Daily": float(ci_lower), "CI_Upper_Daily": float(ci_upper) }