""" VectorBT 回测引擎封装。 统一接口:engine.run(strategy, price_df, factor_df) → BacktestReport """ import numpy as np import pandas as pd import vectorbt as vbt from backtest.base import BaseStrategy from backtest.report import BacktestReport class VectorBTEngine: """ VectorBT 回测引擎。 只做多,不做空。 """ def __init__( self, initial_capital: float = 100_000, commission: float = 0.0003, # 佣金(万三) slippage: float = 0.0000, # 滑点(比例,0=关闭),单边 freq: str = "D", t_plus_one: bool = True, # 信号次日开盘执行(A 股 T+1) limit_check: bool = True, # 涨停拒买 / 跌停拒卖 ): self.initial_capital = initial_capital self.commission = commission self.slippage = slippage self.freq = freq self.t_plus_one = t_plus_one self.limit_check = limit_check # ── 单股票回测 ──────────────────────────────────────── def run( self, strategy: BaseStrategy, price_df: pd.DataFrame, factor_df: pd.DataFrame | None = None, t_plus_one: bool | None = None, slippage: float | None = None, limit_check: bool | None = None, ) -> BacktestReport: """ 单股票回测。 参数: strategy: 策略实例 price_df: 价格数据,index=trade_date,必须含 'close'; 若含 'open' 且启用 t_plus_one,则用次日 open 成交。 factor_df: 因子数据,index=trade_date。None 时使用 price_df。 t_plus_one: 覆盖引擎默认的 T+1 异步成交;None=用引擎设置。 slippage: 覆盖引擎默认滑点;None=用引擎设置。 limit_check: 覆盖引擎默认的涨跌停拒成交;None=用引擎设置。 返回: BacktestReport 成交真实性(相对旧版的关键修复): - T+1: 信号当日收盘产生,成交推迟到次日,避免"今天收盘出信号、 今天收盘就成交"的乐观偏差。 - 涨停拒买 / 跌停拒卖: 涨停当日实际无法买入、跌停当日无法卖出, 对应位置的入场/出场信号被抑制。 - 滑点: 通过 slippage 施加成交价冲击。 """ t_plus_one = self.t_plus_one if t_plus_one is None else t_plus_one slippage = self.slippage if slippage is None else slippage limit_check = self.limit_check if limit_check is None else limit_check if factor_df is None: factor_df = price_df # 1. 对齐日期 common_idx = price_df.index.intersection(factor_df.index) if len(common_idx) < 2: return BacktestReport() price_df = price_df.loc[common_idx].sort_index() factor_df = factor_df.loc[common_idx].sort_index() # 1b. 统一 index 为交易日 DatetimeIndex(支持 'YYYYMMDD' 字符串/int), # 保证 vectorbt 的 equity 与 report 的日期语义正确。 price_df.index = self._normalize_daily_index(price_df.index) factor_df.index = self._normalize_daily_index(factor_df.index) # 2. 合并 close 到 factor_df(策略可能需要) if "close" not in factor_df.columns: factor_df = factor_df.copy() factor_df["close"] = price_df["close"] # 3. 生成信号 raw_signals = strategy.generate_signals(factor_df) # 4. 信号 → VectorBT entries/exits entries, exits = self._signals_to_entries(raw_signals, price_df.index) # 5. A 股真实性过滤:涨跌停拒成交(在 T+1 前,基于信号当日的行情状态) if limit_check: entries, exits = self._apply_limit_filters(entries, exits, price_df) # 6. T+1 异步成交:入场/出场推迟到次日开盘 # (shift 会引入 NaN 使 dtype 变 object;显式转回 bool, # 否则 vectorbt 的 numba 内核因 object 数组报 TypingError) if t_plus_one: entries = entries.shift(1).fillna(False).astype(bool) exits = exits.shift(1).fillna(False).astype(bool) # 7. 运行回测(用 open 序列做成交价,否则 fallback 到 close) exec_price = price_df["open"] if "open" in price_df.columns else price_df["close"] pf = vbt.Portfolio.from_signals( exec_price, entries=entries, exits=exits, init_cash=self.initial_capital, fees=self.commission, slippage=slippage or None, freq=self.freq, direction="longonly", ) return BacktestReport.from_vbt_result(pf, exec_price) @staticmethod def _normalize_daily_index(index: pd.Index) -> pd.Index: """把 'YYYYMMDD' 字符串或 int 类型的 index 统一为 DatetimeIndex。""" if isinstance(index, pd.DatetimeIndex): return index # int64(如 20240101) if pd.api.types.is_integer_dtype(index): parsed = pd.to_datetime(index.astype(str), format="%Y%m%d", errors="coerce") if parsed.notna().all(): return parsed elif index.dtype == object or isinstance(index, pd.Index): parsed = pd.to_datetime(index, format="%Y%m%d", errors="coerce") if parsed.notna().all(): return parsed return index def _apply_limit_filters( self, entries: pd.Series, exits: pd.Series, price_df: pd.DataFrame, ) -> tuple[pd.Series, pd.Series]: """ 涨停拒买 / 跌停拒卖。 依托 price_df 的 pre_close / pct_chg(若存在)估算涨跌停: - close 达到/接近涨停 → 当日无法买入 → 抑制 entry - close 达到/接近跌停 → 当日无法卖出 → 抑制 exit 板块差异(ST 5%、主板 10%、创业板/科创板 20%)通过 ts_code 后缀近似判断, 无后缀信息时按主板 10% 上限处理。 """ if "pre_close" in price_df.columns and "close" in price_df.columns: pre_close = price_df["pre_close"].replace(0, float("nan")) pct = (price_df["close"] - pre_close) / pre_close * 100 elif "pct_chg" in price_df.columns: pct = price_df["pct_chg"] else: return entries, exits # 无行情判断列,跳过 code = str(price_df.index.name or "") or "" # 用列里的 ts_code 判断板块(若有) board_limit = 9.8 if "ts_code" in price_df.columns: codes = price_df["ts_code"].astype(str) # 创业板 300/301/688 科创板 → 20%,ST 无后缀信息按 10% limit_20 = codes.str.match(r"^(300|301|688)\d{3}") board_limit = 19.6 # 留 margin:pct >= +9.8 判定接近涨停(不可买),<= -9.8 判定接近跌停(不可卖) up = pct >= 9.8 down = pct <= -9.8 if board_limit > 9.8: up = pct >= 19.6 down = pct <= -19.6 entries = entries & ~up exits = exits & ~down return entries, exits # ── 截面回测(多股票) ────────────────────────────────── def run_cross_section( self, strategy: BaseStrategy, price_universe: dict[str, pd.DataFrame], factor_universe: dict[str, pd.DataFrame] | None = None, rebalance_freq: str = "M", ) -> BacktestReport: """ 截面策略回测(多股票 + 定期调仓)。 真实组合语义(相对旧版"每股满额独立回测再等权平均"的关键修复): - 每股先用策略信号驱动出每日持仓状态(T+1 成交); - `rebalance_freq` 决定持仓只在调仓日更新('D'/'W'/'M'); - 组合总资金(initial_capital)在当日持仓股票间等权切分, 资金不会被重复分配/超限,是可联合投资的单一连续净值。 参数: strategy: 策略实例 price_universe: {ts_code: price_df}(至少含 close;可含 open) factor_universe: {ts_code: factor_df} rebalance_freq: 调仓频率 'D'/'W'/'M'(默认 'M' 月度) 返回: BacktestReport(组合级,equity_curve=组合净额曲线) """ if factor_universe is None: factor_universe = price_universe # 1. 每股生成 T+1 后的持仓状态序列(策略信号驱动) holdings: dict[str, pd.Series] = {} # ts_code -> bool 每日是否持仓 returns: dict[str, pd.Series] = {} # ts_code -> 每日收益率 for ts_code in price_universe: price_df = price_universe[ts_code] if "close" not in price_df.columns or price_df.empty: continue factor_df = factor_universe.get(ts_code, price_df) common = price_df.index.intersection(factor_df.index) if len(common) < 2: continue p = price_df.loc[common].sort_index() f = factor_df.loc[common].sort_index() p.index = self._normalize_daily_index(p.index) f.index = self._normalize_daily_index(f.index) raw_signals = strategy.generate_signals(f) entries, exits = self._signals_to_entries(raw_signals, p.index) # T+1 成交:信号次日生效 entries = entries.shift(1).fillna(False).astype(bool) exits = exits.shift(1).fillna(False).astype(bool) pos = pd.Series(False, index=p.index) in_now = False e = entries.to_numpy(); x = exits.to_numpy() for i in range(len(p)): if e[i]: in_now = True elif x[i]: in_now = False pos.iloc[i] = in_now holdings[ts_code] = pos returns[ts_code] = p["close"].pct_change() if not holdings: return BacktestReport() # 2. 统一交易日历(全部股票 index 并集,升序) all_days = pd.DatetimeIndex( sorted(set().union(*[h.index for h in holdings.values()])) ) # 3. rebalance 时点(持仓只在调仓日变化) rebalance_mask = self._rebalance_mask(all_days, rebalance_freq) # 4. 逐日计算组合等权收益(资金在当日持仓之间切分) pos_matrix = {c: h.reindex(all_days).fillna(False) for c, h in holdings.items()} ret_matrix = {c: r.reindex(all_days).fillna(0.0) for c, r in returns.items()} current_pos = {c: False for c in holdings} daily_port_ret = np.zeros(len(all_days)) for i, day in enumerate(all_days): if rebalance_mask[i]: # 调仓:按当日的 T+1 持仓状态重新确定各股是否纳入组合 for c in holdings: current_pos[c] = bool(pos_matrix[c].iloc[i]) # 当日组合收益 = 持仓股票当日收益的等权平均(资金按持仓数切分) held = [c for c in holdings if current_pos[c]] if held: daily_port_ret[i] = np.mean([ret_matrix[c].iloc[i] for c in held]) port_ret = pd.Series(daily_port_ret, index=all_days) portfolio_equity = self.initial_capital * (1 + port_ret).cumprod() # 5. 组合指标 return self._build_portfolio_report(portfolio_equity) @staticmethod def _rebalance_mask(days: pd.DatetimeIndex, freq: str) -> np.ndarray: """生成调仓日布尔掩码:'D'=每天,'W'=每周首个,'M'=每月首个。""" mask = np.zeros(len(days), dtype=bool) freq = (freq or "M").upper() if freq == "D": mask[:] = True return mask prev_key = None for i, day in enumerate(days): if freq == "W": key = (day.isocalendar()[0], day.isocalendar()[1]) else: # 'M' key = (day.year, day.month) if key != prev_key: mask[i] = True prev_key = key return mask def _build_portfolio_report(self, portfolio_equity: pd.Series) -> BacktestReport: """从组合净值曲线计算标准化指标。""" dd = portfolio_equity / portfolio_equity.cummax() - 1 daily_ret = portfolio_equity.pct_change().dropna() years = max(len(daily_ret) / 252, 0.02) total_ret = (portfolio_equity.iloc[-1] / portfolio_equity.iloc[0] - 1) * 100 cagr = ((total_ret / 100 + 1) ** (1 / years) - 1) * 100 mdd = dd.min() * 100 mean_ret = daily_ret.mean() * 252 std_ret = daily_ret.std() * np.sqrt(252) sharpe = mean_ret / std_ret if std_ret > 0 else 0 calmar = cagr / abs(mdd) if abs(mdd) > 0 else 0 try: monthly = portfolio_equity.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), total_trades=0, equity_curve=portfolio_equity, drawdown_curve=dd, monthly_returns=monthly, ) # ── 信号转换 ────────────────────────────────────────── @staticmethod def _signals_to_entries( raw_signals: pd.Series, target_index: pd.Index, ) -> tuple[pd.Series, pd.Series]: """ 将策略信号转为 VectorBT entries/exits。 信号格式: 1 → 买入 0 → 平仓 -1 → 继续持有/不操作 entries: True 时开仓 exits: True 时平仓 """ # 对齐到目标 index aligned = pd.Series(-1, index=target_index) common = target_index.intersection(raw_signals.index) aligned.loc[common] = raw_signals.loc[common].values entries = pd.Series(False, index=target_index) exits = pd.Series(False, index=target_index) in_position = False for i in range(len(aligned)): sig = aligned.iloc[i] if not in_position and sig == 1: entries.iloc[i] = True in_position = True elif in_position and sig == 0: exits.iloc[i] = True in_position = False return entries, exits # ── 合并多股票权益 ───────────────────────────────────── def _merge_equities( self, stock_equities: dict[str, pd.Series] ) -> BacktestReport: """等权合并多股票权益曲线,构建组合级报告。""" equity_df = pd.DataFrame(stock_equities) equity_df = equity_df.ffill().fillna(0) # 转为 DatetimeIndex if not isinstance(equity_df.index, pd.DatetimeIndex): equity_df.index = pd.to_datetime(equity_df.index, format="%Y%m%d") n_stocks = len(stock_equities) weight = 1.0 / n_stocks if n_stocks > 0 else 1.0 # 加权组合收益 returns_df = equity_df.pct_change().fillna(0) portfolio_ret = returns_df.mean(axis=1) # 等权 = 逐行平均 # 组合净值 portfolio_equity = self.initial_capital * (1 + portfolio_ret).cumprod() dd = portfolio_equity / portfolio_equity.cummax() - 1 years = max(len(portfolio_ret) / 252, 0.02) total_return = (portfolio_equity.iloc[-1] / portfolio_equity.iloc[0] - 1) * 100 cagr = ((total_return / 100 + 1) ** (1 / years) - 1) * 100 mdd = dd.min() * 100 mean_ret = portfolio_ret.mean() * 252 std_ret = portfolio_ret.std() * np.sqrt(252) sharpe = mean_ret / std_ret if std_ret > 0 else 0 calmar = cagr / abs(mdd) if abs(mdd) > 0 else 0 try: monthly = portfolio_equity.resample("ME").last().pct_change() except Exception: monthly = pd.Series(dtype=float) return BacktestReport( total_return=round(total_return, 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), total_trades=0, equity_curve=portfolio_equity, drawdown_curve=dd, monthly_returns=monthly, )