Files
sanguo_vnpy_v2/tests/factor/test_fundamental_p1b_adapter.py
T
claude_dev 60dd84ad4a feat(factor): P1-B批adapter四新域读取层+CCC三表自算 [nas]
- E08 ccc: 365×(AR均值/REV_TTM + INV均值/COGS_TTM − AP均值/COGS_TTM),
  均值=t与t−4期末平均(_mean4),ACCOUNTS_PAYABLE 入 balance 读取清单,
  金融股置 NaN(NAS 实测列名核实)
- E11 send_total_12m: dividend 回退链 PIT(Plan→PreNotice→AgmPum→Plan,
  全空行丢弃),cum(d)−cum(d−365) 两端 asof 实现开区间滚动窗;域内无事件
  →NaN 不填 0(与缺文件区分)
- E12 gdhs_chg: 自建事件表(代码,截止日)→户数,同键取最新公告,相邻事件
  Δln,公告日期 asof;不用「增减比例」列;实测截止日列名无第二连字符(双名兼容)
- E13 topholder_chg: 只读 factor_cache/top_holders_agg.parquet,期→法定
  披露截止(Q1→04-30/H1→08-31/Q3→10-31/年报→次年04-30)保守 PIT
- D14 ep_vb/bp_vb: vb 按年文件+2026-01-01~08-12 缺口 static/valuation(ak
  中文列)倒数补;实测 exchange=SH/SZ(SSE/SZSE 全称变体兼容);1/pe 守卫 0→NaN
- 四域引用列驱动加载(未引用不读盘)+缺域一次性 warning 优雅降级全 NaN
- conftest 四域合成 fixture;NAS 真数据冒烟通过(300750 ccc=12.5d/
  000333 ccc=−9.2d/600519 ep=1/32PE)
2026-09-09 12:12:00 +08:00

