60dd84ad4a
- 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)
249 lines
12 KiB
Python
249 lines
12 KiB
Python
# 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))
|