"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。 纯 Polars 向量化实现,无 pandas 依赖。 """ from __future__ import annotations import logging import time import uuid from dataclasses import dataclass, field from datetime import date, timedelta from typing import Literal import numpy as np import polars as pl from app.backtest.engine import BacktestEngine logger = logging.getLogger(__name__) # 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标) FACTOR_COLUMNS: list[dict] = [ {"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"}, {"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"}, {"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"}, {"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"}, {"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"}, {"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"}, {"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"}, {"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"}, {"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"}, {"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"}, {"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"}, {"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"}, {"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"}, {"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"}, {"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"}, {"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"}, ] FACTOR_WARMUP_DAYS = 120 @dataclass class FactorConfig: factor_name: str symbols: list[str] | None start: date end: date n_groups: int = 5 rebalance: Literal["daily", "weekly", "monthly"] = "monthly" weight: Literal["equal", "factor_weight"] = "equal" fees_pct: float = 0.0002 slippage_bps: float = 5.0 @dataclass class GroupStats: group: int label: str total_return: float annual_return: float max_drawdown: float sharpe: float win_rate: float @dataclass class FactorResult: run_id: str config: dict # IC 分析 ic_mean: float | None = None ic_std: float | None = None ir: float | None = None ic_win_rate: float | None = None ic_series: list[dict] = field(default_factory=list) # 分层 group_stats: list[dict] = field(default_factory=list) group_nav: list[dict] = field(default_factory=list) # 多空 long_short_stats: dict = field(default_factory=dict) long_short_nav: list[dict] = field(default_factory=list) # 元信息 elapsed_ms: float = 0.0 n_symbols: int = 0 n_dates: int = 0 error: str | None = None class FactorBacktestService: def __init__(self, engine: BacktestEngine) -> None: self.engine = engine def run(self, config: FactorConfig) -> FactorResult: t0 = time.perf_counter() run_id = uuid.uuid4().hex[:10] def _err(msg: str) -> FactorResult: return FactorResult( run_id=run_id, config=self._config_to_dict(config), error=msg, elapsed_ms=(time.perf_counter() - t0) * 1000, ) # 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。 panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"] if config.factor_name not in panel_columns: panel_columns.append(config.factor_name) load_start = config.start if config.factor_name not in {"turnover_rate"}: load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS) panel = self.engine.load_panel( config.symbols, load_start, config.end, columns=panel_columns, ) if panel.is_empty(): return _err("无数据,请检查日期范围或先运行盘后管道") factor_col = config.factor_name if factor_col not in panel.columns: panel = self._compute_missing_factor(panel, factor_col) if factor_col not in panel.columns: return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算") if "close" not in panel.columns: return _err("enriched 数据缺少收盘价 close") panel = panel.select(["symbol", "date", "close", factor_col]) panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end)) # 过滤有效行 panel = panel.filter( pl.col(factor_col).is_not_null() & pl.col("close").is_not_null() & (pl.col("close") > 0) ) if panel.is_empty(): return _err("过滤后无有效数据") n_symbols = panel["symbol"].n_unique() n_dates = panel["date"].n_unique() # 计算下期收益 # 根据调仓频率计算不同周期的 forward return if config.rebalance == "daily": panel = panel.with_columns( (pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1) .alias("_next_return") ) else: # weekly/monthly: 计算到下个调仓日的收益 panel = self._calc_period_return(panel, config.rebalance) # ── 1. IC 分析 ── ic_df = self._calc_ic(panel, factor_col) ic_series = [ {"date": str(row["date"]), "ic": round(float(row["ic"]), 4)} for row in ic_df.iter_rows(named=True) if row["ic"] is not None and not np.isnan(float(row["ic"])) ] ic_values = [r["ic"] for r in ic_series] ic_mean = float(np.mean(ic_values)) if ic_values else None ic_std = float(np.std(ic_values)) if ic_values else None ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None # ── 2. 分层回测 ── panel = self._add_groups(panel, factor_col, config.n_groups) group_nav = self._calc_group_nav(panel, config) group_stats = self._calc_group_stats(group_nav, config.start, config.end) # ── 3. 多空组合 ── long_short_nav, long_short_stats = self._calc_long_short(group_nav, config) elapsed = (time.perf_counter() - t0) * 1000 return FactorResult( run_id=run_id, config=self._config_to_dict(config), ic_mean=round(ic_mean, 4) if ic_mean is not None else None, ic_std=round(ic_std, 4) if ic_std is not None else None, ir=round(ir, 4) if ir is not None else None, ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None, ic_series=ic_series, group_stats=group_stats, group_nav=group_nav, long_short_stats=long_short_stats, long_short_nav=long_short_nav, elapsed_ms=round(elapsed, 1), n_symbols=n_symbols, n_dates=n_dates, ) @staticmethod def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame: required = {"symbol", "date", "open", "high", "low", "close", "volume"} if not required.issubset(panel.columns): missing = sorted(required - set(panel.columns)) logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing) return panel from app.indicators.pipeline import compute_indicators computed = compute_indicators(panel) if factor_col not in computed.columns: return panel return computed.select(["symbol", "date", "close", factor_col]) # ── IC 计算 ── @staticmethod def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame: """计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。""" return ( panel.filter(pl.col("_next_return").is_not_null()) .group_by("date") .agg( pl.corr( pl.col(factor_col).rank(method="random"), pl.col("_next_return").rank(method="random"), ).alias("ic") ) .sort("date") ) # ── 调仓期收益 ── @staticmethod def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame: """计算到下个调仓日的收益。 weekly: 下周一的 open / 今日 close - 1 monthly: 下月首个交易日的 open / 今日 close - 1 只在调仓日标记行有效,其他行为 null。 """ import datetime as _dt all_dates = sorted(panel["date"].unique().to_list()) date_set = set(all_dates) if rebalance == "weekly": # 调仓日 = 每周一 rebalance_dates = set() for d in all_dates: if hasattr(d, "weekday"): wd = d.weekday() else: wd = _dt.date.fromisoformat(str(d)).weekday() if wd == 0: # Monday rebalance_dates.add(d) else: # monthly # 调仓日 = 每月首个交易日 seen_months: set[str] = set() rebalance_dates = set() for d in sorted(all_dates): m = str(d)[:7] # "YYYY-MM" if m not in seen_months: seen_months.add(m) rebalance_dates.add(d) if not rebalance_dates: panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return")) return panel # 对每个调仓日,找到下一个调仓日 sorted_rebalance = sorted(rebalance_dates) next_rebalance_map: dict = {} for i, d in enumerate(sorted_rebalance): if i + 1 < len(sorted_rebalance): next_rebalance_map[d] = sorted_rebalance[i + 1] # 最后一个调仓日没有下一个,不计算收益 # 构建 (date, symbol) → next_rebalance_date 的 close 价格映射 # 简化: 用下个调仓日的 close / 当前 close panel = panel.sort(["symbol", "date"]) dates_col = panel["date"].to_list() close_col = panel["close"].to_list() symbol_col = panel["symbol"].to_list() # 先找下个调仓日的 close # 建立 (date, symbol) → close 的快速查找 price_map: dict[tuple, float] = {} for i in range(len(dates_col)): price_map[(str(dates_col[i]), symbol_col[i])] = close_col[i] next_returns = [None] * len(panel) for i in range(len(panel)): d = dates_col[i] d_val = d if isinstance(d, _dt.date) else _dt.date.fromisoformat(str(d)) if d not in rebalance_dates: continue next_d = next_rebalance_map.get(d) if next_d is None: continue next_d_str = str(next_d)[:10] d_str = str(d)[:10] sym = symbol_col[i] next_close = price_map.get((next_d_str, sym)) cur_close = close_col[i] if next_close is not None and cur_close and cur_close > 0: next_returns[i] = (next_close / cur_close - 1.0) panel = panel.with_columns( pl.Series("_next_return", next_returns, dtype=pl.Float64) ) return panel # ── 分组 ── @staticmethod def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame: """截面分位数分组。""" return panel.with_columns( pl.col(factor_col) .qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)]) .over("date") .alias("_group") ) # ── 分组净值 ── @staticmethod def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]: """计算分组净值曲线 — 只在调仓日更新净值。""" # 只保留有下期收益的行 (= 调仓日) group_ret = ( panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null()) .group_by(["date", "_group"]) .agg(pl.col("_next_return").mean().alias("group_return")) ) # pivot: date × group pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date") if pivot.is_empty(): return [] group_cols = [c for c in pivot.columns if c != "date"] # 累乘净值曲线 result: list[dict] = [] nav_values: dict[str, float] = {c: 1.0 for c in group_cols} for row in pivot.iter_rows(named=True): entry: dict = {"date": str(row["date"])[:10]} for c in group_cols: ret = float(row[c]) if row[c] is not None else 0.0 nav_values[c] *= (1 + ret) entry[c] = round(nav_values[c], 4) result.append(entry) return result # ── 分组统计 ── @staticmethod def _calc_group_stats( group_nav: list[dict], start: date, end: date, ) -> list[dict]: if not group_nav: return [] group_cols = [k for k in group_nav[0] if k != "date"] n_days = max((end - start).days, 1) years = n_days / 365.25 stats = [] for i, c in enumerate(sorted(group_cols)): values = [r[c] for r in group_nav if r.get(c) is not None] if not values: continue total_return = values[-1] - 1.0 annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0 # 最大回撤 peak = 1.0 max_dd = 0.0 for v in values: peak = max(peak, v) dd = (v - peak) / peak max_dd = min(max_dd, dd) # 日收益序列 daily_rets = [] for j in range(1, len(values)): if values[j - 1] > 0: daily_rets.append(values[j] / values[j - 1] - 1) # 夏普 if daily_rets: arr = np.array(daily_rets) sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(252) if np.std(arr) > 0 else 0.0 win_rate = float(np.mean(arr > 0)) else: sharpe = 0.0 win_rate = 0.0 stats.append({ "group": i + 1, "label": c, "total_return": round(total_return, 4), "annual_return": round(annual_return, 4), "max_drawdown": round(max_dd, 4), "sharpe": round(sharpe, 2), "win_rate": round(win_rate, 4), }) return stats # ── 多空组合 ── @staticmethod def _calc_long_short( group_nav: list[dict], config: FactorConfig, ) -> tuple[list[dict], dict]: """多空组合: 做多最高组 + 做空最低组。""" if not group_nav: return [], {} group_cols = sorted([k for k in group_nav[0] if k != "date"]) if len(group_cols) < 2: return [], {} top_col = group_cols[-1] # Q5 (最高) bottom_col = group_cols[0] # Q1 (最低) # 独立计算 top 和 bottom 的日收益,然后合成 ls_value = 1.0 prev_top = 1.0 prev_bot = 1.0 peak = 1.0 max_dd = 0.0 ls_nav: list[dict] = [] for row in group_nav: top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0 bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0 # top 组收益 (做多) top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0 # bottom 组收益 (做空 = 取反) bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0 # 多空组合收益 ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金 ls_value *= (1 + ls_ret) prev_top = top_nav prev_bot = bot_nav peak = max(peak, ls_value) dd = (ls_value - peak) / peak if peak > 0 else 0.0 max_dd = min(max_dd, dd) ls_nav.append({"date": row["date"], "value": round(ls_value, 4)}) total_ret = ls_value - 1.0 ls_stats = { "total_return": round(total_ret, 4), "max_drawdown": round(max_dd, 4), "top_group": top_col, "bottom_group": bottom_col, } return ls_nav, ls_stats @staticmethod def _config_to_dict(c: FactorConfig) -> dict: return { "factor_name": c.factor_name, "symbols": c.symbols, "start": str(c.start), "end": str(c.end), "n_groups": c.n_groups, "rebalance": c.rebalance, "weight": c.weight, "fees_pct": c.fees_pct, "slippage_bps": c.slippage_bps, }