ifieryarrows commited on
Commit
48e48fe
·
verified ·
1 Parent(s): 8fdaddb

Sync from GitHub (tests passed)

Browse files
backtest/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backtest module for champion/challenger comparison.
3
+ """
4
+
5
+ from backend.backtest.runner import (
6
+ BacktestRunner,
7
+ BacktestConfig,
8
+ BacktestResult,
9
+ BacktestMetrics,
10
+ SymbolSet,
11
+ load_symbol_set
12
+ )
13
+
14
+ __all__ = [
15
+ "BacktestRunner",
16
+ "BacktestConfig",
17
+ "BacktestResult",
18
+ "BacktestMetrics",
19
+ "SymbolSet",
20
+ "load_symbol_set"
21
+ ]
backtest/runner.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Champion/Challenger Backtest Script
3
+
4
+ Rolling 6-year train window with weekly retrain and daily 1D predictions.
5
+ Compares model performance between champion and challenger symbol sets.
6
+
7
+ Audit-ready outputs:
8
+ - backtest_report.json: Summary metrics and decision
9
+ - predictions.csv: Daily predictions with timestamps
10
+
11
+ Usage:
12
+ python -m backend.backtest.runner --champion config/symbol_sets/champion.json --challenger runs/latest/selected_symbols.json
13
+ """
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ from dataclasses import dataclass, asdict
20
+ from datetime import datetime, timedelta
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+ # Configure logging
28
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class BacktestConfig:
34
+ """Configuration for backtest run."""
35
+ # Time parameters
36
+ oos_start: str = "2024-01-01"
37
+ oos_end: str = "2025-01-17"
38
+ train_window_years: int = 6
39
+ retrain_frequency: str = "weekly" # Monday
40
+ prediction_horizon: int = 1 # days
41
+
42
+ # Model parameters
43
+ random_seed: int = 42
44
+ xgb_params: dict = None
45
+
46
+ # Promote thresholds
47
+ promote_threshold_pct: float = 5.0 # Champion MAE must improve by 5%
48
+ reject_threshold_pct: float = -5.0 # Challenger MAE 5% worse = reject
49
+
50
+ def __post_init__(self):
51
+ if self.xgb_params is None:
52
+ self.xgb_params = {
53
+ "n_estimators": 100,
54
+ "max_depth": 6,
55
+ "learning_rate": 0.1,
56
+ "random_state": self.random_seed,
57
+ "n_jobs": -1
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class SymbolSet:
63
+ """A set of symbols with metadata."""
64
+ name: str
65
+ symbols: list[str]
66
+ version: str
67
+ source_path: str
68
+ content_hash: str
69
+
70
+
71
+ @dataclass
72
+ class BacktestMetrics:
73
+ """Metrics from a backtest run."""
74
+ mae: float
75
+ rmse: float
76
+ directional_accuracy: float
77
+ n_predictions: int
78
+ mean_actual: float
79
+ mean_predicted: float
80
+
81
+
82
+ @dataclass
83
+ class BacktestResult:
84
+ """Full backtest result with comparison."""
85
+ run_id: str
86
+ generated_at: str
87
+ config: BacktestConfig
88
+ champion: dict # SymbolSet + BacktestMetrics
89
+ challenger: dict # SymbolSet + BacktestMetrics
90
+ delta_mae_pct: float
91
+ delta_rmse_pct: float
92
+ delta_dir_acc_pct: float
93
+ decision: str # PROMOTE | REJECT | MANUAL_REVIEW
94
+ decision_reason: str
95
+
96
+
97
+ def compute_content_hash(data: dict) -> str:
98
+ """Compute deterministic hash of symbol set."""
99
+ # Sort symbols for determinism
100
+ symbols = sorted(data.get("symbols", []))
101
+ canonical = json.dumps({"symbols": symbols}, sort_keys=True)
102
+ return f"sha256:{hashlib.sha256(canonical.encode()).hexdigest()[:16]}"
103
+
104
+
105
+ def load_symbol_set(path: str | Path) -> SymbolSet:
106
+ """Load symbol set from JSON file."""
107
+ path = Path(path)
108
+ with open(path) as f:
109
+ data = json.load(f)
110
+
111
+ # Handle selected_symbols.json format (has "selected" key with objects)
112
+ if "selected" in data:
113
+ symbols = [s["ticker"] for s in data["selected"]]
114
+ name = data.get("screener_run_id", "challenger")
115
+ version = data.get("selection_rules_version", "unknown")
116
+ else:
117
+ symbols = data.get("symbols", [])
118
+ name = data.get("name", "unknown")
119
+ version = data.get("version", "unknown")
120
+
121
+ return SymbolSet(
122
+ name=name,
123
+ symbols=symbols,
124
+ version=version,
125
+ source_path=str(path),
126
+ content_hash=compute_content_hash({"symbols": symbols})
127
+ )
128
+
129
+
130
+ def get_trading_days(start: str, end: str) -> pd.DatetimeIndex:
131
+ """Get trading days in range (approximate - weekdays only)."""
132
+ dates = pd.date_range(start=start, end=end, freq='B') # Business days
133
+ return dates
134
+
135
+
136
+ def get_retrain_dates(trading_days: pd.DatetimeIndex) -> pd.DatetimeIndex:
137
+ """Get Monday retrain dates from trading days."""
138
+ # Get first trading day of each week
139
+ weekly = trading_days.to_series().groupby(pd.Grouper(freq='W-MON')).first()
140
+ return pd.DatetimeIndex(weekly.dropna())
141
+
142
+
143
+ class BacktestRunner:
144
+ """
145
+ Run champion/challenger backtest.
146
+
147
+ Implements:
148
+ - Rolling 6-year train window
149
+ - Weekly retrain on Mondays
150
+ - Daily 1D predictions
151
+ - No lookahead (strict asof convention)
152
+ """
153
+
154
+ def __init__(self, config: BacktestConfig):
155
+ self.config = config
156
+ self.run_id = f"backtest-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
157
+
158
+ def fetch_prices(self, symbols: list[str], start: str, end: str) -> pd.DataFrame:
159
+ """
160
+ Fetch historical prices for symbols.
161
+
162
+ Returns DataFrame with columns: date, symbol, close
163
+ """
164
+ try:
165
+ import yfinance as yf
166
+ except ImportError:
167
+ raise ImportError("yfinance required: pip install yfinance")
168
+
169
+ # Extend start to include train window
170
+ train_start = (pd.Timestamp(start) - pd.DateOffset(years=self.config.train_window_years + 1)).strftime('%Y-%m-%d')
171
+
172
+ all_data = []
173
+ for symbol in symbols:
174
+ try:
175
+ ticker = yf.Ticker(symbol)
176
+ hist = ticker.history(start=train_start, end=end, interval="1d")
177
+ if not hist.empty:
178
+ df = hist[['Close']].reset_index()
179
+ df.columns = ['date', 'close']
180
+ df['symbol'] = symbol
181
+ all_data.append(df)
182
+ except Exception as e:
183
+ logger.warning(f"Failed to fetch {symbol}: {e}")
184
+
185
+ if not all_data:
186
+ raise ValueError("No price data fetched")
187
+
188
+ result = pd.concat(all_data, ignore_index=True)
189
+ result['date'] = pd.to_datetime(result['date']).dt.tz_localize(None)
190
+ return result
191
+
192
+ def prepare_features(self, prices: pd.DataFrame, target_symbol: str = "HG=F") -> pd.DataFrame:
193
+ """
194
+ Prepare feature matrix for modeling.
195
+
196
+ Creates lag features, returns, and rolling metrics.
197
+ """
198
+ # Pivot to wide format
199
+ pivot = prices.pivot(index='date', columns='symbol', values='close')
200
+ pivot = pivot.sort_index()
201
+
202
+ # Compute returns
203
+ returns = pivot.pct_change()
204
+
205
+ # Create feature DataFrame
206
+ features = pd.DataFrame(index=pivot.index)
207
+
208
+ # Target: next day close
209
+ if target_symbol not in pivot.columns:
210
+ raise ValueError(f"Target symbol {target_symbol} not in price data")
211
+
212
+ features['y_target'] = pivot[target_symbol].shift(-1) # Next day price
213
+ features['y_current'] = pivot[target_symbol]
214
+
215
+ # Features for each symbol
216
+ for symbol in pivot.columns:
217
+ if symbol == target_symbol:
218
+ continue
219
+
220
+ # Price ratio to target
221
+ features[f'{symbol}_ratio'] = pivot[symbol] / pivot[target_symbol]
222
+
223
+ # Returns
224
+ features[f'{symbol}_ret_1d'] = returns[symbol]
225
+ features[f'{symbol}_ret_5d'] = pivot[symbol].pct_change(5)
226
+
227
+ # Rolling volatility
228
+ features[f'{symbol}_vol_20d'] = returns[symbol].rolling(20).std()
229
+
230
+ # Target's own features
231
+ features['target_ret_1d'] = returns[target_symbol]
232
+ features['target_ret_5d'] = pivot[target_symbol].pct_change(5)
233
+ features['target_vol_20d'] = returns[target_symbol].rolling(20).std()
234
+ features['target_mom_10d'] = pivot[target_symbol].pct_change(10)
235
+
236
+ return features.dropna()
237
+
238
+ def train_and_predict(
239
+ self,
240
+ features: pd.DataFrame,
241
+ train_end: pd.Timestamp,
242
+ predict_dates: list[pd.Timestamp]
243
+ ) -> list[dict]:
244
+ """
245
+ Train model on data up to train_end and predict for predict_dates.
246
+
247
+ Returns list of prediction records.
248
+ """
249
+ try:
250
+ from xgboost import XGBRegressor
251
+ except ImportError:
252
+ raise ImportError("xgboost required: pip install xgboost")
253
+
254
+ # Train window: last N years
255
+ train_start = train_end - pd.DateOffset(years=self.config.train_window_years)
256
+
257
+ # Get training data
258
+ train_mask = (features.index >= train_start) & (features.index <= train_end)
259
+ train_data = features.loc[train_mask].copy()
260
+
261
+ if len(train_data) < 100:
262
+ logger.warning(f"Insufficient training data: {len(train_data)} rows")
263
+ return []
264
+
265
+ # Prepare X, y
266
+ feature_cols = [c for c in train_data.columns if c not in ['y_target', 'y_current']]
267
+ X_train = train_data[feature_cols]
268
+ y_train = train_data['y_target']
269
+
270
+ # Train model
271
+ model = XGBRegressor(**self.config.xgb_params)
272
+ model.fit(X_train, y_train)
273
+
274
+ # Predict for each date
275
+ predictions = []
276
+ for pred_date in predict_dates:
277
+ if pred_date not in features.index:
278
+ continue
279
+
280
+ row = features.loc[[pred_date]]
281
+ X_pred = row[feature_cols]
282
+ y_pred = model.predict(X_pred)[0]
283
+ y_current = row['y_current'].iloc[0]
284
+ y_actual = row['y_target'].iloc[0]
285
+
286
+ predictions.append({
287
+ 'date': pred_date,
288
+ 'y_pred': y_pred,
289
+ 'y_current': y_current,
290
+ 'y_actual': y_actual,
291
+ 'pred_return': (y_pred / y_current) - 1 if y_current else None,
292
+ 'actual_return': (y_actual / y_current) - 1 if y_current else None,
293
+ 'train_end': train_end,
294
+ 'train_samples': len(train_data)
295
+ })
296
+
297
+ return predictions
298
+
299
+ def run_backtest(self, symbols: list[str]) -> tuple[BacktestMetrics, pd.DataFrame]:
300
+ """
301
+ Run full backtest for a symbol set.
302
+
303
+ Returns metrics and prediction DataFrame.
304
+ """
305
+ logger.info(f"Running backtest with {len(symbols)} symbols")
306
+
307
+ # Fetch prices
308
+ target = "HG=F"
309
+ all_symbols = list(set(symbols + [target]))
310
+ prices = self.fetch_prices(all_symbols, self.config.oos_start, self.config.oos_end)
311
+
312
+ # Prepare features
313
+ features = self.prepare_features(prices, target)
314
+
315
+ # Get trading days and retrain dates
316
+ trading_days = get_trading_days(self.config.oos_start, self.config.oos_end)
317
+ retrain_dates = get_retrain_dates(trading_days)
318
+
319
+ logger.info(f"OOS period: {self.config.oos_start} to {self.config.oos_end}")
320
+ logger.info(f"Retrain dates: {len(retrain_dates)}")
321
+
322
+ # Run rolling predictions
323
+ all_predictions = []
324
+
325
+ for i, retrain_date in enumerate(retrain_dates[:-1]):
326
+ next_retrain = retrain_dates[i + 1] if i + 1 < len(retrain_dates) else pd.Timestamp(self.config.oos_end)
327
+
328
+ # Predict for days between retrains
329
+ predict_dates = [d for d in trading_days if retrain_date <= d < next_retrain]
330
+
331
+ if predict_dates:
332
+ preds = self.train_and_predict(features, retrain_date, predict_dates)
333
+ all_predictions.extend(preds)
334
+
335
+ if not all_predictions:
336
+ raise ValueError("No predictions generated")
337
+
338
+ # Convert to DataFrame
339
+ pred_df = pd.DataFrame(all_predictions)
340
+ pred_df = pred_df.dropna(subset=['y_actual', 'y_pred'])
341
+
342
+ # Compute metrics
343
+ mae = np.abs(pred_df['y_actual'] - pred_df['y_pred']).mean()
344
+ rmse = np.sqrt(((pred_df['y_actual'] - pred_df['y_pred']) ** 2).mean())
345
+
346
+ # Directional accuracy
347
+ pred_df['dir_correct'] = (
348
+ np.sign(pred_df['pred_return']) == np.sign(pred_df['actual_return'])
349
+ )
350
+ dir_acc = pred_df['dir_correct'].mean()
351
+
352
+ metrics = BacktestMetrics(
353
+ mae=round(mae, 6),
354
+ rmse=round(rmse, 6),
355
+ directional_accuracy=round(dir_acc, 4),
356
+ n_predictions=len(pred_df),
357
+ mean_actual=round(pred_df['y_actual'].mean(), 4),
358
+ mean_predicted=round(pred_df['y_pred'].mean(), 4)
359
+ )
360
+
361
+ return metrics, pred_df
362
+
363
+ def compare(
364
+ self,
365
+ champion_set: SymbolSet,
366
+ challenger_set: SymbolSet
367
+ ) -> BacktestResult:
368
+ """
369
+ Run backtest for both sets and compare.
370
+ """
371
+ logger.info(f"=== CHAMPION: {champion_set.name} ({len(champion_set.symbols)} symbols) ===")
372
+ champion_metrics, champion_preds = self.run_backtest(champion_set.symbols)
373
+ champion_preds['symbol_set'] = 'champion'
374
+
375
+ logger.info(f"=== CHALLENGER: {challenger_set.name} ({len(challenger_set.symbols)} symbols) ===")
376
+ challenger_metrics, challenger_preds = self.run_backtest(challenger_set.symbols)
377
+ challenger_preds['symbol_set'] = 'challenger'
378
+
379
+ # Combine predictions
380
+ all_preds = pd.concat([champion_preds, challenger_preds], ignore_index=True)
381
+
382
+ # Compute deltas
383
+ delta_mae = ((champion_metrics.mae - challenger_metrics.mae) / champion_metrics.mae) * 100
384
+ delta_rmse = ((champion_metrics.rmse - challenger_metrics.rmse) / champion_metrics.rmse) * 100
385
+ delta_dir = ((challenger_metrics.directional_accuracy - champion_metrics.directional_accuracy) / champion_metrics.directional_accuracy) * 100
386
+
387
+ # Decision
388
+ if delta_mae >= self.config.promote_threshold_pct:
389
+ decision = "PROMOTE"
390
+ reason = f"Challenger MAE {delta_mae:.1f}% better than champion"
391
+ elif delta_mae <= self.config.reject_threshold_pct:
392
+ decision = "REJECT"
393
+ reason = f"Challenger MAE {-delta_mae:.1f}% worse than champion"
394
+ else:
395
+ decision = "MANUAL_REVIEW"
396
+ reason = f"MAE delta {delta_mae:.1f}% within threshold band"
397
+
398
+ result = BacktestResult(
399
+ run_id=self.run_id,
400
+ generated_at=datetime.utcnow().isoformat() + "Z",
401
+ config=self.config,
402
+ champion={
403
+ "symbol_set": asdict(champion_set),
404
+ "metrics": asdict(champion_metrics)
405
+ },
406
+ challenger={
407
+ "symbol_set": asdict(challenger_set),
408
+ "metrics": asdict(challenger_metrics)
409
+ },
410
+ delta_mae_pct=round(delta_mae, 2),
411
+ delta_rmse_pct=round(delta_rmse, 2),
412
+ delta_dir_acc_pct=round(delta_dir, 2),
413
+ decision=decision,
414
+ decision_reason=reason
415
+ )
416
+
417
+ return result, all_preds
418
+
419
+
420
+ def main():
421
+ parser = argparse.ArgumentParser(description="Champion/Challenger Backtest")
422
+ parser.add_argument("--champion", required=True, help="Path to champion symbol set JSON")
423
+ parser.add_argument("--challenger", required=True, help="Path to challenger symbol set JSON")
424
+ parser.add_argument("--output-dir", default="backend/artifacts/backtests", help="Output directory")
425
+ parser.add_argument("--oos-start", default="2024-01-01", help="OOS start date")
426
+ parser.add_argument("--oos-end", default="2025-01-17", help="OOS end date")
427
+ args = parser.parse_args()
428
+
429
+ # Load symbol sets
430
+ logger.info(f"Loading champion from: {args.champion}")
431
+ champion = load_symbol_set(args.champion)
432
+
433
+ logger.info(f"Loading challenger from: {args.challenger}")
434
+ challenger = load_symbol_set(args.challenger)
435
+
436
+ # Configure and run
437
+ config = BacktestConfig(
438
+ oos_start=args.oos_start,
439
+ oos_end=args.oos_end
440
+ )
441
+
442
+ runner = BacktestRunner(config)
443
+ result, predictions = runner.compare(champion, challenger)
444
+
445
+ # Create output directory
446
+ output_dir = Path(args.output_dir)
447
+ output_dir.mkdir(parents=True, exist_ok=True)
448
+
449
+ # Save report
450
+ report_path = output_dir / f"{result.run_id}_report.json"
451
+ with open(report_path, 'w') as f:
452
+ json.dump(asdict(result), f, indent=2, default=str)
453
+ logger.info(f"Report saved: {report_path}")
454
+
455
+ # Save predictions
456
+ preds_path = output_dir / f"{result.run_id}_predictions.csv"
457
+ predictions.to_csv(preds_path, index=False)
458
+ logger.info(f"Predictions saved: {preds_path}")
459
+
460
+ # Print summary
461
+ print("\n" + "=" * 60)
462
+ print(f"BACKTEST RESULT: {result.decision}")
463
+ print("=" * 60)
464
+ print(f"Champion MAE: {result.champion['metrics']['mae']:.6f}")
465
+ print(f"Challenger MAE: {result.challenger['metrics']['mae']:.6f}")
466
+ print(f"Delta MAE: {result.delta_mae_pct:+.2f}%")
467
+ print(f"Decision: {result.decision}")
468
+ print(f"Reason: {result.decision_reason}")
469
+ print("=" * 60)
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()
config/symbol_sets/champion.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "champion",
3
+ "description": "Current production symbol set (14 symbols)",
4
+ "version": "1.0.0",
5
+ "created_at": "2026-01-21T21:00:00Z",
6
+ "symbols": [
7
+ "CPER",
8
+ "COPX",
9
+ "PICK",
10
+ "FCX",
11
+ "SCCO",
12
+ "BHP",
13
+ "RIO",
14
+ "TECK",
15
+ "HBM.TO",
16
+ "DX-Y.NYB",
17
+ "CL=F",
18
+ "FXI",
19
+ "GC=F",
20
+ "^VIX"
21
+ ],
22
+ "notes": "Original 14-symbol set used in production"
23
+ }