# 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: 57−50 assert val(feat, A, "2023-04-30", "topholder_chg") == pytest.approx(5.0) # Q1'23: 65−60(年报 Δ=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: 62−65 # 无 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))