Files
sanguo_vnpy_v2/tests/factor/test_fundamental_p1c_adapter.py
claude_dev 200ea620a0
CI/CD / test (push) Successful in 23s
CI/CD / nas-deploy (push) Successful in 11s
CI/CD / nas-verify (push) Successful in 6s
fix(factor): pledge_ratio 消失股 stale 修复+节假日空周字面夹具(infra 09-22 核查派单三件) [nas]
infra 验收核查发现: 快照只含有质押的股票,全解押股退出名单后事件行
join_asof backward 会把旧值永久 stale 带到全部后续日期——与 detail 族
UNFREEZE 区间重建的解押归零语义直接矛盾(解押后风险解除应拿 0 档,
stale 会漏掉改善分)。修=loader 在「最后出现期的下一实际期」插 0 行,
消失次期起归 0;再质押后续真行自然覆盖;期序列=树内实际有数据的期
(树完整假设,failed 期周六班自愈)。测试: 消失股 D 0106 有行 0707 起
消失→次期起 0 永久(修复前 stale 8.0 实锤 RED);既有 C 股断言由 stale
语义勘误为真实在押(0707 补行)。

节假日空周=0 行 0 列 parquet(NAS 实测形态,周六班收口)→ loader 列
检查跳过与「省略文件」同效果,asof 回退节前最近真期——补 0×0 字面
夹具钉死该防线(防未来重构破坏列检查)。全套 1483 绿(+2 用例) [nas]

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 19:22:13 +08:00

