Files
sanguo_vnpy_v2/sanguo_factor/tears_data.py
T
claude_dev 8eba3e1cf1
CI/CD / test (push) Successful in 1s
CI/CD / nas-deploy (push) Successful in 6s
CI/CD / nas-verify (push) Successful in 8s
fix(factor): tears对秩类离散因子必空根治(qcut duplicates+quantiles=10) [vps]
alpha16实锤(08-30用户验收factor_b7f3a2e8无图表):cs_rank(ts_cov(cs_rank,cs_rank,5))
截面值高度并列(中位日25值仅6唯一,-19.5×12/-5×9)→alphalens quantize_factor的
pd.qcut(x,q)不传duplicates(默认raise)→Bin edges must be unique→no_raise吞成全NaN
→binning丢100%(303/303日期全抛)→merged空→IC全'No valid IC values'+tears
IndexError+报告空=页面无图表。该形态下此类因子tears永远不可能成功。

修复三件:
①tears_data.py加pd.qcut duplicates='drop'幂等patch(与demean pandas2 patch同位,
_SANGUO flag防重入)——并列值并入少数分位,quantile_keys动态短化前端已容错
②analyzer get_clean调用前import tears_data激活模块级补丁(原import在IC后=补丁
晚到)+quantiles默认5→10显式传参(对齐tears页D1..D10设计与排行榜decile口径)
③build_tears_data qkeys防御:<2组多空退化零序列/0组空Series,不再IndexError

+回归测试:离散并列因子走真实alphalens binning(原测试自合成factor_quantile
绕过该路径=漏掉此bug的原因);RED→GREEN双向验证(无patch merged空(0,3)97.5%
bin丢/有patch绿)。factor+api 123绿;NAS容器30只股端到端:binning丢0.0%、
merged 7732行、tears三周期出数(ic_mean 5D=-0.0014)
2026-08-30 09:36:21 +08:00

