Files
sanguo_vnpy_v2/tests/factor/test_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

172 lines
6.4 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.
"""Test tears_data - 分层序列序列化(方案A tears 端点数据源)."""
import sys
import os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
if _VNPY_SRC not in sys.path:
sys.path.insert(0, _VNPY_SRC)
import numpy as np
import pandas as pd
import pytest
# —— 纯函数(不依赖 alphalens,本地必跑) ——
def test_nav_compound():
from sanguo_factor.tears_data import _nav
nav = _nav(pd.Series([0.1, -0.2, 0.1]))
assert list(np.round(nav.values, 6)) == [1.1, 0.88, 0.968]
def test_nav_nan_day_is_flat():
from sanguo_factor.tears_data import _nav
nav = _nav(pd.Series([np.nan, 0.1]))
assert nav.iloc[0] == 1.0
assert abs(nav.iloc[1] - 1.1) < 1e-9
def test_max_drawdown():
from sanguo_factor.tears_data import _max_drawdown, _nav
nav = _nav(pd.Series([0.1, -0.2, 0.1]))
dd = _max_drawdown(nav)
assert dd <= 0
assert abs(dd - (0.88 / 1.1 - 1.0)) < 1e-9
def test_annualized_deoverlaps_period():
from sanguo_factor.tears_data import _annualized
s = pd.Series([0.001] * 252) # 日均 0.001
assert abs(_annualized(s, 1) - 0.252) < 1e-9
# 同收益按 5D 重叠口径 → 年化除以 5(去重叠,三周期可比)
assert abs(_annualized(s, 5) - 0.0504) < 1e-9
def test_monthly_ic_groups_by_month():
from sanguo_factor.tears_data import _monthly_ic
idx = pd.to_datetime(["2024-01-05", "2024-01-20", "2024-02-01"])
g = _monthly_ic(pd.Series([0.1, 0.3, 0.2], index=idx))
assert [x["month"] for x in g] == ["2024-01", "2024-02"]
assert abs(g[0]["ic"] - 0.2) < 1e-9
assert g[1]["ic"] == 0.2
# —— build_tears_data 全链(真实 alphalens 函数) ——
def _merged_data(n_days: int = 40, n_assets: int = 20, seed: int = 7) -> pd.DataFrame:
"""合成 alphalens factor_data:因子与前瞻收益秩正相关(rank IC 显著为正)."""
rng = np.random.default_rng(seed)
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"])
factor = rng.normal(size=len(idx))
df = pd.DataFrame({"factor": factor}, index=idx)
# 横截面 rank(0~1)驱动前瞻收益 → IC>0;1/5/10D 噪声递减信号不变
rk = pd.Series(factor, index=idx).groupby(level="date").rank(pct=True).values
base = (rk - 0.5) * 0.04
for p in (1, 5, 10):
df[f"{p}D"] = base + rng.normal(0, 0.01, size=len(idx))
df["factor_quantile"] = (
pd.Series(factor, index=idx).groupby(level="date")
.transform(lambda x: pd.qcut(x, 10, labels=False) + 1)
.astype(int)
)
return df
@pytest.fixture(scope="module")
def built():
pytest.importorskip("alphalens")
from sanguo_factor.tears_data import build_tears_data
return build_tears_data(_merged_data())
def test_build_tears_data_period_keys(built):
assert set(built["periods"]) == {"1D", "5D", "10D"}
def test_build_tears_data_ic_positive(built):
"""合成因子与收益正相关 → 三周期 IC 均值/ICIR 为正,胜率过半."""
for p in ("1D", "5D", "10D"):
d = built["periods"][p]
assert d["ic_mean"] > 0, p
assert d["icir"] > 0, p
assert d["t_stat"] > 2, p
assert d["win_rate"] > 0.5, p
def test_build_tears_data_series_shapes(built):
d1 = built["periods"]["1D"]
n_nav = len(d1["nav_dates"])
assert n_nav == 40
assert d1["quantile_keys"] == [str(i) for i in range(1, 11)]
for q in d1["quantile_keys"]:
assert len(d1["quantile_nav"][q]) == n_nav, q
assert len(d1["ls_nav"]) == n_nav
assert len(d1["ic_dates"]) == d1["count"]
assert len(d1["ic_dates"]) == len(d1["ic_values"])
# 净值恒正(首值 = 1+首日收益,同 alphalens cum_returns 口径)
assert all(v > 0 for v in d1["quantile_nav"]["1"])
assert all(v > 0 for v in d1["ls_nav"])
def test_build_tears_data_quantile_monotonic(built):
"""信号由 rank 驱动 → Q10 年化 > Q1 年化,多空年化为正."""
d1 = built["periods"]["1D"]
assert d1["quantile_annual"]["10"] > d1["quantile_annual"]["1"]
assert d1["ls_annual"] > 0
assert d1["ls_max_dd"] <= 0
def test_build_tears_data_monthly_ic_present(built):
d1 = built["periods"]["1D"]
assert d1["monthly_ic"], "monthly_ic 不应为空(40 个交易日 ≥ 1 个月)"
assert "month" in d1["monthly_ic"][0] and "ic" in d1["monthly_ic"][0]
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)