""" Optuna 优化引擎。 统一接口:optimizer.optimize(strategy_class, space, price_df, factor_df) → OptimizationResult """ import copy import time import numpy as np import optuna import pandas as pd from backtest.base import BaseStrategy from backtest.report import BacktestReport from backtest.vectorbt.engine import VectorBTEngine from optimizer.objectives import Objective from optimizer.result import OptimizationResult, WalkForwardResult from optimizer.space import SearchSpace # 抑制 Optuna 日志 optuna.logging.set_verbosity(optuna.logging.WARNING) class OptunaEngine: """ Optuna 优化引擎。 """ def __init__(self, bt_engine: VectorBTEngine | None = None): self.bt_engine = bt_engine or VectorBTEngine() def optimize( self, strategy_class: type[BaseStrategy], search_space: SearchSpace, price_df: pd.DataFrame, factor_df: pd.DataFrame | None = None, metric: str = "sharpe", n_trials: int = 100, direction: str = "maximize", sampler: optuna.samplers.BaseSampler | None = None, ) -> OptimizationResult: """ 参数寻优。 参数: strategy_class: 策略类 search_space: 搜索空间 price_df: 价格数据 factor_df: 因子数据 metric: 优化目标 n_trials: 试验次数 direction: 'maximize' | 'minimize' sampler: Optuna 采样器,默认 TPESampler """ if sampler is None: sampler = optuna.samplers.TPESampler(seed=42) study = optuna.create_study( direction=direction, sampler=sampler, ) objective = Objective( strategy_class=strategy_class, search_space=search_space, price_df=price_df, factor_df=factor_df, bt_engine=self.bt_engine, metric=metric, ) t0 = time.time() study.optimize(objective, n_trials=n_trials, show_progress_bar=True) elapsed = time.time() - t0 # 用最优参数跑一次完整回测 best_params = study.best_params try: best_strategy = strategy_class(**best_params) except TypeError: valid = {k: v for k, v in best_params.items() if k in strategy_class.__init__.__code__.co_varnames} best_strategy = strategy_class(**valid) best_report = self.bt_engine.run( best_strategy, price_df, price_df if factor_df is None else factor_df, ) # 参数重要性 try: importance = optuna.importance.get_param_importances(study) except Exception: importance = {} # 试验记录 trials_df = study.trials_dataframe() return OptimizationResult( best_params=study.best_params, best_value=study.best_value, metric=metric, best_report=best_report, trials_df=trials_df, param_importance=importance, ) def optimize_walk_forward( self, strategy_class: type[BaseStrategy], search_space: SearchSpace, price_df: pd.DataFrame, factor_df: pd.DataFrame | None = None, metric: str = "sharpe", n_trials: int = 100, train_window: int = 252 * 3, test_window: int = 252, ) -> WalkForwardResult: """ 滚动窗口优化(Walk-Forward Analysis)。 每一步:train_window 训练 → test_window 验证 → 滑动。 """ if factor_df is None: factor_df = price_df n_total = len(price_df) windows = [] param_history = [] # 跨窗口结转资本:consolidated 是连续可投资的净值曲线, # 每个测试窗口的收益按期初已积累资本放大,而非各自从 100k 独立重算。 running_capital = float(self.bt_engine.initial_capital) equity_parts: list[pd.Series] = [] start = 0 while start + train_window + test_window <= n_total: train_slice = slice(start, start + train_window) test_slice = slice(start + train_window, start + train_window + test_window) train_price = price_df.iloc[train_slice] train_factor = factor_df.iloc[train_slice] test_price = price_df.iloc[test_slice] test_factor = factor_df.iloc[test_slice] # 训练集上优化 opt_result = self.optimize( strategy_class=strategy_class, search_space=search_space, price_df=train_price, factor_df=train_factor, metric=metric, n_trials=n_trials, ) # 测试集上验证 try: test_strategy = strategy_class(**opt_result.best_params) except TypeError: valid = {k: v for k, v in opt_result.best_params.items() if k in strategy_class.__init__.__code__.co_varnames} test_strategy = strategy_class(**valid) test_report = self.bt_engine.run(test_strategy, test_price, test_factor) # 该窗口的相对收益 → 用累计资本放大 → 连续资本曲线 eq = test_report.equity_curve if eq is not None and len(eq) > 0: window_ret = eq.pct_change().fillna(0.0) # 用上一窗口末累计资本作基准放大本窗口收益 window_capital = running_capital * (1 + window_ret).cumprod() equity_parts.append(window_capital) running_capital = float(window_capital.iloc[-1]) train_idx = train_price.index test_idx = test_price.index windows.append({ "train_start": train_idx[0] if len(train_idx) > 0 else "", "train_end": train_idx[-1] if len(train_idx) > 0 else "", "test_start": test_idx[0] if len(test_idx) > 0 else "", "test_end": test_idx[-1] if len(test_idx) > 0 else "", "best_params": opt_result.best_params, "best_value": opt_result.best_value, "test_return": test_report.total_return, "test_sharpe": test_report.sharpe_ratio, "test_mdd": test_report.max_drawdown, }) param_history.append(opt_result.best_params) start += test_window # 合并测试期权益曲线(跨窗口结转后的连续净值) consolidated = _merge_test_periods(equity_parts) # 参数稳定性 param_df = pd.DataFrame(param_history) if param_history else pd.DataFrame() if not param_df.empty: param_df.index.name = "window" return WalkForwardResult( windows=windows, consolidated_report=consolidated, param_stability=param_df, ) def _merge_test_periods( equity_parts: list[pd.Series], ) -> BacktestReport | None: """拼接已跨窗口结转资本的测试期净值片段为连续序列。""" if not equity_parts: return None merged = pd.concat(equity_parts) merged = merged[~merged.index.duplicated(keep="last")].sort_index() # 确保 DatetimeIndex if not isinstance(merged.index, pd.DatetimeIndex): parsed = pd.to_datetime(merged.index, format="%Y%m%d", errors="coerce") if parsed.notna().all(): merged.index = parsed dd = merged / merged.cummax() - 1 daily_ret = merged.pct_change().dropna() years = max(len(daily_ret) / 252, 0.02) total_ret = (merged.iloc[-1] / merged.iloc[0] - 1) * 100 cagr = ((total_ret / 100 + 1) ** (1 / years) - 1) * 100 mdd = dd.min() * 100 std_ret = daily_ret.std() * np.sqrt(252) sharpe = (daily_ret.mean() * 252) / std_ret if std_ret > 0 else 0 calmar = cagr / abs(mdd) if abs(mdd) > 0 else 0 try: monthly = merged.resample("ME").last().pct_change() except Exception: monthly = pd.Series(dtype=float) return BacktestReport( total_return=round(total_ret, 2), cagr=round(cagr, 2), max_drawdown=round(mdd, 2), sharpe_ratio=round(sharpe, 2), calmar_ratio=round(calmar, 2), annual_volatility=round(std_ret * 100 if std_ret != 0 else 0, 2), equity_curve=merged, drawdown_curve=dd, monthly_returns=monthly, )