198 lines
9.1 KiB
Python
Raw Permalink 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.
# tests/factor/test_fundamental_p1c_adapter.py
"""P1-C 批财务因子适配层: 研报共识 5 + 质押 4(spec §20.13 联合设计,2026-09-22).
口径锚(P1-C 开工包):
- 研报族 PIT=条目 publishDate;180 天窗两端 cum 口径(同 E11 手法);
rating_pos 门=窗内研报 ≥3;fwd_ep 门=窗内带 EPS 条目 ≥1(空值不算
分母);target=最新带目标价条目+180 天新鲜度门(null 目标价不进流
→ 不遮蔽上一份带价研报)
- pledge_ratio PIT=周五快照(asof 取 ≤asof 最近期);股无行=0(无质押
=真实零,与缺域 NaN 语义区分)
- pledge_detail 区间重建: 生效开始=max(PF_START_DATE, NOTICE_DATE)
(公告日才可见,宁晚毋早);结束=UNFREEZE_DATE(解押即视为可见——EM 无
解押公告日列);asof∈[开始,结束) ⇒ 在押;IS_CONTROL_SHAREHOLDER=='1'
行=实控人质押直标(免股东名 join)
- pledge_net_180 = 180 天窗(解押笔数 − 新增质押笔数);计数列聚合即
cast Int64(u32 环回坑,P2 实锤先例)
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
import pytest
from sanguo_factor.fundamental_adapter import build_fundamental_features
A, B, C, BANK, D = "600000.SSE", "000001.SZSE", "300001.SZSE", "601398.SSE", "600004.SSE"
_RES_COLS = ["res_coverage", "res_rating_pos", "res_fwd_eps",
"res_target_price", "res_rating_chg"]
_PL_COLS = ["pledge_ratio", "pledge_ctrl", "pledge_warn_line", "pledge_net_180"]
def _dt(day: str):
import datetime as _d
return _d.datetime.strptime(day, "%Y-%m-%d")
def val(df, vt: str, day: str, col: str):
row = df.filter((df["vt_symbol"] == vt) & (df["datetime"] == _dt(day)))
assert row.height == 1, f"grid 缺行 {vt} {day}"
v = row[col][0]
return None if v is None else float(v)
@pytest.fixture(scope="module")
def feat(synthetic_static):
return build_fundamental_features(
[A, B, C, BANK, D], "2021-01-01", "2024-06-30", data_dir=synthetic_static,
columns=_RES_COLS + _PL_COLS)
# ---------- R 族: res_coverage(180 天窗研报数) ----------
def test_res_coverage_window(feat):
# A 事件(窗右开两端差): 07-03 时窗含 e1..e3 → 3;08-29(−180d=03-02,
# e1@03-10 未出窗) → 3;24-06-30(−180d=2024-01-02,e1..e4 出窗)仅 e5 → 1
assert val(feat, A, "2023-03-10", "res_coverage") == 1.0
assert val(feat, A, "2023-07-03", "res_coverage") == 3.0
assert val(feat, A, "2023-08-29", "res_coverage") == 3.0
assert val(feat, A, "2024-06-30", "res_coverage") == 1.0
def test_res_coverage_zero_after_slideout(feat):
# C 单份 2022-06-30: 2023-08-29 窗已滑出 → 0(曾覆盖后窗空=0 值,
# 与首研报前 NaN 区分)
assert val(feat, C, "2023-08-29", "res_coverage") == 0.0
def test_res_coverage_nan_before_first(feat):
assert val(feat, A, "2023-03-09", "res_coverage") is None
# B 无研报 → 全 NaN(关注缺失≠零关注,与质押族 fill 0 语义相反)
assert val(feat, B, "2023-12-31", "res_coverage") is None
# ---------- R 族: res_rating_pos(买入+增持占比,门 n≥3) ----------
def test_res_rating_pos_value_and_gate(feat):
assert val(feat, A, "2023-07-03", "res_rating_pos") == pytest.approx(2.0 / 3.0)
# 门: 窗内 <3 条 → NaN(06-15 窗 e1,e2 共 2 条;24-06-30 窗仅 e5;
# C 单条)
assert val(feat, A, "2023-06-15", "res_rating_pos") is None
assert val(feat, A, "2024-06-30", "res_rating_pos") is None
assert val(feat, C, "2022-08-29", "res_rating_pos") is None # 窗 1 条
# ---------- R 族: res_fwd_eps(窗内 mean EPS,空值不算分母) ----------
def test_res_fwd_eps(feat):
assert val(feat, A, "2023-03-10", "res_fwd_eps") == 1.0 # 仅 e1
assert val(feat, A, "2023-07-03", "res_fwd_eps") == pytest.approx(1.05) # (1.0+1.1)/2,e3 无 eps
assert val(feat, A, "2023-08-29", "res_fwd_eps") == 1.05 # e1 未出窗(−180d=03-02)
# 24-06-30 窗仅 e5(无 eps) → NaN
assert val(feat, A, "2024-06-30", "res_fwd_eps") is None
# ---------- R 族: res_target_price(最新可见+180 天新鲜度门) ----------
def test_res_target_price_freshness(feat):
assert val(feat, A, "2023-07-03", "res_target_price") == 18.0
assert val(feat, A, "2023-08-29", "res_target_price") == 18.0 # e3 距 57 天
# e4(22.0,2023-11-17)距 2024-06-30=226 天 >180 → 过期 NaN;
# e5 tgt=None 不进流不遮蔽 → 整列 NaN(门失效会得 22.0)
assert val(feat, A, "2024-06-30", "res_target_price") is None
# 首个带价条目(e2 05-12)前 → NaN
assert val(feat, A, "2023-05-11", "res_target_price") is None
# ---------- R 族: res_rating_chg(窗内净上调次数) ----------
def test_res_rating_chg(feat):
assert val(feat, A, "2023-07-03", "res_rating_chg") == -1.0 # e3 下调
assert val(feat, A, "2023-08-29", "res_rating_chg") == -1.0
# 24-06-30 窗仅 e5(维持) → 0
assert val(feat, A, "2024-06-30", "res_rating_chg") == 0.0
assert val(feat, A, "2023-11-17", "res_rating_chg") == 0.0 # 窗(e3,e4)下调+上调相抵
# ---------- Q 族: pledge_ratio(周五快照 asof;无行 fill 0) ----------
def test_pledge_ratio_snapshot_asof(feat):
assert val(feat, A, "2023-01-05", "pledge_ratio") == 0.0 # 首期前 fill 0
assert val(feat, A, "2023-01-06", "pledge_ratio") == 5.0
assert val(feat, A, "2023-07-06", "pledge_ratio") == 5.0 # ≤asof 最近期=0106
assert val(feat, A, "2023-07-07", "pledge_ratio") == 6.0
assert val(feat, C, "2023-07-07", "pledge_ratio") == 20.0
# B 快照无行(无质押=真实零)
assert val(feat, B, "2023-12-31", "pledge_ratio") == 0.0
def test_pledge_ratio_disappearing_stock_fresh_zero(feat):
"""全解押股退出快照名单 → 下一期起归 0(非 join_asof 永久 stale 旧值).
快照只含有质押的股票,D 0106 期有行(8.0) 0707 期起消失=全解押;
若无消失修复,asof 会把 0106 的 8.0 带到全部后续日期(与 detail 族
区间重建的解押语义矛盾——解押后风险解除应拿 0 档).
"""
assert val(feat, D, "2023-01-06", "pledge_ratio") == 8.0
assert val(feat, D, "2023-07-06", "pledge_ratio") == 8.0 # 仍在押
assert val(feat, D, "2023-07-07", "pledge_ratio") == 0.0 # 消失次期归 0
assert val(feat, D, "2024-06-30", "pledge_ratio") == 0.0 # 永久 0 非 stale
def test_pledge_ratio_empty_holiday_week_file_skipped(feat):
"""0 行 0 列节假日空周文件(NAS 实测形态)→ loader 列检查跳过,
asof 回退最近真期(0707),非 NaN 非误 0."""
assert val(feat, A, "2023-10-06", "pledge_ratio") == 6.0
assert val(feat, A, "2023-12-31", "pledge_ratio") == 6.0 # 空文件后仍回退 0707
# ---------- Q 族: pledge_ctrl(实控人在押 max 占所持;区间重建) ----------
def test_pledge_ctrl_interval_rebuild(feat):
# p1 生效=max(start 02-10, notice 02-15)=02-15(PIT: 公告日才可见)
assert val(feat, A, "2023-02-14", "pledge_ctrl") == 0.0
assert val(feat, A, "2023-02-15", "pledge_ctrl") == 30.0
# 05-20 起 p1+p2 在押 → 实控人 max(30,50)=50
assert val(feat, A, "2023-06-30", "pledge_ctrl") == 50.0
# p2 09-10 解押 → 实控人在押仅 p1 → 30
assert val(feat, A, "2023-10-01", "pledge_ctrl") == 30.0
# C 无实控人质押行 → 0
assert val(feat, C, "2023-12-31", "pledge_ctrl") == 0.0
assert val(feat, B, "2023-12-31", "pledge_ctrl") == 0.0
# ---------- Q 族: pledge_warn_line(在押最高预估平仓线) ----------
def test_pledge_warn_line(feat):
assert val(feat, A, "2023-02-14", "pledge_warn_line") == 0.0
assert val(feat, A, "2023-06-30", "pledge_warn_line") == 25.0 # max(15,25)
assert val(feat, A, "2023-10-01", "pledge_warn_line") == 15.0 # p2 解押 → max(15,8)
assert val(feat, C, "2023-01-01", "pledge_warn_line") == 12.0 # 2021 全程在押
# ---------- Q 族: pledge_net_180(180 天窗解押−新增笔数) ----------
def test_pledge_net_chg(feat):
# A 事件: 02-15 新增 / 05-20 新增 / 08-01 新增 / 09-10 解押
assert val(feat, A, "2023-02-14", "pledge_net_180") == 0.0 # 首事件前 fill 0
assert val(feat, A, "2023-02-15", "pledge_net_180") == -1.0
assert val(feat, A, "2023-06-30", "pledge_net_180") == -2.0 # 新增 p1+p2
# 10-01 窗 (2023-04-04,10-01]: 新增 p3 − 解押 p2 相抵,但 180 天前
# 端 cum=−1(至 02-15) → 两端差 = −2 −(−1) = −1
assert val(feat, A, "2023-10-01", "pledge_net_180") == -1.0
# C 2021 事件早已出 180 天窗 → 0;B 无质押史 → 0
assert val(feat, C, "2023-01-01", "pledge_net_180") == 0.0
assert val(feat, B, "2023-12-31", "pledge_net_180") == 0.0
# ---------- 引用瘦身路径(仅 P1-C 域列请求) ----------
def test_p1c_columns_subset(synthetic_static):
df = build_fundamental_features(
[A], "2023-06-01", "2023-12-31", data_dir=synthetic_static,
columns=["pledge_ctrl", "res_coverage"])
assert df.columns == ["vt_symbol", "datetime", "pledge_ctrl", "res_coverage"]
assert val(df, A, "2023-06-30", "pledge_ctrl") == 50.0
assert val(df, A, "2023-07-03", "res_coverage") == 3.0