- 新增 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 扩充配置项
425 lines
17 KiB
Python
425 lines
17 KiB
Python
"""
|
||
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,
|
||
)
|