Files
myquant/finance/backtest/vectorbt/engine.py
T
Simon 73d191b43a feat: 量化引擎加固 — 新增测试 + 数据/因子/回测层优化
- 新增 finance/tests/ 6 个测试套件(agents/backtest/dao_upsert/factors/features/fundamental_lookahead)
- 数据层: data_manager / dao 优化,新增 upsert 逻辑
- 因子层: 基本面因子抽象定位 _mapping、ROE/PE/PB 重构
- 回测层: vectorbt/engine 大改动(251 行),report 增强
- ML 层: features/backtest_integration 特征工程与回测优化
- CLI: agent_cli 重构
- config/settings 扩充配置项
2026-08-31 14:01:06 +08:00

425 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
)