249 lines
12 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.
# tests/factor/test_fundamental_p1b_adapter.py
"""P1-B 批财务因子适配层: 四新域(dividend/gdhs/top_holders agg/vb)+ CCC 三表自算.
口径锚(P1-B 任务书 + NAS 实测 2026-09-08):
- E08 CCC = 365×(AR_avg/REV_TTM + INV_avg/COGS_TTM AP_avg/COGS_TTM);
均值 = t 与 t−4 报告期期末余额平均(恰隔 4 季守卫);金融股置 NaN
- E11 send_total_12m = 近 365 天(开区间下界)分红事件 dividStocksPs 累计和;
PIT 日期回退链 dividPlanAnnounceDate→PreNotice→AgmPum→Plan,全空行丢弃;
域内无事件(≤决策日) → NaN(不填 0),纯现金事件送转 0 → 0
- E12 gdhs_chg = 相邻事件 Δln(户数)(按截止日排序,公告日期 asof;
同 (股,截止日) 多行取最新公告)
- E13 topholder_chg = 相邻期十大流通合计占比 Δpct;PIT = 法定披露截止
(Q1→04-30/H1→08-31/Q3→10-31/年报→次年04-30,保守侧)
- D14 ep_vb/bp_vb = 1/peTTM、1/pbMRQ(0 → NaN);2026-01-01~08-12 缺口由
static/valuation(ak 中文列)倒数补,08-13 起用 vb
"""
import math
import shutil
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 = "600000.SSE", "000001.SZSE", "300001.SZSE"
BANK = "601398.SSE"
# 合成 dividend 事件(eff, dividStocksPs;A)
_A_EVENTS = [("2022-06-15", 0.2), ("2023-03-10", 0.5), ("2023-09-20", 0.3),
("2023-12-01", 0.4), ("2024-01-05", 1.0)]
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)
def _window_sum(day: str) -> float:
"""独立重算: (day-365, day] 窗内 A 事件 dividStocksPs 合计."""
import datetime as _d
d = _dt(day)
lo = d.replace(year=d.year - 1)
return sum(v for eff, v in _A_EVENTS if _dt(eff) <= d and _dt(eff) > lo)
@pytest.fixture(scope="module")
def feat(synthetic_static):
return build_fundamental_features(
[A, B, C], "2023-01-01", "2023-12-31", data_dir=synthetic_static)
# ---------- E08 CCC(三表自算) ----------
def test_ccc_value_at_h1(feat):
# 2023H1(i=21): AR mean(190,150)=170 INV mean(245,225)=235 AP mean(192,176)=176
# REV_TTM=600 COGS_TTM=0.6×600=360
expect = 365 * (170 / 600 + 235 / 360 - 176 / 360)
assert val(feat, A, "2023-08-29", "ccc") == pytest.approx(expect)
# scale 不变性(C=2×): 三均值与两 TTM 同比放大 → CCC 不变
assert val(feat, C, "2023-08-29", "ccc") == pytest.approx(expect)
def test_ccc_pit_boundary(feat):
# 2023-08-28 见 2023Q1(i=20): AR mean(180,140)=160 INV mean(240,220)=230
# AP mean(184,152)=168 REV_TTM=q(17..20)=600 COGS_TTM=360
assert val(feat, A, "2023-08-28", "ccc") == pytest.approx(
365 * (160 / 600 + 230 / 360 - 168 / 360))
assert val(feat, A, "2023-08-29", "ccc") == pytest.approx(
365 * (170 / 600 + 235 / 360 - 176 / 360))
def test_ccc_guards(feat, synthetic_static):
# B 缺 2022Q1(i=16) → 2023Q1 均值基期断档 → NaN;H1 基期(i=17) 恢复
assert val(feat, B, "2023-04-28", "ccc") is None
assert val(feat, B, "2023-08-29", "ccc") == pytest.approx(
365 * (170 / 600 + 235 / 360 - 176 / 360))
# 金融股: CCC 属营运效率,银行无存货/应付概念 → NaN(§7 红线扩展)
df = build_fundamental_features(
[BANK], "2023-08-25", "2023-09-02", data_dir=synthetic_static)
assert val(df, BANK, "2023-08-29", "ccc") is None
# ---------- E11 高送转强度(dividend 域) ----------
def test_send_total_values_and_fallback_chain(feat):
# 回退链: 2023-12-01 事件只有 dividPreNoticeDate → 计入
assert val(feat, A, "2023-11-30", "send_total_12m") == pytest.approx(0.8)
assert val(feat, A, "2023-12-01", "send_total_12m") == pytest.approx(1.2)
# 全链空行(dividStocksPs=9.9 哨兵)被丢弃: 任何时点 ≤2.2
assert val(feat, A, "2023-12-31", "send_total_12m") == pytest.approx(1.2)
def test_send_total_pit_window_boundaries(synthetic_static):
df = build_fundamental_features(
[A], "2023-03-01", "2024-03-15", data_dir=synthetic_static)
# 事件日边界(当日可见)
assert val(df, A, "2023-03-09", "send_total_12m") == pytest.approx(0.2)
assert val(df, A, "2023-03-10", "send_total_12m") == pytest.approx(0.7)
# 365 天滑出边界(开区间下界): 2022-06-15 事件在 2023-06-14 仍在/06-15 出窗
assert val(df, A, "2023-06-14", "send_total_12m") == pytest.approx(0.7)
assert val(df, A, "2023-06-15", "send_total_12m") == pytest.approx(0.5)
# 迟到事件: 2024-01-05 公告,04 日不可见/05 日计入
assert val(df, A, "2024-01-04", "send_total_12m") == pytest.approx(1.2)
assert val(df, A, "2024-01-05", "send_total_12m") == pytest.approx(2.2)
# 365 天出窗边界 = 精确 365×24h(跨 2024 闰日 → 2023-03-10 事件在
# 2024-03-09(=事件+366 日历日)已出窗)
assert val(df, A, "2024-03-08", "send_total_12m") == pytest.approx(2.2)
assert val(df, A, "2024-03-09", "send_total_12m") == pytest.approx(1.7)
def test_send_total_domain_semantics(feat):
# B: 域内纯现金事件(dividStocksPs=0)→ 0(非 NaN);事件前无事件 → NaN
assert val(feat, B, "2023-03-09", "send_total_12m") is None
assert val(feat, B, "2023-03-10", "send_total_12m") == pytest.approx(0.0)
# C: 无 dividend 文件(域不覆盖)→ NaN(不填 0)
assert val(feat, C, "2023-12-31", "send_total_12m") is None
# ---------- E12 股东户数变化(gdhs 域) ----------
def test_gdhs_chg_adjacent_events_and_pit(feat):
ln = math.log
# A 首事件(2022-09-30)Δ=null;2022-12-31 事件公告 2023-01-10
assert val(feat, A, "2023-01-09", "gdhs_chg") is None
assert val(feat, A, "2023-01-10", "gdhs_chg") == pytest.approx(ln(80000 / 84000))
# 相邻事件各自 Δln(Q1→Q2 换值,非全史一个值): 公告日边界
assert val(feat, A, "2023-04-19", "gdhs_chg") == pytest.approx(ln(80000 / 84000))
assert val(feat, A, "2023-04-20", "gdhs_chg") == pytest.approx(ln(72000 / 80000))
assert val(feat, A, "2023-07-24", "gdhs_chg") == pytest.approx(ln(72000 / 80000))
assert val(feat, A, "2023-07-25", "gdhs_chg") == pytest.approx(ln(68000 / 72000))
assert val(feat, A, "2023-10-14", "gdhs_chg") == pytest.approx(ln(68000 / 72000))
assert val(feat, A, "2023-10-15", "gdhs_chg") == pytest.approx(ln(77760 / 68000))
def test_gdhs_chg_dedupe_latest_announcement(feat):
# B 2023-06-30 截止日两行(公告 07-20 户数 50000 / 08-01 修订 50500):
# 去重取最新公告 → 08-01 前不得提前泄露修订值
assert val(feat, B, "2023-07-31", "gdhs_chg") == pytest.approx(math.log(50000 / 48000))
assert val(feat, B, "2023-08-01", "gdhs_chg") == pytest.approx(math.log(50500 / 50000))
def test_gdhs_chg_uncovered_stock(feat):
# C 无 gdhs 行 → NaN;「增减比例」噪声列不参与(值由 Δln 自算)
assert val(feat, C, "2023-12-31", "gdhs_chg") is None
# ---------- E13 十大流通股东占比变化(top_holders 聚合域) ----------
def test_topholder_chg_statutory_deadlines(feat):
# 法定披露截止 PIT: Q1→04-30 / 年报→次年04-30 / H1→08-31 / Q3→10-31
# Q1 与年报共用 04-30 截止 → 同日双事件 asof 取期更近的 Q1 事件
assert val(feat, A, "2023-04-29", "topholder_chg") == pytest.approx(7.0) # Q3'22: 5750
assert val(feat, A, "2023-04-30", "topholder_chg") == pytest.approx(5.0) # Q1'23: 6560(年报 Δ=3 同日让位)
assert val(feat, A, "2023-08-30", "topholder_chg") == pytest.approx(5.0)
assert val(feat, A, "2023-08-31", "topholder_chg") == pytest.approx(-3.0) # H1'23: 6265
# 无 Q3'23 期数据 → 10-31 无法定截止新事件,值持续
assert val(feat, A, "2023-12-31", "topholder_chg") == pytest.approx(-3.0)
def test_topholder_chg_insufficient_history(feat):
# B 仅单期 → 无上期 → NaN
assert val(feat, B, "2023-12-31", "topholder_chg") is None
# C 不在聚合产物 → NaN
assert val(feat, C, "2023-12-31", "topholder_chg") is None
# ---------- D14 长史 EP/BP(vb 域 + 2026 补口) ----------
def test_vb_ep_bp_values_and_ffill(feat):
# A: 08-25 pe=12 → 周末 08-26(日历 grid)前向填充;08-28/08-29 换值
assert val(feat, A, "2023-08-26", "ep_vb") == pytest.approx(1 / 12)
assert val(feat, A, "2023-08-28", "ep_vb") == pytest.approx(1 / 11)
assert val(feat, A, "2023-08-29", "ep_vb") == pytest.approx(1 / 10)
assert val(feat, A, "2023-08-29", "bp_vb") == pytest.approx(1 / 2.0)
assert val(feat, B, "2023-08-29", "ep_vb") == pytest.approx(1 / 20)
assert val(feat, B, "2023-08-29", "bp_vb") == pytest.approx(1 / 0.8)
def test_vb_ep_guards(feat):
# 亏损(baostock pe=NaN)→ ep NaN;pe=0 → NaN(1/0 守卫);bp 不受影响
assert val(feat, C, "2023-08-29", "ep_vb") is None
assert val(feat, C, "2023-08-29", "bp_vb") == pytest.approx(1 / 5.0)
assert val(feat, B, "2023-08-30", "ep_vb") is None
assert val(feat, B, "2023-08-30", "bp_vb") == pytest.approx(1 / 0.8)
def test_vb_exchange_full_form_tolerated(synthetic_static):
"""exchange 全称变体(SSE/SZSE)与实测缩写(SH/SZ)同被映射(旧契约兼容)."""
df = build_fundamental_features(
["600004.SSE", "600000.SSE"], "2023-08-25", "2023-08-31",
data_dir=synthetic_static)
assert val(df, "600004.SSE", "2023-08-29", "ep_vb") == pytest.approx(1 / 40)
assert val(df, "600004.SSE", "2023-08-29", "bp_vb") == pytest.approx(1 / 4.0)
def test_vb_2026_gap_splice(synthetic_static):
"""2026-01-01~08-12 用 static/valuation(ak)倒数补;08-13 起用 vb."""
df = build_fundamental_features(
[A], "2026-01-01", "2026-08-15", data_dir=synthetic_static)
assert val(df, A, "2026-01-05", "ep_vb") == pytest.approx(1 / 25) # ak
assert val(df, A, "2026-01-05", "bp_vb") == pytest.approx(1 / 5.0)
assert val(df, A, "2026-08-11", "ep_vb") == pytest.approx(1 / 25) # ak ffill
assert val(df, A, "2026-08-12", "ep_vb") == pytest.approx(1 / 24) # ak 末日
assert val(df, A, "2026-08-13", "ep_vb") == pytest.approx(1 / 20) # vb 接管
assert val(df, A, "2026-08-13", "bp_vb") == pytest.approx(1 / 4.0)
assert val(df, A, "2026-08-14", "ep_vb") == pytest.approx(1 / 19)
# ---------- 缺域优雅降级(本地无 NAS 数据) ----------
def test_missing_domains_degrade_to_nan(synthetic_static, tmp_path):
"""四域整体缺失 → 六新列全 NaN + warning,不崩(管线可本地跑通)."""
dst = tmp_path / "no_domains"
shutil.copytree(os.path.dirname(synthetic_static), str(dst))
for sub in ("dividend", "gdhs", "valuation"):
shutil.rmtree(str(dst / "static" / sub))
shutil.rmtree(str(dst / "factor_cache"))
shutil.rmtree(str(dst / "valuation_baostock"))
with pytest.warns(UserWarning, match=""):
df = build_fundamental_features(
[A], "2023-08-25", "2023-08-31", data_dir=str(dst / "static"))
assert df.height > 0
for col in ("send_total_12m", "gdhs_chg", "topholder_chg", "ep_vb", "bp_vb"):
assert df[col].null_count() == df.height, f"{col} 缺域应全 NaN"
# 报表域仍在 → ccc 照常产出(缺的只是四个新域)
assert val(df, A, "2023-08-29", "ccc") is not None
# ---------- 分块等值(新列过一遍 batch_codes=1 极端路径) ----------
def test_chunked_equals_full_p1b_columns(synthetic_static):
six = ["600000.SSE", "000001.SZSE", "300001.SZSE",
"600004.SSE", "000333.SZSE", "300124.SZSE"]
full = build_fundamental_features(
six, "2023-01-01", "2023-12-31", data_dir=synthetic_static, batch_codes=6)
by_one = build_fundamental_features(
six, "2023-01-01", "2023-12-31", data_dir=synthetic_static, batch_codes=1)
key = ["vt_symbol", "datetime"]
assert full.sort(key).equals(by_one.sort(key))