diff --git a/rdagent/components/backtesting/__init__.py b/rdagent/components/backtesting/__init__.py new file mode 100644 index 00000000..ee7db824 --- /dev/null +++ b/rdagent/components/backtesting/__init__.py @@ -0,0 +1,6 @@ +"""Predix Backtesting Package""" +from .backtest_engine import BacktestMetrics, FactorBacktester +from .results_db import ResultsDatabase +from .risk_management import CorrelationAnalyzer, PortfolioOptimizer, AdvancedRiskManager +__all__ = ['BacktestMetrics', 'FactorBacktester', 'ResultsDatabase', + 'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager'] diff --git a/rdagent/components/backtesting/backtest_engine.py b/rdagent/components/backtesting/backtest_engine.py new file mode 100644 index 00000000..e4bce72a --- /dev/null +++ b/rdagent/components/backtesting/backtest_engine.py @@ -0,0 +1,85 @@ +""" +Predix Backtesting Engine - IC, Sharpe, Drawdown +""" +import numpy as np +import pandas as pd +from pathlib import Path +from typing import Dict, Optional +from datetime import datetime +import json + +class BacktestMetrics: + def __init__(self, risk_free_rate: float = 0.02): + self.risk_free_rate = risk_free_rate + + def calculate_ic(self, factor_values: pd.Series, forward_returns: pd.Series) -> float: + mask = factor_values.notna() & forward_returns.notna() + if mask.sum() < 10: return np.nan + return factor_values[mask].corr(forward_returns[mask]) + + def calculate_sharpe(self, returns: pd.Series, annualize: bool = True) -> float: + if len(returns) < 10 or returns.std() == 0: return np.nan + sharpe = (returns.mean() - self.risk_free_rate/252) / returns.std() + return sharpe * np.sqrt(252) if annualize else sharpe + + def calculate_max_drawdown(self, equity: pd.Series) -> float: + running_max = equity.cummax() + drawdown = (equity - running_max) / running_max + return float(drawdown.min()) + + def calculate_all(self, returns: pd.Series, equity: pd.Series, + factor_values: Optional[pd.Series] = None, + forward_returns: Optional[pd.Series] = None) -> Dict: + metrics = { + 'total_return': float((1 + returns).prod() - 1), + 'annualized_return': float(returns.mean() * 252), + 'sharpe_ratio': self.calculate_sharpe(returns), + 'max_drawdown': self.calculate_max_drawdown(equity), + 'win_rate': float((returns > 0).mean()), + 'total_trades': len(returns), + } + if factor_values is not None and forward_returns is not None: + metrics['ic'] = self.calculate_ic(factor_values, forward_returns) + return metrics + +class FactorBacktester: + def __init__(self): + self.metrics = BacktestMetrics() + self.results_path = Path(__file__).parent.parent.parent / "results" / "backtests" + self.results_path.mkdir(parents=True, exist_ok=True) + + def run_backtest(self, factor_values: pd.Series, forward_returns: pd.Series, + factor_name: str, transaction_cost: float = 0.00015) -> Dict: + ic = self.metrics.calculate_ic(factor_values, forward_returns) + signals = np.sign(factor_values) + strategy_returns = signals.shift(1) * forward_returns - transaction_cost + equity = (1 + strategy_returns).cumprod() + + metrics = self.metrics.calculate_all(strategy_returns, equity, factor_values, forward_returns) + metrics['ic'] = ic if not np.isnan(ic) else np.nan + metrics['factor_name'] = factor_name + metrics['timestamp'] = datetime.now().isoformat() + + # Speichern + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = factor_name.replace("/", "_") + + with open(self.results_path / f"{safe_name}_{timestamp}.json", 'w') as f: + json.dump({k: (None if isinstance(v, float) and np.isnan(v) else v) for k, v in metrics.items()}, f, indent=2) + + return metrics + +if __name__ == "__main__": + print("=== Backtest Test ===") + np.random.seed(42) + n = 252 + factor = pd.Series(np.random.randn(n)) + fwd_ret = pd.Series(np.random.randn(n) * 0.01 + 0.0001) + + backtester = FactorBacktester() + metrics = backtester.run_backtest(factor, fwd_ret, "TestFactor") + + print(f"IC: {metrics.get('ic', np.nan):.4f}") + print(f"Sharpe: {metrics.get('sharpe_ratio', np.nan):.4f}") + print(f"Win Rate: {metrics.get('win_rate', np.nan):.4f}") + print("✅ Test bestanden!") diff --git a/rdagent/components/backtesting/results_db.py b/rdagent/components/backtesting/results_db.py new file mode 100644 index 00000000..5a8c4b66 --- /dev/null +++ b/rdagent/components/backtesting/results_db.py @@ -0,0 +1,88 @@ +""" +Predix Results Database - SQLite für Backtest-Ergebnisse +""" +import sqlite3 +import pandas as pd +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional + +class ResultsDatabase: + def __init__(self, db_path: Optional[str] = None): + if db_path is None: + db_path = Path(__file__).parent.parent.parent / "results" / "db" / "backtest_results.db" + self.db_path = db_path + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(db_path) + self._create_tables() + + def _create_tables(self): + c = self.conn.cursor() + c.execute("""CREATE TABLE IF NOT EXISTS factors ( + id INTEGER PRIMARY KEY, factor_name TEXT UNIQUE, factor_type TEXT, created_at TIMESTAMP)""") + c.execute("""CREATE TABLE IF NOT EXISTS backtest_runs ( + id INTEGER PRIMARY KEY, factor_id INTEGER, run_name TEXT, run_date TIMESTAMP, + ic REAL, sharpe REAL, annual_return REAL, max_drawdown REAL, win_rate REAL)""") + c.execute("""CREATE TABLE IF NOT EXISTS loop_results ( + id INTEGER PRIMARY KEY, loop_index INTEGER, factors_success INTEGER, + factors_fail INTEGER, success_rate REAL, best_ic REAL, status TEXT)""") + self.conn.commit() + + def add_factor(self, name: str, type: str = "unknown") -> int: + c = self.conn.cursor() + c.execute("INSERT OR IGNORE INTO factors (factor_name, factor_type, created_at) VALUES (?, ?, ?)", + (name, type, datetime.now())) + c.execute("SELECT id FROM factors WHERE factor_name = ?", (name,)) + self.conn.commit() + result = c.fetchone() + return result[0] if result else -1 + + def add_backtest(self, factor_name: str, metrics: Dict) -> int: + factor_id = self.add_factor(factor_name) + c = self.conn.cursor() + c.execute("""INSERT INTO backtest_runs + (factor_id, run_name, run_date, ic, sharpe, annual_return, max_drawdown, win_rate) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (factor_id, f"{factor_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}", + datetime.now(), metrics.get('ic'), metrics.get('sharpe_ratio'), + metrics.get('annualized_return'), metrics.get('max_drawdown'), metrics.get('win_rate'))) + self.conn.commit() + return c.lastrowid + + def add_loop(self, loop_idx: int, success: int, fail: int, best_ic: float = None, status: str = "completed") -> int: + c = self.conn.cursor() + rate = success / (success + fail) if (success + fail) > 0 else 0 + c.execute("""INSERT INTO loop_results (loop_index, factors_success, factors_fail, success_rate, best_ic, status) + VALUES (?, ?, ?, ?, ?, ?)""", (loop_idx, success, fail, rate, best_ic, status)) + self.conn.commit() + return c.lastrowid + + def get_top_factors(self, metric: str = 'sharpe', limit: int = 20) -> pd.DataFrame: + return pd.read_sql_query(f"""SELECT factor_name, {metric}, ic, annual_return, max_drawdown + FROM backtest_runs JOIN factors ON factor_id = factors.id + WHERE {metric} IS NOT NULL ORDER BY {metric} DESC LIMIT ?""", + self.conn, params=[limit]) + + def get_aggregate_stats(self) -> Dict: + c = self.conn.cursor() + c.execute("""SELECT COUNT(DISTINCT factor_name), AVG(ic), MAX(sharpe), AVG(annual_return) + FROM backtest_runs JOIN factors ON factor_id = factors.id""") + r = c.fetchone() + return {'total_factors': r[0], 'avg_ic': r[1], 'max_sharpe': r[2], 'avg_return': r[3]} + + def close(self): + self.conn.close() + +if __name__ == "__main__": + print("=== DB Test ===") + db = ResultsDatabase() + db.add_factor("TestFactor", "Momentum") + db.add_backtest("TestFactor", {'ic': 0.05, 'sharpe_ratio': 1.5, 'annualized_return': 0.15, 'max_drawdown': -0.08, 'win_rate': 0.55}) + db.add_loop(1, 4, 6, 0.05, "completed") + + print("Top Faktoren:") + print(db.get_top_factors()) + print("\nAggregate Stats:") + print(db.get_aggregate_stats()) + db.close() + print("✅ Test bestanden!") diff --git a/rdagent/components/backtesting/risk_management.py b/rdagent/components/backtesting/risk_management.py new file mode 100644 index 00000000..7d2b8733 --- /dev/null +++ b/rdagent/components/backtesting/risk_management.py @@ -0,0 +1,89 @@ +""" +Predix Risk Management - Korrelation, Portfolio-Optimierung +""" +import numpy as np +import pandas as pd +from pathlib import Path +from typing import Dict, List, Optional +from datetime import datetime +import json + +class CorrelationAnalyzer: + def __init__(self, lookback: int = 60): + self.lookback = lookback + + def calculate_matrix(self, returns: pd.DataFrame) -> pd.DataFrame: + return returns.dropna().corr() + + def find_uncorrelated(self, corr: pd.DataFrame, threshold: float = 0.3) -> List[str]: + result = [] + for f in corr.columns: + others = [x for x in corr.columns if x != f] + if corr.loc[f, others].abs().mean() < threshold: + result.append(f) + return result + +class PortfolioOptimizer: + def mean_variance(self, exp_ret: pd.Series, cov: pd.DataFrame) -> np.ndarray: + try: + w = np.linalg.inv(cov.values) @ exp_ret.values + return w / np.sum(w) + except: + return np.ones(len(exp_ret)) / len(exp_ret) + + def risk_parity(self, cov: pd.DataFrame, max_iter: int = 100) -> np.ndarray: + n = cov.shape[0] + w = np.ones(n) / n + for _ in range(max_iter): + marginal = cov.values @ w + vol = np.sqrt(w @ cov.values @ w) + if vol == 0: break + risk_contrib = w * marginal / vol + scale = np.sum(risk_contrib) / (n * risk_contrib + 1e-10) + new_w = w * scale + new_w = new_w / np.sum(new_w) + if np.max(np.abs(new_w - w)) < 1e-6: break + w = new_w + return w + +class AdvancedRiskManager: + def __init__(self, max_pos: float = 0.2, max_lev: float = 5.0, max_dd: float = 0.20): + self.max_pos = max_pos + self.max_lev = max_lev + self.max_dd = max_dd + self.corr_analyzer = CorrelationAnalyzer() + self.optimizer = PortfolioOptimizer() + + def check_limits(self, weights: np.ndarray, vol: float, dd: float) -> Dict[str, bool]: + return { + 'position_limit': np.max(np.abs(weights)) <= self.max_pos, + 'leverage_limit': np.sum(np.abs(weights)) <= self.max_lev, + 'drawdown_limit': abs(dd) <= self.max_dd, + } + +if __name__ == "__main__": + print("=== Risk Test ===") + np.random.seed(42) + n, names = 252, ['Mom', 'MeanRev', 'Vol', 'Volu', 'ML'] + ret = pd.DataFrame(np.random.randn(n, 5), columns=names) + + corr = CorrelationAnalyzer().calculate_matrix(ret) + print("Korrelationsmatrix:") + print(corr.round(2)) + + opt = PortfolioOptimizer() + exp_ret = pd.Series([0.1, 0.08, 0.06, 0.07, 0.12], index=names) + cov = ret.cov() * 252 + + mv = opt.mean_variance(exp_ret, cov) + print("\nMean-Variance:") + for n, w in zip(names, mv): print(f" {n}: {w:.2%}") + + rp = opt.risk_parity(cov) + print("\nRisk Parity:") + for n, w in zip(names, rp): print(f" {n}: {w:.2%}") + + rm = AdvancedRiskManager() + checks = rm.check_limits(mv, 0.15, -0.08) + print(f"\nLimits OK: {all(checks.values())}") + print("✅ Test bestanden!")