Files
sanguo_vnpy_v2/tests/factor/test_fundamental_p1_adapter.py
T

341 lines
17 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_p1_adapter.py
"""P1 批财务因子适配层: 35 因子原料列的数值/守卫/PIT 契约.
口径锚(docs/fundamental_factor_survey_20260907.md §1.3 + P1 任务书):
- EBIT = TOTAL_PROFIT + FE_INTEREST_EXPENSE(利息未披露按 0)
- DA = 四件折旧摊销之和(缺列/缺值按 0)
- τ = INCOME_TAX/TOTAL_PROFIT(TTM)截断 [0,0.5],缺失/TP≤0 → 0.25
- IBD 含 LEASE_LIAB(钉死版);VSIG 16 季 sample std(ddof=1)
- F07 锚 = max(实际披露日, 预告公告日)
报告期索引(24 期合成史): 2023Q1=i20 / 2023H1=i21 / 2023Q3=i22 / 2023Q4=i23;
2022Q4=i19 / 2022H1=i17 / 2021Q4=i15 / 2019Q4=i15 前推。
"""
import statistics
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"
@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)
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)
# ---------- A 盈利能力 P1(7) ----------
def test_a_family_values_at_h1(feat):
# 2023H1(i=21, 披露 2023-08-29): NP_TTM=60 REV_TTM=600 TA=1450 EQ=680
# INT_TTM=0.2×60=12 TP_TTM=66 → EBIT=78; DA_TTM=60; τ=0.25; MON=240 IBD=109
assert val(feat, A, "2023-08-29", "ebit_over_assets") == pytest.approx(78 / 1450)
assert val(feat, A, "2023-08-29", "roa_pretax") == pytest.approx((60 + 12 * 0.75) / 1450)
assert val(feat, A, "2023-08-29", "ebitda_margin") == pytest.approx(138 / 600)
assert val(feat, A, "2023-08-29", "roic") == pytest.approx(78 * 0.75 / (680 + 109 - 240))
assert val(feat, A, "2023-08-29", "roe_avg") == pytest.approx(60 / 640) # mean(680,600)
assert val(feat, A, "2023-08-29", "rd_intensity") == pytest.approx(0.05)
assert val(feat, A, "2023-08-29", "sale_expense_ratio") == pytest.approx(0.08)
assert val(feat, A, "2023-08-29", "interest_cover") == pytest.approx(78 / 12)
assert val(feat, A, "2023-08-29", "ebit_ttm") == pytest.approx(78.0)
assert val(feat, A, "2023-08-29", "ebitda_ttm") == pytest.approx(138.0)
# scale 不变性: C(scale=2) EBIT 翻倍、比率不变
assert val(feat, C, "2023-08-29", "ebit_ttm") == pytest.approx(156.0)
assert val(feat, C, "2023-08-29", "roic") == pytest.approx(78 * 0.75 / (680 + 109 - 240))
def test_a_family_pit_boundary(feat):
# 2023-08-28 仍见 2023Q1(i=20): TTM=q(17..20)=60 → EBIT=78, TA=1400
assert val(feat, A, "2023-08-28", "ebit_over_assets") == pytest.approx(78 / 1400)
assert val(feat, A, "2023-08-29", "ebit_over_assets") == pytest.approx(78 / 1450)
def test_a_family_guards(feat):
# B 缺 2022Q1(i=16) → 2023Q1(i=20) 平均净资产基期不连续 → roe_avg NaN
assert val(feat, B, "2023-04-28", "roe_avg") is None
# 滑出坏点后恢复: 2023H1 基期 = 2022H1(i=17) 行存在且恰隔 4 季
assert val(feat, B, "2023-08-29", "roe_avg") == pytest.approx(60 / 640)
# ---------- B 盈利质量 P1(8) ----------
def test_b_family_values_at_h1(feat):
assert val(feat, A, "2023-08-29", "cash_ibd_product") == pytest.approx((240 / 1450) * (109 / 1450))
assert val(feat, A, "2023-08-29", "da_intensity") == pytest.approx(0.10)
assert val(feat, A, "2023-08-29", "gm_nm_scissors") == pytest.approx(0.4 - 0.1, abs=1e-9)
# 存货异常 = 存货期末同比 REV_TTM 同比(INV: 245/225; REV_TTM: 600/560)
assert val(feat, A, "2023-08-29", "inventory_anomaly") == pytest.approx(
245 / 225 - 600 / 560)
# 持续盈利: A 全史单季 NP>0 → 截断 8
assert val(feat, A, "2023-08-29", "profit_streak") == pytest.approx(8.0)
def test_b_family_pit_boundary(feat):
# 2023-08-28 仍见 2023Q1(i=20): INV 240/220 REV_TTM 600/540
assert val(feat, A, "2023-08-28", "inventory_anomaly") == pytest.approx(
240 / 220 - 600 / 540)
def test_b_family_profit_streak_recount_after_gap(feat):
# B: i=16 行缺失且 i=17 单季 NaN(两个断流点)→ 2023H1 连续计数 = 4 季
assert val(feat, B, "2023-08-29", "profit_streak") == pytest.approx(4.0)
# 2023Q3(i=22) 再 +1 → 5
assert val(feat, B, "2023-10-27", "profit_streak") == pytest.approx(5.0)
def test_vsig_three_variants(feat):
# 独立重算 16 季窗(i=6..21): series = 单季NP/TA, sample std(ddof=1)
np_q = [7, 10, 11, 12, 8, 11, 12, 13, 9, 13, 14, 15, 10, 12, 15, 16,
11, 14, 15, 18, 13, 14, 17, 18]
ta = lambda i: 1000 + 50 * max(i - 12, 0)
series = [np_q[i] / ta(i) for i in range(6, 22)]
expect = statistics.stdev(series)
assert val(feat, A, "2023-08-29", "vsig") == pytest.approx(expect)
# CFO = 1.2×NP / 应计 = 0.2×NP → 线性变换下 std 同比例(口径自洽性)
assert val(feat, A, "2023-08-29", "vsig_cfo") == pytest.approx(1.2 * expect)
assert val(feat, A, "2023-08-29", "vsig_acc") == pytest.approx(0.2 * expect)
def test_vsig_insufficient_history_nan(synthetic_static, np_q_series):
# 2020Q3(i=10) 报告期: 16 季窗需 i=-5..10 → 不足 → NaN
df = build_fundamental_features([A], "2020-10-20", "2020-11-05", data_dir=synthetic_static)
assert val(df, A, "2020-10-27", "vsig") is None
# 首个可算窗 = 2019Q4(i=15, 窗 i=0..15),2022-04-25 可见
# (TA 自 i=13 起线性抬升,series = q/TA 非恒定分母)
df2 = build_fundamental_features([A], "2022-04-20", "2022-05-05", data_dir=synthetic_static)
assert val(df2, A, "2022-04-24", "vsig") is None
assert val(df2, A, "2022-04-25", "vsig") == pytest.approx(
statistics.stdev(
[float(np_q_series[i]) / (1000 + 50 * max(i - 12, 0)) for i in range(0, 16)]))
def test_vsig_null_in_window_nan(feat):
# B 窗 i=6..21 含 i=17 单季 NaN → vsig NaN(窗内缺失不填 0)
assert val(feat, B, "2023-08-29", "vsig") is None
assert val(feat, B, "2023-08-29", "vsig_acc") is None
assert val(feat, B, "2023-08-29", "vsig_cfo") is None
# ---------- C 成长 P1(8) ----------
def test_c_family_acceleration(feat):
# np_accel(2023H1) = yoy(i21) yoy(i17) = 0 (14/121)
assert val(feat, A, "2023-08-29", "np_accel") == pytest.approx(-(14 / 12 - 1))
assert val(feat, A, "2023-08-29", "rev_accel") == pytest.approx(-(140 / 120 - 1))
# nm_delta = 0.1 0.1(合成史 NM 恒 0.1;管线+守卫仍被锁定)
assert val(feat, A, "2023-08-29", "nm_delta") == pytest.approx(0.0, abs=1e-9)
def test_c_family_pit_boundary(synthetic_static):
# 2023-04-24 仍见 2022Q3(i=18): accel = (15/151)(15/141)
df = build_fundamental_features([A], "2023-04-20", "2023-04-30", data_dir=synthetic_static)
assert val(df, A, "2023-04-24", "np_accel") == pytest.approx(0.0 - (15 / 14 - 1))
# 2023-04-25 起 2022Q4(i=19): accel = (18/161)(16/151)
assert val(df, A, "2023-04-25", "np_accel") == pytest.approx((18 / 16 - 1) - (16 / 15 - 1))
def test_c_family_growth_guards(feat):
# B 基期(i=17) 单季 NaN → yoy NaN → 加速度二次差分 NaN 传播
assert val(feat, B, "2023-08-29", "np_accel") is None
# B 2023H1 的 NM_TTM 基期(i=17) TTM 含 NaN → nm_delta NaN
assert val(feat, B, "2023-08-29", "nm_delta") is None
# invest_growth 仅年报行: 2023H1(非年报) → NaN
assert val(feat, A, "2023-08-29", "invest_growth") is None
def test_c_family_stock_growth(feat):
# nwc = (存货+应收): 2023H1 435/2022H1 375 1
assert val(feat, A, "2023-08-29", "nwc_growth") == pytest.approx(435 / 375 - 1)
# equity_growth = 680/600 1;goodwill 恒 50 → 0
assert val(feat, A, "2023-08-29", "equity_growth") == pytest.approx(680 / 600 - 1)
assert val(feat, A, "2023-08-29", "goodwill_growth") == pytest.approx(0.0, abs=1e-9)
def test_c_family_pit_equity_growth(feat):
# 2023-08-28 见 2023Q1(i=20): 660/5801;08-29 起换 2023H1: 680/6001
assert val(feat, A, "2023-08-28", "equity_growth") == pytest.approx(660 / 580 - 1)
assert val(feat, A, "2023-08-29", "equity_growth") == pytest.approx(680 / 600 - 1)
def test_cagr5_and_annual_factors(synthetic_static):
"""5 年 CAGR 首个可算点 = 2023 年报(基期 2018 年报),2024-04-25 可见."""
df = build_fundamental_features([A], "2024-04-20", "2024-04-30", data_dir=synthetic_static)
assert val(df, A, "2024-04-24", "rev_cagr5") is None
assert val(df, A, "2024-04-24", "np_cagr5") is None
# 2023 年报 REV=620 NP=62;2018 年报 REV=400 NP=40
assert val(df, A, "2024-04-25", "rev_cagr5") == pytest.approx((620 / 400) ** 0.2 - 1)
assert val(df, A, "2024-04-25", "np_cagr5") == pytest.approx((62 / 40) ** 0.2 - 1)
# 投资增速(年度口径) = 2023 年 capex / 2022 年 capex 1(capex=0.15×年报REV)
assert val(df, A, "2024-04-25", "invest_growth") == pytest.approx(620 / 580 - 1)
# 2022 年报(i=19) 投资增速 2023-04-25 已可见 = 580/5301(2021 年报 530)
df2 = build_fundamental_features([A], "2023-04-20", "2023-04-30", data_dir=synthetic_static)
assert val(df2, A, "2023-04-25", "invest_growth") == pytest.approx(580 / 530 - 1)
# ---------- D 估值 P1(6 的原料列) ----------
def test_d_family_raw_materials(feat):
assert val(feat, A, "2023-08-29", "gp_ttm") == pytest.approx(240.0)
assert val(feat, A, "2023-08-29", "fcf_ttm") == pytest.approx(72 - 90)
assert val(feat, A, "2023-08-29", "debt_issue_ttm") == pytest.approx(60.0)
# EV 外生部分 = IBD MON = 109 240 = 131(表达式层再 + close×share_capital)
assert val(feat, A, "2023-08-29", "ev_ex_mv") == pytest.approx(-131.0)
def test_d_family_pit_boundary(feat):
# 2023-08-28 见 2023Q1(i=20): IBD=108 MON=230 → 122;08-29 起 131
assert val(feat, A, "2023-08-28", "ev_ex_mv") == pytest.approx(-122.0)
assert val(feat, A, "2023-08-29", "ev_ex_mv") == pytest.approx(-131.0)
# ---------- F 预期事件 P1(3 + 严窗变体) ----------
def test_sue_eps_foster(synthetic_static):
"""SUE(EPS): EPS 累计 = 年内 NP 累计/当期股本(2023 起 110 股)."""
np_q = [7, 10, 11, 12, 8, 11, 12, 13, 9, 13, 14, 15, 10, 12, 15, 16,
11, 14, 15, 18, 13, 14, 17, 18]
sc = lambda i: 100 if i < 20 else 110
cum = lambda i: float(sum(np_q[(i // 4) * 4:i + 1]))
eps_c = [cum(i) / sc(i) for i in range(24)]
q_eps = [eps_c[0]] + [eps_c[i] - eps_c[i - 1] if (i % 4) else eps_c[i]
for i in range(1, 24)] # Q1 直接取累计
diff4 = [q_eps[i] - q_eps[i - 4] for i in range(4, 24)]
df = build_fundamental_features([A], "2023-10-25", "2023-10-28", data_dir=synthetic_static)
# 2023Q3(i=22, 披露 10-27): 窗 diff4[11..18]
assert val(df, A, "2023-10-27", "sue_eps") == pytest.approx(
diff4[18] / statistics.stdev(diff4[11:19]))
assert val(df, A, "2023-10-26", "sue_eps") is None or \
val(df, A, "2023-10-26", "sue_eps") == pytest.approx(
diff4[17] / statistics.stdev(diff4[10:18]))
def test_sue_np_strict_window_excludes_current(feat, np_q_series):
"""严窗变体: σ 只用 t−1 及更早差分(shift(1) 后滚 8 期)."""
q = [float(x) for x in np_q_series]
diff4 = [q[i] - q[i - 4] for i in range(4, 24)]
# 2023Q3(i=22, r=18): σ 窗 = diff4[10..17](不含当期)
expect = diff4[18] / statistics.stdev(diff4[10:18])
assert val(feat, A, "2023-10-27", "sue_np_strict") == pytest.approx(expect)
# 与标准 SUE 数值不同(σ 窗不同)且均非空
std = val(feat, A, "2023-10-27", "sue_np")
assert std is not None and std != pytest.approx(expect)
def test_disclosure_speed(feat):
# A 2023H1: 披露 08-29 报告期 06-30 = 60 天 → 60(早披露=高分)
assert val(feat, A, "2023-08-28", "disclosure_speed") == pytest.approx(-28.0)
assert val(feat, A, "2023-08-29", "disclosure_speed") == pytest.approx(-60.0)
# 年报 2022Q4: 2023-04-25 2022-12-31 = 115 天
assert val(feat, A, "2023-04-25", "disclosure_speed") == pytest.approx(-115.0)
def test_forecast_np_annualized(feat):
# A: 2022 年报预告(公告 2023-05-10,中值 55)年化系数 = 年报×1 → 55
assert val(feat, A, "2023-05-10", "forecast_np_annualized") == pytest.approx(55.0)
# 2023H1 预告(公告 07-15)覆盖: 中值 30 × 2 = 60
assert val(feat, A, "2023-07-14", "forecast_np_annualized") == pytest.approx(55.0)
assert val(feat, A, "2023-07-15", "forecast_np_annualized") == pytest.approx(60.0)
assert val(feat, B, "2023-07-20", "forecast_np_annualized") == pytest.approx(80.0)
# C 只有营业收入行 → 年化预告净利不产出(收入中值不作净利)
assert val(feat, C, "2023-07-10", "forecast_np_annualized") is None
# Q3 预告(公告 10-15): 中值 65 × 4/3
assert val(feat, A, "2023-10-15", "forecast_np_annualized") == pytest.approx(65 * 4 / 3)
def test_forecast_beat_pit_anchor(feat):
# 2022Q4 配对先可见: 实际 NP 58 vs 中值 55(锚 05-10)→ 3/55
assert val(feat, A, "2023-08-28", "forecast_beat") == pytest.approx(3 / 55)
# 2023H1 配对: 实际 NP 27 vs 中值 30 → beat = 0.1,锚 = max(08-29, 07-15) = 08-29
assert val(feat, A, "2023-08-29", "forecast_beat") == pytest.approx(-0.1)
assert val(feat, B, "2023-08-29", "forecast_beat") == pytest.approx((27 - 40) / 40)
# 2023Q3 配对: 实际 44 vs 中值 65,锚 = max(10-27, 10-15) = 10-27
assert val(feat, A, "2023-10-26", "forecast_beat") == pytest.approx(-0.1)
assert val(feat, A, "2023-10-27", "forecast_beat") == pytest.approx((44 - 65) / 65)
def test_forecast_beat_late_forecast_anchor(synthetic_static):
"""迟到预告: 2022 年报披露 04-25,预告公告 05-10 晚于披露 → 锚 = 05-10
(不早于两者较晚者;若锚错取披露日则 04-25 即可见 → 测试即红)."""
df = build_fundamental_features([A], "2023-04-20", "2023-05-15", data_dir=synthetic_static)
assert val(df, A, "2023-05-09", "forecast_beat") is None
assert val(df, A, "2023-05-10", "forecast_beat") == pytest.approx((58 - 55) / 55)
# ---------- 北交所预告映射(随批互评 1) ----------
def test_bj_forecast_exchange_mapping(synthetic_static):
"""92/43 前缀 → .BJSE(不再落入 SZSE);北交股无三表 → 报表特征全 null."""
df = build_fundamental_features(
["920001.BJSE", "430047.BJSE"], "2023-07-14", "2023-07-21",
data_dir=synthetic_static)
assert val(df, "920001.BJSE", "2023-07-17", "forecast_type_score") is None
assert val(df, "920001.BJSE", "2023-07-18", "forecast_type_score") == 3.0
assert val(df, "920001.BJSE", "2023-07-18", "forecast_change_pct") == pytest.approx(20.0)
assert val(df, "920001.BJSE", "2023-07-18", "forecast_np_annualized") == pytest.approx(100.0)
assert val(df, "430047.BJSE", "2023-07-19", "forecast_type_score") == 3.0
assert val(df, "430047.BJSE", "2023-07-19", "forecast_np_annualized") == pytest.approx(40.0)
# 三表侧: BJSE 无文件映射 → 报表特征 null,不炸
assert val(df, "920001.BJSE", "2023-07-18", "equity") is None
# ---------- 金融股红线(§7 红线 5) ----------
def test_financial_stock_family_gating(synthetic_static):
"""银行模板(OPERATE_COST 缺失): 盈利质量/成长/费用类 NaN,盈利能力/估值保留."""
df = build_fundamental_features([BANK], "2023-08-25", "2023-09-02", data_dir=synthetic_static)
assert val(df, BANK, "2023-08-29", "roe_ttm") == pytest.approx(60 / 680)
assert val(df, BANK, "2023-08-29", "ebit_over_assets") == pytest.approx(78 / 1450)
assert val(df, BANK, "2023-08-29", "interest_cover") == pytest.approx(6.5)
for col in ("rd_intensity", "sale_expense_ratio", "inventory_anomaly",
"cash_ibd_product", "vsig", "vsig_acc", "vsig_cfo", "da_intensity",
"gm_nm_scissors", "profit_streak", "np_accel", "rev_accel",
"nm_delta", "rev_cagr5", "np_cagr5", "nwc_growth",
"invest_growth", "equity_growth"):
assert val(df, BANK, "2023-08-29", col) is None, f"{col} 金融股应置 NaN"
# ---------- 列子集(引用瘦身)与分块 ----------
def test_columns_subset(synthetic_static):
df = build_fundamental_features(
[A, B], "2023-08-25", "2023-09-02", data_dir=synthetic_static,
columns=["vsig", "share_capital"])
assert df.columns == ["vt_symbol", "datetime", "vsig", "share_capital"]
assert val(df, A, "2023-08-29", "share_capital") == pytest.approx(110.0)
# 子集列与全量产出逐值一致
full = build_fundamental_features(
[A, B], "2023-08-25", "2023-09-02", data_dir=synthetic_static)
key = ["vt_symbol", "datetime"]
assert (df.sort(key).select(key + ["vsig"]).equals(
full.sort(key).select(key + ["vsig"])))
def test_chunked_equals_full_p1_columns(synthetic_static):
"""新列也过一遍分块等值(batch_codes=1 极端路径)."""
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))