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 扩充配置项
This commit is contained in:
Simon
2026-08-31 14:01:06 +08:00
parent 6acf938caf
commit 73d191b43a
28 changed files with 1418 additions and 373 deletions
+225 -24
View File
@@ -22,12 +22,18 @@ class VectorBTEngine:
def __init__(
self,
initial_capital: float = 100_000,
commission: float = 0.0003, # 万三
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
# ── 单股票回测 ────────────────────────────────────────
@@ -36,19 +42,36 @@ class VectorBTEngine:
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' 列
factor_df: 因子数据,index=trade_date。
None 时使用 price_df 作为因子数据源。
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
@@ -60,6 +83,11 @@ class VectorBTEngine:
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()
@@ -71,19 +99,90 @@ class VectorBTEngine:
# 4. 信号 → VectorBT entries/exits
entries, exits = self._signals_to_entries(raw_signals, price_df.index)
# 5. 运行回测
close = price_df["close"]
# 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(
close,
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, close)
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
# ── 截面回测(多股票) ──────────────────────────────────
@@ -97,41 +196,143 @@ class VectorBTEngine:
"""
截面策略回测(多股票 + 定期调仓)。
对每只股票独立回测,合并权益曲线。
真实组合语义(相对旧版"每股满额独立回测再等权平均"的关键修复):
- 每股先用策略信号驱动出每日持仓状态(T+1 成交);
- `rebalance_freq` 决定持仓只在调仓日更新('D'/'W'/'M');
- 组合总资金(initial_capital)在当日持仓股票间等权切分,
资金不会被重复分配/超限,是可联合投资的单一连续净值。
参数:
strategy: 策略实例
price_universe: {ts_code: price_df}
price_universe: {ts_code: price_df}(至少含 close;可含 open)
factor_universe: {ts_code: factor_df}
rebalance_freq: 调仓频率 'D'/'W'/'M',用于合并时对齐
rebalance_freq: 调仓频率 'D'/'W'/'M'(默认 'M' 月度)
返回:
BacktestReport
BacktestReport(组合级,equity_curve=组合净额曲线)
"""
if factor_universe is None:
factor_universe = price_universe
stock_equities = {}
stock_reports = {}
# 逐股票回测
# 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)
report = self.run(strategy, price_df, factor_df)
if report is not None and len(report.equity_curve) > 0:
stock_equities[ts_code] = report.equity_curve
stock_reports[ts_code] = report
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)
if not stock_equities:
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()
# 合并:等权分配资金到各股票
return self._merge_equities(stock_equities)
# 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,
)
# ── 信号转换 ──────────────────────────────────────────