150 lines
6.1 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.
"""Tears 分层序列序列化(方案A tears 端点数据源).
create_full_tear_sheet 的 matplotlib 图,本质是"已算好的序列被画成图"——
本模块把同一批 alphalens performance 序列(日度IC / 分组日度收益 / 因子秩自相关)
直接序列化成前端 ECharts 可渲染的 dict,替代 iframe 内嵌浅色 HTML 报告
(用户 2026-08-29 拍板方案A:原生重构)。
"""
import pandas as pd
# 与 analyzer.py 同款幂等补丁:alphalens-reloaded 的 demean_forward_returns 用
# groupby.transform(lambda) 在 pandas2 崩,独立 import 本模块(单测路径)时
# analyzer 可能未加载 → 补丁不在 → mean_return_by_quantile 崩。重复 patch 无害。
try:
import alphalens.utils as _al_utils
def _demean_forward_returns_pandas2(factor_data, grouper=None):
factor_data = factor_data.copy()
if not grouper:
grouper = factor_data.index.get_level_values("date")
cols = _al_utils.get_forward_returns_columns(factor_data.columns)
means = factor_data.groupby(grouper)[cols].transform("mean")
factor_data[cols] = factor_data[cols] - means
return factor_data
_al_utils.demean_forward_returns = _demean_forward_returns_pandas2
except ImportError:
pass
# 同款幂等补丁:alphalens quantize_factor 的 pd.qcut(x, q) 不传 duplicates
# (默认 'raise')——秩类因子(cs_rank/ts_rank 族,如 alpha16)截面值高度并列,
# 分位边界必重合 → ValueError: Bin edges must be unique → 被 no_raise 吞成
# 全 NaN → binning 丢 100% → merged 空 → IC 全空 + tears 崩(NAS 2026-08-30
# 实锤:alpha16 303/303 日期全抛,中位日 25 值仅 6 唯一)。改默认 duplicates='drop'
# 后并列值并入少数分位(quantile_keys 变短,前端已按实际 keys 动态渲染)。
_orig_qcut = pd.qcut
def _qcut_dup_drop(*args, **kwargs):
kwargs.setdefault("duplicates", "drop")
return _orig_qcut(*args, **kwargs)
if not getattr(pd.qcut, "_sanguo_dup_drop", False):
_qcut_dup_drop._sanguo_dup_drop = True
pd.qcut = _qcut_dup_drop
_TRADING_DAYS = 252
def _nav(daily: pd.Series) -> pd.Series:
"""日度收益(可含重叠窗口)→ 累计净值,首值 1;NaN 日视为空仓(0 收益)."""
return (1.0 + daily.fillna(0.0)).cumprod()
def _max_drawdown(nav: pd.Series) -> float:
"""净值序列最大回撤(≤0)."""
v = (nav / nav.cummax() - 1.0).min()
return float(v) if pd.notna(v) else 0.0
def _annualized(daily: pd.Series, period: int) -> float:
"""日均 × 252 / period:线性去重叠年化,1/5/10D 三周期可比(1D≈eval 链路口径)."""
s = daily.dropna()
if s.empty:
return 0.0
return float(s.mean() * _TRADING_DAYS / period)
def _monthly_ic(ic: pd.Series) -> list[dict]:
"""日度 IC 按月聚合(月度柱/热力图数据),口径同 sanguo_factor.metrics.monthly_ic."""
s = ic.dropna()
if s.empty:
return []
g = s.groupby(s.index.to_period("M")).mean()
return [{"month": t.strftime("%Y-%m"), "ic": round(float(v), 6)} for t, v in g.items()]
def build_tears_data(merged_data, periods: tuple = (1, 5, 10)) -> dict:
"""alphalens factor_data → tears 序列 dict.
每序列自带日期轴(ic_dates/nav_dates);IC/分组收益均调 alphalens 原函数,
与 tearsheet 同源。quantile_nav 给全 10 组,前端按需画 Q1/Q5/Q10。
"""
from alphalens.performance import (
factor_information_coefficient,
factor_rank_autocorrelation,
mean_return_by_quantile,
)
ic_df = factor_information_coefficient(merged_data)
qr_by_date, _ = mean_return_by_quantile(merged_data, by_date=True, demeaned=True)
# 因子秩自相关(1D,指标条一项):失败不致命 → None
try:
ac = factor_rank_autocorrelation(merged_data, period=1).dropna()
factor_autocorr = round(float(ac.mean()), 4) if len(ac) else None
except Exception:
factor_autocorr = None
out: dict = {"factor_autocorr": factor_autocorr, "periods": {}}
for p in periods:
# 列名兼容:alphalens-reloaded 生成 "1D",老版纯数字 "1"(同 analyzer 口径)
col = next((c for c in ic_df.columns if c in (f"{p}D", str(p))), None)
if col is None:
continue
ic = ic_df[col].dropna()
qd = qr_by_date[col].unstack("factor_quantile") # date × quantile
qkeys = sorted(int(q) for q in qd.columns)
quantile_nav: dict = {}
quantile_annual: dict = {}
for q in qkeys:
daily_q = qd[q]
quantile_nav[str(q)] = [round(float(v), 6) for v in _nav(daily_q)]
quantile_annual[str(q)] = round(_annualized(daily_q, p), 4)
# 多空 = 最高分位 − 最低分位(每日,demeaned 超额口径同 tearsheet)。
# 离散因子 duplicates='drop' 后极端形态可能仅 1 组:多空退化为零序列。
if len(qkeys) >= 2:
ls_daily = qd[qkeys[-1]] - qd[qkeys[0]]
elif qkeys:
ls_daily = qd[qkeys[0]] * 0.0
else:
ls_daily = pd.Series(dtype=float)
ls_nav = _nav(ls_daily)
n = len(ic)
m = float(ic.mean()) if n else 0.0
sd = float(ic.std()) if n > 1 else 0.0
out["periods"][f"{p}D"] = {
"count": int(n),
"ic_mean": round(m, 6) if n else None,
"ic_std": round(sd, 6) if n > 1 else None,
"icir": round(m / sd, 4) if sd > 0 else None,
"t_stat": round(m / (sd / n ** 0.5), 4) if sd > 0 and n > 1 else None,
"win_rate": round(float((ic > 0).mean()), 4) if n else None,
"ic_dates": [d.strftime("%Y-%m-%d") for d in ic.index],
"ic_values": [round(float(v), 6) for v in ic],
"monthly_ic": _monthly_ic(ic),
"quantile_keys": [str(q) for q in qkeys],
"nav_dates": [d.strftime("%Y-%m-%d") for d in qd.index],
"quantile_nav": quantile_nav,
"quantile_annual": quantile_annual,
"ls_nav": [round(float(v), 6) for v in ls_nav],
"ls_annual": round(_annualized(ls_daily, p), 4),
"ls_max_dd": round(_max_drawdown(ls_nav), 4),
}
return out