a8eacfb017
Co-Authored-By: Claude Code <noreply@anthropic.com>
159 lines
7.3 KiB
Python
159 lines
7.3 KiB
Python
# tests/factor/test_fundamental_p2_adapter.py
|
||
"""P2 批财务因子适配层: 七项目指标调研增量(PEG/PS/股息族).
|
||
|
||
口径锚(P2 任务书=七项目调研报告 §5,2026-09-22):
|
||
- D16 PEG 原料: np_ttm_yoy = TTM 净利同比(_yoy4 ttm 口径);peg_denom =
|
||
np_ttm×np_ttm_yoy(守卫 np_ttm>0 且 g>1%——增速失真不硬算,表达式层无
|
||
条件算子故守卫前移特征层;除 null 即 NaN)
|
||
- D17 PS 原料: rev_ttm = 营收 TTM(与 EP/BP/CP 同分母体系)
|
||
- E14 div_12m = 近 365 天(开区间下界)已实施现金分红/股,PIT 锚 = 除息日
|
||
(dividOperateDate,钱到股东日);除息日缺失行对现金族丢弃(宁缺毋假,
|
||
不影响 E11 送转流的公告日回退链)
|
||
- E15 div_payout = div_12m×share_capital/np_ttm(亏损派息语义反转 →
|
||
np_ttm>0 守卫,宁缺毋假)
|
||
- E16 div_streak = 截至当日的连续现金分红年数(按除息日所在年;断年重计,
|
||
纯送转/零现金事件不计年)
|
||
"""
|
||
import math
|
||
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")))
|
||
|
||
from datetime import date
|
||
|
||
import polars as pl
|
||
import pytest
|
||
|
||
from sanguo_factor.fundamental_adapter import build_fundamental_features
|
||
from sanguo_factor.fundamental_report_features import _compute_report_features
|
||
|
||
A, B, C, BANK = "600000.SSE", "000001.SZSE", "300001.SZSE", "601398.SSE"
|
||
|
||
|
||
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], "2021-01-01", "2024-06-30", data_dir=synthetic_static)
|
||
|
||
|
||
# ---------- D16 PEG 原料(np_ttm_yoy / peg_denom) ----------
|
||
|
||
def test_np_ttm_yoy_and_peg_denom_at_h1(feat):
|
||
# 2023-08-29 见 2023H1(i=21): ttm_np(21)=q18..21=15+18+13+14=60,
|
||
# ttm_np(17)=q14..17=15+16+11+14=56 → yoy=60/56−1=1/14
|
||
assert val(feat, A, "2023-08-29", "np_ttm_yoy") == pytest.approx(1.0 / 14.0)
|
||
# 守卫链通过(g≈7.1%>1%) → denom = 60×(1/14)
|
||
assert val(feat, A, "2023-08-29", "peg_denom") == pytest.approx(60.0 / 14.0)
|
||
|
||
|
||
def test_peg_denom_growth_floor_guard():
|
||
"""g≤1%(增速失真)→ peg_denom NaN 不硬算: 8 期持平净利的迷你报表."""
|
||
rds = [f"{y}-{m}" for y in (2021, 2022) for m in ("03-31", "06-30", "09-30", "12-31")]
|
||
base = {c: None for c in (
|
||
"rev cogs np dnp tp invest_inc fv_inc asset_imp credit_imp cfo sales_cash "
|
||
"acc_inv_cash research sale_exp int_exp tax eps fa_depr ia_amort "
|
||
"lpe_amort ua_amort capex recv_loan issue_bond pay_debt").split()}
|
||
rows = []
|
||
for i, rd in enumerate(rds):
|
||
r = {"vt_symbol": "600000.SSE",
|
||
"REPORT_DATE": date.fromisoformat(rd),
|
||
"notice_eff": date.fromisoformat(rd).replace(day=28),
|
||
"TOTAL_ASSETS": 1000.0, "TOTAL_PARENT_EQUITY": 500.0,
|
||
"ACCOUNTS_RECE": 100.0, "OTHER_RECE": 5.0, "GOODWILL": 0.0,
|
||
"SHARE_CAPITAL": 100.0, "INVENTORY": 50.0, "MONETARYFUNDS": 80.0,
|
||
"ACCOUNTS_PAYABLE": 60.0, "SHORT_LOAN": 10.0, "SHORT_FIN_PAYABLE": 0.0,
|
||
"NONCURRENT_LIAB_1YEAR": 0.0, "LONG_LOAN": 0.0, "BOND_PAYABLE": 0.0,
|
||
"LEASE_LIAB": 0.0, "UPDATE_DATE": None, "NOTICE_DATE": None,
|
||
**base}
|
||
# 累计口径: 每年 4 季各 25(=单季持平) → TTM 恒 100 → yoy=0 → 守卫触发
|
||
cum = 25.0 * (i % 4 + 1)
|
||
r.update({"rev": cum, "np": cum, "cogs": 0.6 * cum})
|
||
rows.append(r)
|
||
# 全 None 列须显式 Float64(真实 loader 有 cast,迷你帧没有 → Null dtype
|
||
# 会让 .abs()/算术炸)
|
||
out = _compute_report_features(pl.DataFrame(
|
||
rows, schema_overrides={c: pl.Float64 for c in base}))
|
||
last = out.filter(pl.col("REPORT_DATE") == date(2022, 12, 31))
|
||
assert last.height == 1
|
||
assert last["np_ttm_yoy"][0] == pytest.approx(0.0)
|
||
assert last["peg_denom"][0] is None, "g=0 ≤1% 增速失真必须 NaN(宁缺毋假)"
|
||
|
||
|
||
# ---------- D17 PS 原料(rev_ttm) ----------
|
||
|
||
def test_rev_ttm_at_h1(feat):
|
||
# ttm_rev(21) = q_rev18..21 = 150+180+130+140 = 600(与 E08 CCC 断言互证)
|
||
assert val(feat, A, "2023-08-29", "rev_ttm") == pytest.approx(600.0)
|
||
# scale=2 的 C 同窗翻倍(比率族 scale 不变性对原料列不成立,原料×scale)
|
||
assert val(feat, C, "2023-08-29", "rev_ttm") == pytest.approx(1200.0)
|
||
|
||
|
||
# ---------- E14 div_12m(除息日锚 365 天滚动) ----------
|
||
|
||
def test_div_12m_window_and_pit(feat):
|
||
# A 现金事件(除息日): 2022-06-20(1.0) 2023-03-15(2.0) 2023-09-25(3.0)
|
||
# 2023-12-05(4.0) 2024-01-10(5.0)
|
||
assert val(feat, A, "2023-03-14", "div_12m") == pytest.approx(1.0) # 只含 2022-06-20
|
||
assert val(feat, A, "2023-03-15", "div_12m") == pytest.approx(3.0) # 当日除息即计入
|
||
assert val(feat, A, "2023-09-24", "div_12m") == pytest.approx(2.0) # 2022-06-20 出窗
|
||
assert val(feat, A, "2023-12-31", "div_12m") == pytest.approx(9.0) # 2+3+4
|
||
assert val(feat, A, "2024-06-30", "div_12m") == pytest.approx(12.0) # 3+4+5
|
||
|
||
|
||
def test_div_12m_nan_before_first_event(feat):
|
||
# 首个除息日前 → NaN(域内无事件,不填 0;与窗口和为 0 区分)
|
||
assert val(feat, A, "2022-06-19", "div_12m") is None
|
||
# 无 dividend 文件的 BANK → NaN
|
||
assert val(feat, BANK, "2023-12-31", "div_12m") is None
|
||
|
||
|
||
# ---------- E15 div_payout(亏损守卫) ----------
|
||
|
||
def test_div_payout_value(feat):
|
||
# 2023-12-31(A): 见 2023Q3(i=22) → ttm_np=62, share_capital=110
|
||
# payout = 9.0×110/62
|
||
assert val(feat, A, "2023-12-31", "div_payout") == pytest.approx(9.0 * 110.0 / 62.0)
|
||
# B: div_12m=0.5(纯现金), np_ttm 同窗可算 → 值非 NaN
|
||
v = val(feat, B, "2023-12-31", "div_payout")
|
||
assert v is not None and v > 0
|
||
|
||
|
||
# ---------- E16 div_streak(连续现金分红年数) ----------
|
||
|
||
def test_div_streak_progression(feat):
|
||
assert val(feat, A, "2022-06-19", "div_streak") is None # 首事件前 NaN
|
||
assert val(feat, A, "2022-12-31", "div_streak") == 1.0 # 2022 年事件后=1
|
||
assert val(feat, A, "2023-03-15", "div_streak") == 2.0 # 2023 首事件 → 连续第 2 年
|
||
assert val(feat, A, "2023-12-31", "div_streak") == 2.0 # 同年后续事件不递增
|
||
assert val(feat, A, "2024-01-10", "div_streak") == 3.0 # 2024 首事件 → 3
|
||
|
||
|
||
def test_div_streak_gap_reset(feat):
|
||
# C 现金年份 2021/2023(2022 断档): 2023-06-01 事件时 streak 重计=1
|
||
# (若误实现为「累计分红年数」会得 2)
|
||
assert val(feat, C, "2023-06-01", "div_streak") == 1.0
|
||
assert val(feat, C, "2021-12-31", "div_streak") == 1.0
|
||
|
||
|
||
# ---------- 引用瘦身路径(仅域列请求) ----------
|
||
|
||
def test_domain_only_columns_subset(synthetic_static):
|
||
df = build_fundamental_features(
|
||
[A], "2023-12-01", "2023-12-31", data_dir=synthetic_static,
|
||
columns=["div_12m", "div_streak"])
|
||
assert df.columns == ["vt_symbol", "datetime", "div_12m", "div_streak"]
|
||
assert val(df, A, "2023-12-05", "div_12m") == pytest.approx(9.0)
|
||
assert val(df, A, "2023-12-05", "div_streak") == 2.0
|