# 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/12−1) 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/15−1)−(15/14−1) 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/16−1)−(16/15−1) 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/580−1;08-29 起换 2023H1: 680/600−1 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/530−1(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))