Premchan369 commited on
Commit
24ff2ee
·
verified ·
1 Parent(s): c522dca

Add backtest engine with Sharpe, Sortino, max drawdown, IC tracking

Browse files
Files changed (1) hide show
  1. backtest_engine.py +338 -0
backtest_engine.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backtest Engine for AlphaForge with comprehensive metrics."""
2
+ import numpy as np
3
+ import pandas as pd
4
+ from typing import Dict, List, Optional, Callable
5
+ import warnings
6
+ warnings.filterwarnings('ignore')
7
+
8
+
9
+ class BacktestEngine:
10
+ """Portfolio backtest engine with transaction costs and slippage"""
11
+
12
+ def __init__(self,
13
+ initial_capital: float = 1_000_000,
14
+ transaction_cost: float = 0.0003,
15
+ slippage: float = 0.0001,
16
+ benchmark: str = 'SPY'):
17
+ self.initial_capital = initial_capital
18
+ self.transaction_cost = transaction_cost
19
+ self.slippage = slippage
20
+ self.benchmark = benchmark
21
+
22
+ self.portfolio_values = []
23
+ self.weights_history = []
24
+ self.returns_history = []
25
+ self.dates = []
26
+ self.trades = []
27
+
28
+ def run_backtest(self,
29
+ returns_df: pd.DataFrame,
30
+ weights_df: pd.DataFrame,
31
+ rebalance_dates: Optional[List[pd.Timestamp]] = None) -> Dict:
32
+ """
33
+ Run portfolio backtest
34
+
35
+ Args:
36
+ returns_df: DataFrame of asset returns (dates x assets)
37
+ weights_df: DataFrame of portfolio weights (dates x assets)
38
+ rebalance_dates: List of dates to rebalance (if None, rebalance daily)
39
+
40
+ Returns:
41
+ Dict with performance metrics
42
+ """
43
+ # Align dates
44
+ common_dates = returns_df.index.intersection(weights_df.index)
45
+ returns_df = returns_df.loc[common_dates]
46
+ weights_df = weights_df.loc[common_dates]
47
+
48
+ capital = self.initial_capital
49
+ current_weights = np.zeros(len(returns_df.columns))
50
+ portfolio_values = [capital]
51
+
52
+ for i, date in enumerate(common_dates[1:], 1):
53
+ # Get target weights
54
+ target_weights = weights_df.iloc[i].values
55
+
56
+ # Check if rebalance needed
57
+ if rebalance_dates is None or date in rebalance_dates:
58
+ # Calculate turnover
59
+ turnover = np.sum(np.abs(target_weights - current_weights))
60
+
61
+ # Transaction costs
62
+ tc = turnover * self.transaction_cost * capital
63
+ capital -= tc
64
+
65
+ # Record trade
66
+ if turnover > 0.001:
67
+ self.trades.append({
68
+ 'date': date,
69
+ 'turnover': turnover,
70
+ 'cost': tc,
71
+ 'old_weights': current_weights.copy(),
72
+ 'new_weights': target_weights.copy()
73
+ })
74
+
75
+ current_weights = target_weights.copy()
76
+
77
+ # Apply slippage to returns
78
+ daily_returns = returns_df.iloc[i].values
79
+ slippage_cost = np.sum(np.abs(current_weights)) * self.slippage
80
+
81
+ # Portfolio return
82
+ port_return = np.dot(current_weights, daily_returns) - slippage_cost
83
+ capital *= (1 + port_return)
84
+
85
+ portfolio_values.append(capital)
86
+ self.returns_history.append(port_return)
87
+ self.weights_history.append(current_weights.copy())
88
+ self.dates.append(date)
89
+
90
+ self.portfolio_values = np.array(portfolio_values)
91
+ self.returns_history = np.array(self.returns_history)
92
+
93
+ return self.compute_metrics()
94
+
95
+ def compute_metrics(self, benchmark_returns: Optional[np.ndarray] = None) -> Dict:
96
+ """Compute comprehensive performance metrics"""
97
+ returns = self.returns_history
98
+
99
+ if len(returns) == 0:
100
+ return {}
101
+
102
+ # Basic metrics
103
+ total_return = (self.portfolio_values[-1] / self.initial_capital) - 1
104
+ annualized_return = (1 + total_return) ** (252 / len(returns)) - 1
105
+
106
+ # Volatility
107
+ volatility = np.std(returns) * np.sqrt(252)
108
+
109
+ # Sharpe ratio
110
+ excess_returns = returns - 0.04 / 252 # Assuming 4% risk-free rate
111
+ sharpe = np.mean(excess_returns) / np.std(returns) * np.sqrt(252) if np.std(returns) > 0 else 0
112
+
113
+ # Sortino ratio
114
+ downside_returns = returns[returns < 0]
115
+ downside_std = np.std(downside_returns) * np.sqrt(252) if len(downside_returns) > 0 else 1e-8
116
+ sortino = (annualized_return - 0.04) / downside_std
117
+
118
+ # Max drawdown
119
+ cumulative = np.cumprod(1 + returns)
120
+ running_max = np.maximum.accumulate(cumulative)
121
+ drawdown = (cumulative - running_max) / running_max
122
+ max_drawdown = np.min(drawdown)
123
+
124
+ # Calmar ratio
125
+ calmar = annualized_return / abs(max_drawdown) if max_drawdown != 0 else 0
126
+
127
+ # Win rate
128
+ win_rate = np.sum(returns > 0) / len(returns)
129
+
130
+ # Profit factor
131
+ gross_profit = np.sum(returns[returns > 0])
132
+ gross_loss = abs(np.sum(returns[returns < 0]))
133
+ profit_factor = gross_profit / gross_loss if gross_loss > 0 else np.inf
134
+
135
+ # Alpha and Beta (vs benchmark)
136
+ alpha, beta = 0, 0
137
+ if benchmark_returns is not None and len(benchmark_returns) == len(returns):
138
+ cov = np.cov(returns, benchmark_returns)[0, 1]
139
+ bench_var = np.var(benchmark_returns)
140
+ beta = cov / bench_var if bench_var > 0 else 0
141
+ alpha = (np.mean(returns) - beta * np.mean(benchmark_returns)) * 252
142
+
143
+ # Information ratio
144
+ if benchmark_returns is not None:
145
+ tracking_error = np.std(returns - benchmark_returns) * np.sqrt(252)
146
+ info_ratio = (annualized_return - np.mean(benchmark_returns) * 252) / tracking_error if tracking_error > 0 else 0
147
+ else:
148
+ info_ratio = 0
149
+
150
+ # Turnover statistics
151
+ avg_turnover = np.mean([t['turnover'] for t in self.trades]) if self.trades else 0
152
+ total_cost = sum([t['cost'] for t in self.trades]) if self.trades else 0
153
+
154
+ metrics = {
155
+ 'total_return': total_return,
156
+ 'annualized_return': annualized_return,
157
+ 'volatility': volatility,
158
+ 'sharpe_ratio': sharpe,
159
+ 'sortino_ratio': sortino,
160
+ 'max_drawdown': max_drawdown,
161
+ 'calmar_ratio': calmar,
162
+ 'win_rate': win_rate,
163
+ 'profit_factor': profit_factor,
164
+ 'alpha': alpha,
165
+ 'beta': beta,
166
+ 'information_ratio': info_ratio,
167
+ 'avg_turnover': avg_turnover,
168
+ 'total_transaction_costs': total_cost,
169
+ 'final_capital': self.portfolio_values[-1],
170
+ 'n_trades': len(self.trades),
171
+ 'n_days': len(returns)
172
+ }
173
+
174
+ return metrics
175
+
176
+ def get_equity_curve(self) -> pd.DataFrame:
177
+ """Get equity curve"""
178
+ return pd.DataFrame({
179
+ 'date': [self.dates[0]] + list(self.dates),
180
+ 'portfolio_value': self.portfolio_values,
181
+ 'cumulative_return': (self.portfolio_values / self.initial_capital) - 1
182
+ })
183
+
184
+ def get_drawdown_series(self) -> pd.Series:
185
+ """Get drawdown series"""
186
+ cumulative = np.cumprod(1 + self.returns_history)
187
+ running_max = np.maximum.accumulate(cumulative)
188
+ drawdown = (cumulative - running_max) / running_max
189
+ return pd.Series(drawdown, index=self.dates)
190
+
191
+ def get_monthly_returns(self) -> pd.DataFrame:
192
+ """Get monthly returns"""
193
+ returns_series = pd.Series(self.returns_history, index=self.dates)
194
+ monthly = returns_series.resample('M').apply(lambda x: np.prod(1 + x) - 1)
195
+ return monthly
196
+
197
+ def get_rolling_metrics(self, window: int = 63) -> pd.DataFrame:
198
+ """Get rolling performance metrics"""
199
+ returns_series = pd.Series(self.returns_history, index=self.dates)
200
+
201
+ rolling_sharpe = (
202
+ returns_series.rolling(window).mean() /
203
+ returns_series.rolling(window).std() * np.sqrt(252)
204
+ )
205
+
206
+ rolling_vol = returns_series.rolling(window).std() * np.sqrt(252)
207
+
208
+ return pd.DataFrame({
209
+ 'rolling_sharpe': rolling_sharpe,
210
+ 'rolling_volatility': rolling_vol
211
+ })
212
+
213
+
214
+ def compute_information_coefficient(predictions: pd.Series,
215
+ actuals: pd.Series,
216
+ by_date: bool = True) -> Dict:
217
+ """
218
+ Compute Information Coefficient (rank correlation)
219
+
220
+ Args:
221
+ predictions: Series of predicted returns
222
+ actuals: Series of actual returns
223
+ by_date: If True, compute IC per date and return mean/std
224
+
225
+ Returns:
226
+ Dict with IC metrics
227
+ """
228
+ from scipy.stats import spearmanr
229
+
230
+ if by_date and hasattr(predictions, 'index') and hasattr(actuals, 'index'):
231
+ # Group by date
232
+ ic_by_date = []
233
+
234
+ pred_df = pd.DataFrame({'pred': predictions, 'actual': actuals})
235
+ pred_df = pred_df.dropna()
236
+
237
+ if hasattr(pred_df.index, 'date'):
238
+ dates = pred_df.index.date
239
+ else:
240
+ dates = pred_df.index
241
+
242
+ for date in np.unique(dates):
243
+ mask = dates == date
244
+ if mask.sum() > 3:
245
+ p = pred_df.loc[mask, 'pred']
246
+ a = pred_df.loc[mask, 'actual']
247
+ ic, _ = spearmanr(p, a)
248
+ if not np.isnan(ic):
249
+ ic_by_date.append(ic)
250
+
251
+ if len(ic_by_date) > 0:
252
+ return {
253
+ 'mean_ic': np.mean(ic_by_date),
254
+ 'ic_std': np.std(ic_by_date),
255
+ 'ic_ir': np.mean(ic_by_date) / np.std(ic_by_date) if np.std(ic_by_date) > 0 else 0,
256
+ 'ic_pct_positive': np.sum(np.array(ic_by_date) > 0) / len(ic_by_date),
257
+ 'n_periods': len(ic_by_date)
258
+ }
259
+
260
+ # Overall IC
261
+ mask = ~(np.isnan(predictions) | np.isnan(actuals))
262
+ ic, pvalue = spearmanr(predictions[mask], actuals[mask])
263
+
264
+ return {
265
+ 'mean_ic': ic if not np.isnan(ic) else 0,
266
+ 'ic_std': 0,
267
+ 'ic_ir': 0,
268
+ 'ic_pct_positive': 1 if ic > 0 else 0,
269
+ 'n_periods': 1,
270
+ 'p_value': pvalue
271
+ }
272
+
273
+
274
+ class RegimeDetector:
275
+ """Detect market regimes using Hidden Markov Model or simple heuristics"""
276
+
277
+ def __init__(self, method: str = 'simple'):
278
+ self.method = method
279
+ self.regimes = []
280
+
281
+ def detect_regimes(self, returns: pd.Series,
282
+ volatility_window: int = 21) -> pd.Series:
283
+ """
284
+ Detect market regimes:
285
+ - Bull: positive trend, low vol
286
+ - Bear: negative trend, high vol
287
+ - High Vol: high volatility regardless of trend
288
+ """
289
+ # Trend
290
+ trend = returns.rolling(63).mean()
291
+
292
+ # Volatility
293
+ vol = returns.rolling(volatility_window).std() * np.sqrt(252)
294
+ vol_median = vol.median()
295
+
296
+ regimes = pd.Series(index=returns.index, dtype='object')
297
+
298
+ for i, date in enumerate(returns.index):
299
+ if pd.isna(trend.loc[date]) or pd.isna(vol.loc[date]):
300
+ regimes.loc[date] = 'unknown'
301
+ continue
302
+
303
+ t = trend.loc[date]
304
+ v = vol.loc[date]
305
+
306
+ if v > vol_median * 1.5:
307
+ regimes.loc[date] = 'high_vol'
308
+ elif t > 0.001:
309
+ regimes.loc[date] = 'bull'
310
+ elif t < -0.001:
311
+ regimes.loc[date] = 'bear'
312
+ else:
313
+ regimes.loc[date] = 'neutral'
314
+
315
+ self.regimes = regimes
316
+ return regimes
317
+
318
+ def get_regime_stats(self, returns: pd.Series) -> pd.DataFrame:
319
+ """Get performance statistics by regime"""
320
+ if len(self.regimes) == 0:
321
+ self.detect_regimes(returns)
322
+
323
+ stats = []
324
+ for regime in self.regimes.unique():
325
+ mask = self.regimes == regime
326
+ regime_returns = returns[mask]
327
+
328
+ if len(regime_returns) > 0:
329
+ stats.append({
330
+ 'regime': regime,
331
+ 'n_days': len(regime_returns),
332
+ 'mean_return': regime_returns.mean() * 252,
333
+ 'volatility': regime_returns.std() * np.sqrt(252),
334
+ 'sharpe': (regime_returns.mean() / regime_returns.std()) * np.sqrt(252) if regime_returns.std() > 0 else 0,
335
+ 'max_drawdown': (regime_returns.cumsum() - regime_returns.cumsum().cummax()).min()
336
+ })
337
+
338
+ return pd.DataFrame(stats)