diff --git a/sanguo_factor/analyzer.py b/sanguo_factor/analyzer.py index 66e3ab1..2ce3fd0 100644 --- a/sanguo_factor/analyzer.py +++ b/sanguo_factor/analyzer.py @@ -242,10 +242,15 @@ def run_factor_analysis( continue # Call get_clean_factor_and_forward_returns + # tears_data 模块级补丁(pd.qcut duplicates='drop' + demean pandas2) + # 须在 get_clean 前加载生效:秩类因子(cs_rank 族)截面并列值会使 + # alphalens 分位边界重合,不 patch 则 binning 丢 100%(2026-08-30 实锤)。 + from .tears_data import build_tears_data # noqa: F401 (import for side-effect patches) merged_data = get_clean_factor_and_forward_returns( factor=factor_series, prices=prices_df, periods=periods, # Use configurable periods + quantiles=10, # 十分组:对齐 tears 页 D1..D10 与排行榜 decile 口径 max_loss=1.0 # TEMPORARY: Allow 100% loss to see IC data ) @@ -299,7 +304,6 @@ def run_factor_analysis( try: import json as _json from datetime import datetime as _dt - from .tears_data import build_tears_data tears = build_tears_data(merged_data, periods) tears["factor"] = factor_name tears["generated_at"] = _dt.now().isoformat(timespec="seconds") diff --git a/sanguo_factor/tears_data.py b/sanguo_factor/tears_data.py index 19ea7dc..c8493eb 100644 --- a/sanguo_factor/tears_data.py +++ b/sanguo_factor/tears_data.py @@ -26,6 +26,24 @@ try: 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 @@ -97,8 +115,14 @@ def build_tears_data(merged_data, periods: tuple = (1, 5, 10)) -> dict: 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) - ls_daily = qd[qkeys[-1]] - qd[qkeys[0]] + # 多空 = 最高分位 − 最低分位(每日,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) diff --git a/tests/factor/test_tears_data.py b/tests/factor/test_tears_data.py index 555f6f3..16a735f 100644 --- a/tests/factor/test_tears_data.py +++ b/tests/factor/test_tears_data.py @@ -127,3 +127,45 @@ def test_build_tears_data_json_serializable(built): """端点要 FileResponse 这个 dict → 必须整棵 json 可序列化.""" import json json.dumps(built) + + +# —— 离散并列因子走真实 alphalens binning(NAS 2026-08-30 alpha16 实锤回归) —— + +def test_discrete_factor_binning_survives(): + """alpha16 实锤形态:cs_rank 族因子截面值高度并列(中位日 25 值仅 6 唯一, + -19.5×12/-5×9)→ alphalens quantize_factor 的 pd.qcut 不传 duplicates, + 边界必重合 → ValueError 被 no_raise 吞成全 NaN → binning 丢 100% → + merged 空 → IC 全空 + tears IndexError。tears_data 模块级 qcut + duplicates='drop' 补丁后:merged 非空、tears 出数、keys 动态短化不崩。""" + pytest.importorskip("alphalens") + import sanguo_factor.tears_data # noqa: F401 — 触发 pd.qcut duplicates='drop' 补丁 + from alphalens.utils import get_clean_factor_and_forward_returns + from sanguo_factor.tears_data import build_tears_data + + rng = np.random.default_rng(11) + n_days, n_assets = 40, 25 + dates = pd.date_range("2024-01-02", periods=n_days, freq="B", name="date") + assets = [f"S{i:03d}" for i in range(n_assets)] + idx = pd.MultiIndex.from_product([dates, assets], names=["date", "asset"]) + levels = np.array([-19.5, -13.0, -12.0, -11.0, -10.0, -5.0]) + picks = rng.integers(0, len(levels), size=len(idx)) + factor = pd.Series(levels[picks], index=idx, name="factor") + + px = pd.DataFrame( + 100 + np.cumsum(rng.normal(0, 1, size=(n_days, n_assets)), axis=0), + index=dates, + columns=assets, + ) + + merged = get_clean_factor_and_forward_returns( + factor=factor, prices=px, periods=(1,), quantiles=10, max_loss=1.0, + ) + assert not merged.empty, "离散并列因子不应被 binning 丢光(修复回归)" + + tears = build_tears_data(merged, periods=(1,)) + d = tears["periods"]["1D"] + assert d["count"] > 0 + assert d["quantile_keys"], "至少应有一组" + assert len(d["ls_nav"]) == len(d["nav_dates"]) + import json + json.dumps(tears)