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:
@@ -62,11 +62,15 @@ class BacktestReport:
|
||||
except AttributeError:
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
# VectorBT 1.1.0 的 pf.value() 可能返回数字位置 index(0..n-1)。
|
||||
# 统一用它对应的交易日 index(close 已是规范化后的 DatetimeIndex),
|
||||
# 长度相等时按位置对齐,保证 equity/drawdown 的日期语义正确。
|
||||
equity = pf.value()
|
||||
equity = pd.Series(equity.values, index=close.index[: len(equity)])
|
||||
|
||||
if not isinstance(equity.index, pd.DatetimeIndex):
|
||||
equity.index = pd.to_datetime(equity.index, format="%Y%m%d")
|
||||
if not isinstance(equity.index, pd.DatetimeIndex) and len(equity) == len(close):
|
||||
equity.index = close.index
|
||||
elif not isinstance(equity.index, pd.DatetimeIndex):
|
||||
# 长度不等时尽力用 close 前缀对齐,避免异常
|
||||
equity.index = close.index[: len(equity)]
|
||||
|
||||
dd = equity / equity.cummax() - 1
|
||||
daily_ret = equity.pct_change().dropna()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# ── 信号转换 ──────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user