feat(factor): P1批adapter特征层36列——EBIT/DA/τ/ROIC+16季VSIG+五年CAGR+预告配对三事件流 [nas]
This commit is contained in:
@@ -53,17 +53,40 @@ _CUM_MAP = {
|
||||
"NETCASH_OPERATE": "cfo",
|
||||
"SALES_SERVICES": "sales_cash",
|
||||
"ACCEPT_INVEST_CASH": "acc_inv_cash",
|
||||
# ---- P1 批新增(income;NAS 实测 2026-09-08 列名核实)----
|
||||
"RESEARCH_EXPENSE": "research", # 研发费用(2018Q3 起单列,早年 null 传播)
|
||||
"SALE_EXPENSE": "sale_exp", # 销售费用
|
||||
"FE_INTEREST_EXPENSE": "int_exp", # 财务费用-利息费用(披露稀疏,EBIT 组装层按 0)
|
||||
"INCOME_TAX": "tax", # 所得税费用(τ 实际税率分子)
|
||||
"BASIC_EPS": "eps", # 基本每股收益(累计口径,需单季化)
|
||||
# ---- P1 批新增(cashflow 补充资料段 + 融资流)----
|
||||
"FA_IR_DEPR": "fa_depr", # 固定资产折旧
|
||||
"IA_AMORTIZE": "ia_amort", # 无形资产摊销
|
||||
"LPE_AMORTIZE": "lpe_amort", # 长期待摊费用摊销
|
||||
"USERIGHT_ASSET_AMORTIZE": "ua_amort", # 使用权资产折旧摊销(2019 起才有)
|
||||
"CONSTRUCT_LONG_ASSET": "capex", # 购建长期资产现金(C17 投资率/D15 FCF)
|
||||
"RECEIVE_LOAN_CASH": "recv_loan", # 取得借款现金(E03)
|
||||
"ISSUE_BOND": "issue_bond", # 发行债券现金(E03)
|
||||
"PAY_DEBT_CASH": "pay_debt", # 偿还债务现金(E03)
|
||||
}
|
||||
# 存量列(balance,时点值直接用;缺列 → null)
|
||||
_BALANCE_COLS = ["TOTAL_ASSETS", "TOTAL_PARENT_EQUITY", "ACCOUNTS_RECE",
|
||||
"OTHER_RECE", "GOODWILL", "SHARE_CAPITAL", "SHORT_LOAN",
|
||||
"SHORT_FIN_PAYABLE", "NONCURRENT_LIAB_1YEAR", "LONG_LOAN",
|
||||
"BOND_PAYABLE", "LEASE_LIAB"]
|
||||
"BOND_PAYABLE", "LEASE_LIAB",
|
||||
# P1 新增存量列
|
||||
"INVENTORY", "MONETARYFUNDS"]
|
||||
# IBD 口径钉死含 LEASE_LIAB 版(survey §3.5 E 族注意点「定稿钉死」;2019 前该列
|
||||
# 整体缺失按 0,与一年内到期非流动负债等组件同款处理)
|
||||
_IBD_PARTS = ["SHORT_LOAN", "SHORT_FIN_PAYABLE", "NONCURRENT_LIAB_1YEAR",
|
||||
"LONG_LOAN", "BOND_PAYABLE", "LEASE_LIAB"]
|
||||
# DA 组装: cashflow 间接法补充资料四件,列/值缺失按 0(「列存在才加」;
|
||||
# USERIGHT_ASSET_AMORTIZE 2019 前整列缺失不影响早年 DA)
|
||||
_DA_PARTS = ["fa_depr", "ia_amort", "lpe_amort", "ua_amort"]
|
||||
_DATE_COLS = ["REPORT_DATE", "NOTICE_DATE", "UPDATE_DATE"]
|
||||
|
||||
# 输出特征列(32 因子的全部原料;契约由 test_fundamental_library 锁定)
|
||||
# 输出特征列(P0 32 + P1 35 因子及 2 变体的全部原料;契约由 test_fundamental_library
|
||||
# 与 test_fundamental_p1_library 锁定)
|
||||
FEATURE_COLUMNS: list[str] = [
|
||||
# 报告期级比率(盈利能力 A / 盈利质量 B / 成长 C / 资本结构 E / 预期事件 F)
|
||||
"roe_ttm", "roe_deduct_ttm", "roa_ttm", "gp_over_assets", "gross_margin",
|
||||
@@ -77,14 +100,41 @@ FEATURE_COLUMNS: list[str] = [
|
||||
# 估值/资本行为因子的日频原料(表达式层 ÷ close×share_capital)
|
||||
"np_ttm", "dnp_ttm", "cfo_ttm", "equity", "share_capital",
|
||||
"acc_invest_cash_ttm",
|
||||
# ---- P1 批新增(A 盈利 7)----
|
||||
"roe_avg", "roa_pretax", "ebit_over_assets", "ebitda_margin", "roic",
|
||||
"rd_intensity", "sale_expense_ratio",
|
||||
# ---- P1 批新增(B 质量 8;B12 为哑变量的连续近似乘积)----
|
||||
"inventory_anomaly", "cash_ibd_product", "vsig", "vsig_acc", "vsig_cfo",
|
||||
"da_intensity", "gm_nm_scissors", "profit_streak",
|
||||
# ---- P1 批新增(C 成长 8)----
|
||||
"np_accel", "rev_accel", "nm_delta", "rev_cagr5", "np_cagr5",
|
||||
"nwc_growth", "invest_growth", "equity_growth",
|
||||
# ---- P1 批新增(D 估值 6 的原料;EV = close×share_capital + ev_ex_mv;
|
||||
# forecast_np_annualized 走 forecast 事件流公告日 asof)----
|
||||
"ebit_ttm", "ebitda_ttm", "fcf_ttm", "gp_ttm", "ev_ex_mv",
|
||||
"forecast_np_annualized",
|
||||
# ---- P1 批新增(E 资本 3)----
|
||||
"debt_issue_ttm", "interest_cover", "goodwill_growth",
|
||||
# ---- P1 批新增(F 预期 3 + SUE 严窗变体;forecast_beat 走独立兑现差事件流)----
|
||||
"sue_eps", "disclosure_speed", "sue_np_strict", "forecast_beat",
|
||||
]
|
||||
# 金融股置 NaN 的特征(盈利质量 B 族 + 成长 C 族,§7 红线 5)
|
||||
# 金融股置 NaN 的特征(盈利质量 B 族 + 成长 C 族 + 费用类,§7 红线 5;
|
||||
# survey A 族注意点: A14/A16 金融无三费结构 → rd/sale_expense_ratio 同剔)
|
||||
_FIN_NULL_COLS = ["tacc", "nonrec_ratio", "impairment_ratio",
|
||||
"invest_income_dep", "receivables_anomaly",
|
||||
"sales_cash_ratio", "other_rece_ratio",
|
||||
"rev_q_yoy", "np_q_yoy", "growth_scissors", "gm_delta",
|
||||
"roe_delta", "asset_growth"]
|
||||
_FORECAST_COLS = ("forecast_type_score", "forecast_change_pct")
|
||||
"roe_delta", "asset_growth",
|
||||
"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"]
|
||||
# forecast 事件流日频列(公告日 asof): P0 两列 + P1 年化预告净利(D13)
|
||||
_FORECAST_COLS = ("forecast_type_score", "forecast_change_pct",
|
||||
"forecast_np_annualized")
|
||||
# 预告兑现差独立事件流(F07 前视红线: 锚 = max(实际披露日, 预告公告日))
|
||||
_BEAT_COLS = ("forecast_beat",)
|
||||
|
||||
|
||||
# ==================== 读取层 ====================
|
||||
@@ -145,9 +195,14 @@ _TABLE_RAW = {
|
||||
"income": ["TOTAL_OPERATE_INCOME", "OPERATE_COST", "PARENT_NETPROFIT",
|
||||
"DEDUCT_PARENT_NETPROFIT", "TOTAL_PROFIT", "INVEST_INCOME",
|
||||
"FAIRVALUE_CHANGE_INCOME", "ASSET_IMPAIRMENT_LOSS",
|
||||
"CREDIT_IMPAIRMENT_LOSS"],
|
||||
"CREDIT_IMPAIRMENT_LOSS",
|
||||
"RESEARCH_EXPENSE", "SALE_EXPENSE", "FE_INTEREST_EXPENSE",
|
||||
"INCOME_TAX", "BASIC_EPS"],
|
||||
"balance": _BALANCE_COLS,
|
||||
"cashflow": ["NETCASH_OPERATE", "SALES_SERVICES", "ACCEPT_INVEST_CASH"],
|
||||
"cashflow": ["NETCASH_OPERATE", "SALES_SERVICES", "ACCEPT_INVEST_CASH",
|
||||
"FA_IR_DEPR", "IA_AMORTIZE", "LPE_AMORTIZE",
|
||||
"USERIGHT_ASSET_AMORTIZE", "CONSTRUCT_LONG_ASSET",
|
||||
"RECEIVE_LOAN_CASH", "ISSUE_BOND", "PAY_DEBT_CASH"],
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +300,31 @@ def _delta4(col: str) -> pl.Expr:
|
||||
return pl.when(ok).then(pl.col(col) - pl.col(col).shift(4)).otherwise(None).over(_SYM)
|
||||
|
||||
|
||||
def _mean4(col: str) -> pl.Expr:
|
||||
"""mean(X_t, X_{t−4季})(A03 平均 ROE 分母;恰隔 4 季 + 两期非空守卫)."""
|
||||
ok = (pl.col("_qidx") - pl.col("_qidx").shift(4)) == 4
|
||||
both = pl.col(col).is_not_null() & pl.col(col).shift(4).is_not_null()
|
||||
m = (pl.col(col) + pl.col(col).shift(4)) / 2.0
|
||||
return pl.when(ok & both).then(m).otherwise(None).over(_SYM)
|
||||
|
||||
|
||||
def _cagr5(col: str) -> pl.Expr:
|
||||
"""5 年 CAGR = (X_y / X_{y−5})^{1/5} − 1(年报行;基期/现期 ≤0 → NaN,
|
||||
负基期 CAGR 无意义;恰隔 20 季守卫)."""
|
||||
cur, base = pl.col(col), pl.col(col).shift(20)
|
||||
ok = (pl.col("_qidx") - pl.col("_qidx").shift(20)) == 20
|
||||
valid = ok & base.is_not_null() & (base > 0) & cur.is_not_null() & (cur > 0)
|
||||
return pl.when(valid).then((cur / base).pow(0.2) - 1.0).otherwise(None).over(_SYM)
|
||||
|
||||
|
||||
def _std16(col: str) -> pl.Expr:
|
||||
"""16 季滚动 sample std(ddof=1,与 SUE Foster 同款钉死);窗口须恰为连续
|
||||
16 个季度且全非空——不足 16 期/窗内含缺失 → NaN(不填 0)."""
|
||||
ok = ((pl.col("_qidx") - pl.col("_qidx").shift(15)) == 15).over(_SYM)
|
||||
sd = pl.col(col).rolling_std(window_size=16, ddof=1).over(_SYM)
|
||||
return pl.when(ok & sd.is_not_null()).then(sd).otherwise(None)
|
||||
|
||||
|
||||
def _safe_ratio(num: pl.Expr, den: pl.Expr) -> pl.Expr:
|
||||
"""分母缺失/为 0 → NaN(比率类通用守卫)."""
|
||||
return pl.when(den.is_not_null() & (den != 0)).then(num / den).otherwise(None)
|
||||
@@ -261,17 +341,64 @@ def _compute_report_features(reports: pl.DataFrame) -> pl.DataFrame:
|
||||
[_ttm_of(f"q_{c}").alias(f"ttm_{c}") for c in _CUM_MAP.values()]
|
||||
)
|
||||
|
||||
# 第二级: IBD(缺组件按 0) + 金融股判定(银行模板无营业成本) + 毛利
|
||||
# 第一级半(P1): 补充资料加工底座
|
||||
# - DA 累计 = 四件折旧摊销之和(缺列/缺值按 0,与 IBD 组装同款;
|
||||
# USERIGHT_ASSET_AMORTIZE 2019 前整列缺失不影响早年 DA)
|
||||
# - 利息费用累计 fill 0(NAS 实测披露稀疏: 600519 78% null/银行模板整列缺;
|
||||
# 未披露按 0 回加 → EBIT 退化为 TP,口径登记)
|
||||
# - NWC = 存货+应收(null 传播,缺一即 NaN)
|
||||
# - 年报行门控列(C13/C14/C18 年度口径;非年报行 null → yoy 天然 NaN)
|
||||
df = df.with_columns(
|
||||
pl.sum_horizontal([pl.col(p).fill_null(0.0) for p in _DA_PARTS]).alias("_da_cum"),
|
||||
pl.col("int_exp").fill_null(0.0).alias("_int0_cum"),
|
||||
(pl.col("INVENTORY") + pl.col("ACCOUNTS_RECE")).alias("_nwc"),
|
||||
pl.when(pl.col("REPORT_DATE").dt.month() == 12).then(pl.col("rev")).alias("_rev_ann"),
|
||||
pl.when(pl.col("REPORT_DATE").dt.month() == 12).then(pl.col("np")).alias("_np_ann"),
|
||||
pl.when(pl.col("REPORT_DATE").dt.month() == 12).then(pl.col("capex")).alias("_capex_ann"),
|
||||
).with_columns(
|
||||
_single_quarter("_da_cum").alias("q__da"),
|
||||
_single_quarter("_int0_cum").alias("q__int0"),
|
||||
).with_columns(
|
||||
_ttm_of("q__da").alias("ttm__da"),
|
||||
_ttm_of("q__int0").alias("ttm__int0"),
|
||||
)
|
||||
|
||||
# 第二级: IBD(缺组件按 0,钉死含 LEASE_LIAB 版) + 金融股判定(银行模板无
|
||||
# 营业成本) + 毛利 + τ 实际税率(TTM 口径: 消费者均为 TTM 流量;
|
||||
# τ = INCOME_TAX_TTM/TOTAL_PROFIT_TTM 截断 [0,0.5],两列缺失或 TP≤0 → 0.25)
|
||||
ibd = pl.sum_horizontal([pl.col(p).fill_null(0.0) for p in _IBD_PARTS])
|
||||
is_fin = pl.col("cogs").is_null() | (pl.col("cogs") == 0)
|
||||
tau = (
|
||||
pl.when(pl.col("ttm_tp").is_not_null() & (pl.col("ttm_tp") > 0)
|
||||
& pl.col("ttm_tax").is_not_null())
|
||||
.then((pl.col("ttm_tax") / pl.col("ttm_tp")).clip(0.0, 0.5))
|
||||
.otherwise(0.25)
|
||||
)
|
||||
df = df.with_columns(
|
||||
ibd.alias("_ibd"),
|
||||
is_fin.alias("_is_fin"),
|
||||
tau.alias("_tau"),
|
||||
(pl.col("ttm_rev") - pl.col("ttm_cogs")).alias("_gp_ttm"),
|
||||
# EBIT_TTM = (TOTAL_PROFIT + FE_INTEREST_EXPENSE)_TTM(§1.3)
|
||||
(pl.col("ttm_tp") + pl.col("ttm__int0")).alias("_ebit_ttm"),
|
||||
# (EBIT+DA)_TTM / FCF_TTM / 债务净发行 TTM(E03 三流合成)
|
||||
(pl.col("ttm_tp") + pl.col("ttm__int0") + pl.col("ttm__da")).alias("_ebitda_ttm"),
|
||||
(pl.col("ttm_cfo") - pl.col("ttm_capex")).alias("_fcf_ttm"),
|
||||
(pl.col("ttm_recv_loan") + pl.col("ttm_issue_bond")
|
||||
- pl.col("ttm_pay_debt")).alias("_debt_issue_ttm"),
|
||||
)
|
||||
|
||||
# 第二级半(P1): 平均净资产(A03 分母) + VSIG 三序列底座(单季口径 / TA)
|
||||
ta = pl.col("TOTAL_ASSETS")
|
||||
df = df.with_columns(
|
||||
_mean4("TOTAL_PARENT_EQUITY").alias("_eq_avg"),
|
||||
_safe_ratio(pl.col("q_np"), ta).alias("_np_ta"),
|
||||
_safe_ratio(pl.col("q_np") - pl.col("q_cfo"), ta).alias("_accq_ta"),
|
||||
_safe_ratio(pl.col("q_cfo"), ta).alias("_cfo_ta"),
|
||||
)
|
||||
|
||||
# 第三级: 行本地比率(无时序,无需 over)
|
||||
eq, ta = pl.col("TOTAL_PARENT_EQUITY"), pl.col("TOTAL_ASSETS")
|
||||
eq = pl.col("TOTAL_PARENT_EQUITY")
|
||||
df = df.with_columns(
|
||||
# 盈利能力 A
|
||||
_safe_ratio(pl.col("ttm_np"), eq).alias("roe_ttm"),
|
||||
@@ -294,6 +421,25 @@ def _compute_report_features(reports: pl.DataFrame) -> pl.DataFrame:
|
||||
# 成长 C / 资本结构 E
|
||||
_safe_ratio(pl.col("_ibd"), ta).alias("ibd_ratio"),
|
||||
_safe_ratio(pl.col("GOODWILL"), ta).alias("goodwill_ratio"),
|
||||
# 盈利能力 A(P1 7): A03/A09/A10/A11/A12/A15/A16
|
||||
_safe_ratio(pl.col("ttm_np"), pl.col("_eq_avg")).alias("roe_avg"),
|
||||
_safe_ratio(pl.col("ttm_np") + pl.col("ttm__int0") * (1.0 - pl.col("_tau")),
|
||||
ta).alias("roa_pretax"),
|
||||
_safe_ratio(pl.col("_ebit_ttm"), ta).alias("ebit_over_assets"),
|
||||
_safe_ratio(pl.col("_ebitda_ttm"), pl.col("ttm_rev")).alias("ebitda_margin"),
|
||||
_safe_ratio(pl.col("_ebit_ttm") * (1.0 - pl.col("_tau")),
|
||||
eq + pl.col("_ibd") - pl.col("MONETARYFUNDS")).alias("roic"),
|
||||
_safe_ratio(pl.col("ttm_research"), pl.col("ttm_rev")).alias("rd_intensity"),
|
||||
_safe_ratio(pl.col("ttm_sale_exp"), pl.col("ttm_rev")).alias("sale_expense_ratio"),
|
||||
# 盈利质量 B(P1): B12 哑变量的连续近似 = (MON/TA)×(IBD/TA) 乘积变体
|
||||
# (表达式引擎无截面分位函数,不改引擎——survey B12 的可计算降级)
|
||||
(_safe_ratio(pl.col("MONETARYFUNDS"), ta)
|
||||
* _safe_ratio(pl.col("_ibd"), ta)).alias("cash_ibd_product"),
|
||||
_safe_ratio(pl.col("ttm__da"), pl.col("ttm_rev")).alias("da_intensity"),
|
||||
# E07 利息保障倍数(利息费用≤0 → NaN: 负利息=净收入,倍数无意义)
|
||||
pl.when(pl.col("ttm__int0") > 0)
|
||||
.then(pl.col("_ebit_ttm") / pl.col("ttm__int0"))
|
||||
.otherwise(None).alias("interest_cover"),
|
||||
)
|
||||
|
||||
# 第四级: 跨期差分/同比/剪刀差(over 组内时序)
|
||||
@@ -308,20 +454,74 @@ def _compute_report_features(reports: pl.DataFrame) -> pl.DataFrame:
|
||||
_delta4("roe_ttm").alias("roe_delta"),
|
||||
_delta4("q_np").alias("_diff4_np"),
|
||||
_delta4("q_rev").alias("_diff4_rev"),
|
||||
# P1: B09 存货同比 / C16 NWC 同比 / C19 净资产 / E10 商誉
|
||||
_yoy4("INVENTORY").alias("_inv_yoy"),
|
||||
_yoy4("_nwc").alias("nwc_growth"),
|
||||
_yoy4("TOTAL_PARENT_EQUITY").alias("equity_growth"),
|
||||
_yoy4("GOODWILL").alias("goodwill_growth"),
|
||||
# P1: C18 投资增速(年度口径,非年报行 cur=null → NaN)
|
||||
_yoy4("_capex_ann").alias("invest_growth"),
|
||||
# P1: C13/C14 五年 CAGR(年报行,恰隔 20 季守卫,基期/现期≤0 → NaN)
|
||||
_cagr5("_rev_ann").alias("rev_cagr5"),
|
||||
_cagr5("_np_ann").alias("np_cagr5"),
|
||||
_delta4("net_margin").alias("nm_delta"),
|
||||
_delta4("q_eps").alias("_diff4_eps"),
|
||||
).with_columns(
|
||||
(pl.col("np_q_yoy") - pl.col("rev_q_yoy")).alias("growth_scissors"),
|
||||
(pl.col("_ar_yoy") - pl.col("_rev_ttm_yoy")).alias("receivables_anomaly"),
|
||||
# B09 与 B08 同构: 期末存量同比 − REV_TTM 同比(登记口径)
|
||||
(pl.col("_inv_yoy") - pl.col("_rev_ttm_yoy")).alias("inventory_anomaly"),
|
||||
# B18 毛净剪刀差 = GM_TTM − NM_TTM(第三级产物,同块不可引用故后置)
|
||||
(pl.col("gross_margin") - pl.col("net_margin")).alias("gm_nm_scissors"),
|
||||
# C07/C08 加速度 = yoy 的恰隔 4 季二次差分(基期>0 守卫由 yoy 层继承,
|
||||
# 二次差分同样 NaN 传播;同块不可引用 yoy 列故后置)
|
||||
_delta4("np_q_yoy").alias("np_accel"),
|
||||
_delta4("rev_q_yoy").alias("rev_accel"),
|
||||
)
|
||||
|
||||
# 第五级: SUE(Foster 标准化)= diff4 / std(过去 8 期 diff4, ddof=1)
|
||||
for src, out in (("_diff4_np", "sue_np"), ("_diff4_rev", "sue_rev")):
|
||||
for src, out in (("_diff4_np", "sue_np"), ("_diff4_rev", "sue_rev"),
|
||||
("_diff4_eps", "sue_eps")):
|
||||
sd = pl.col(src).rolling_std(window_size=8, ddof=1).over(_SYM)
|
||||
df = df.with_columns(
|
||||
pl.when(sd.is_not_null() & (sd > 0) & pl.col(src).is_not_null())
|
||||
.then(pl.col(src) / sd).otherwise(None).alias(out)
|
||||
)
|
||||
# SUE 严窗变体(随批互评): σ 只用 t−1 及更早差分(shift(1) 后滚 8 期,不含当期)
|
||||
sd_strict = pl.col("_diff4_np").shift(1).rolling_std(window_size=8, ddof=1).over(_SYM)
|
||||
df = df.with_columns(
|
||||
pl.when(sd_strict.is_not_null() & (sd_strict > 0) & pl.col("_diff4_np").is_not_null())
|
||||
.then(pl.col("_diff4_np") / sd_strict).otherwise(None).alias("sue_np_strict")
|
||||
)
|
||||
|
||||
# 金融股: 盈利质量/成长族特征置 NaN(§7 红线 5)
|
||||
# 第五级半(P1): VSIG 16 季滚动(sample std ddof=1 钉死,恰连续 16 季全非空)
|
||||
# + F06 披露及时性 = −(有效披露日 − 报告期) 天数(早披露=高分;notice_eff
|
||||
# 取三表最晚可见,与 PIT 锚一致)
|
||||
df = df.with_columns(
|
||||
_std16("_np_ta").alias("vsig"),
|
||||
_std16("_accq_ta").alias("vsig_acc"),
|
||||
_std16("_cfo_ta").alias("vsig_cfo"),
|
||||
(-(pl.col("notice_eff") - pl.col("REPORT_DATE")).dt.total_days())
|
||||
.cast(pl.Float64).alias("disclosure_speed"),
|
||||
)
|
||||
|
||||
# 第五级半续(P1): B19 持续盈利季数 = 连续单季 NP>0 计数(截断 8;
|
||||
# 当期缺失→NaN;非正→0 断流;中间缺失行视为断流点)
|
||||
df = df.with_columns(
|
||||
(pl.col("q_np") > 0).alias("_pos"),
|
||||
pl.int_range(pl.len()).cast(pl.Int64).alias("_ridx"),
|
||||
).with_columns(
|
||||
pl.when(pl.col("_pos").is_null() | ~pl.col("_pos"))
|
||||
.then(pl.col("_ridx")).otherwise(None)
|
||||
.fill_null(strategy="forward").over(_SYM).alias("_lastbrk"),
|
||||
).with_columns(
|
||||
pl.when(pl.col("_pos").is_null()).then(None)
|
||||
.when(pl.col("_pos"))
|
||||
.then((pl.col("_ridx") - pl.col("_lastbrk").fill_null(-1)).clip(1, 8).cast(pl.Float64))
|
||||
.otherwise(0.0).alias("profit_streak"),
|
||||
)
|
||||
|
||||
# 金融股: 盈利质量/成长/费用类族特征置 NaN(§7 红线 5;估值族保留)
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col("_is_fin")).then(None).otherwise(pl.col(c)).alias(c)
|
||||
for c in _FIN_NULL_COLS
|
||||
@@ -335,23 +535,41 @@ def _compute_report_features(reports: pl.DataFrame) -> pl.DataFrame:
|
||||
pl.col("ttm_acc_inv_cash").alias("acc_invest_cash_ttm"),
|
||||
pl.col("TOTAL_PARENT_EQUITY").alias("equity"),
|
||||
pl.col("SHARE_CAPITAL").alias("share_capital"),
|
||||
# P1: EV 群/FCF/债务净发行原料(EV = close×share_capital + ev_ex_mv)
|
||||
pl.col("_gp_ttm").alias("gp_ttm"),
|
||||
pl.col("_ebit_ttm").alias("ebit_ttm"),
|
||||
pl.col("_ebitda_ttm").alias("ebitda_ttm"),
|
||||
pl.col("_fcf_ttm").alias("fcf_ttm"),
|
||||
(pl.col("_ibd") - pl.col("MONETARYFUNDS")).alias("ev_ex_mv"),
|
||||
pl.col("_debt_issue_ttm").alias("debt_issue_ttm"),
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
# ==================== forecast 事件层 ====================
|
||||
|
||||
def _load_forecast_events(codes: list[str], data_dir: str) -> pl.DataFrame:
|
||||
"""forecast 按期文件 → (vt_symbol, eff=公告日期, type_score, change_pct) 事件行.
|
||||
# 预告净利年化系数(按报告期进度;D13 预期 EP): Q1×4 / H1×2 / Q3×4/3 / 年报×1
|
||||
_ANNUALIZE_FACTOR = {3: 4.0, 6: 2.0, 9: 4.0 / 3.0, 12: 1.0}
|
||||
|
||||
一股一公告日多行(按预测指标): 归母净利润行优先(含"净利润"且不含"扣"),
|
||||
无净利润行 fallback 任意行。同股多公告日全保留(asof 取最新)。
|
||||
|
||||
def _load_forecast_events(codes: list[str], data_dir: str) -> tuple[pl.DataFrame, pl.DataFrame]:
|
||||
"""forecast 按期文件 → (fc_events, fc_pair).
|
||||
|
||||
fc_events: (vt_symbol, eff=公告日期, forecast_type_score, forecast_change_pct,
|
||||
forecast_np_annualized) 事件行——一股一公告日多行(按预测指标),
|
||||
归母净利润行优先(含"净利润"且不含"扣"),无净利润行 fallback 任意行;
|
||||
年化预告净利只对净利行生效(fallback 营业收入行的中值不作净利用)。
|
||||
同股多公告日全保留(asof 取最新)。
|
||||
|
||||
fc_pair: (vt_symbol, REPORT_DATE, _fc_mid, eff) —— F07 预告兑现差的配对原料,
|
||||
每 (股, 报告期) 取最新公告日的净利行中值(REPORT_DATE 取自文件名)。
|
||||
"""
|
||||
schema = {"vt_symbol": pl.Utf8, "eff": pl.Date,
|
||||
"forecast_type_score": pl.Float64, "forecast_change_pct": pl.Float64}
|
||||
schema = {"vt_symbol": pl.Utf8, "eff": pl.Date, "REPORT_DATE": pl.Date,
|
||||
"forecast_type_score": pl.Float64, "forecast_change_pct": pl.Float64,
|
||||
"forecast_np_annualized": pl.Float64, "_fc_mid": pl.Float64, "_is_np": pl.Boolean}
|
||||
fc_dir = os.path.join(data_dir, "forecast")
|
||||
if not os.path.isdir(fc_dir):
|
||||
return pl.DataFrame(schema=schema)
|
||||
return pl.DataFrame(schema=schema), pl.DataFrame(schema=schema)
|
||||
code_set = set(codes)
|
||||
frames = []
|
||||
for fname in sorted(os.listdir(fc_dir)):
|
||||
@@ -364,8 +582,17 @@ def _load_forecast_events(codes: list[str], data_dir: str) -> pl.DataFrame:
|
||||
if f.height == 0 or not all(c in f.columns for c in
|
||||
("股票代码", "预告类型", "公告日期")):
|
||||
continue
|
||||
try: # 文件名前 8 位 = 报告期(20230630_forecast.parquet)
|
||||
report_date = datetime.strptime(fname[:8], "%Y%m%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
code = pl.col("股票代码").cast(pl.Utf8).str.strip_chars().str.zfill(6)
|
||||
# 交易所映射: 60→SSE;北交前缀白名单(92/43/82/83)→BJSE;其余→SZSE
|
||||
# (互评备注: 北交种类不得落入 SZSE——容器/实盘 universe 按后缀路由)
|
||||
is_bj = (code.str.starts_with("92") | code.str.starts_with("43")
|
||||
| code.str.starts_with("82") | code.str.starts_with("83"))
|
||||
vt = (pl.when(code.str.starts_with("60")).then(code + pl.lit(".SSE"))
|
||||
.when(is_bj).then(code + pl.lit(".BJSE"))
|
||||
.otherwise(code + pl.lit(".SZSE")).alias("vt_symbol"))
|
||||
if "预测指标" in f.columns:
|
||||
ind = pl.col("预测指标").cast(pl.Utf8)
|
||||
@@ -374,25 +601,53 @@ def _load_forecast_events(codes: list[str], data_dir: str) -> pl.DataFrame:
|
||||
pref = pl.lit(0, pl.Int32)
|
||||
pct = (pl.col("业绩变动幅度").cast(pl.Float64, strict=False)
|
||||
if "业绩变动幅度" in f.columns else pl.lit(None, pl.Float64))
|
||||
mid = (pl.col("预测数值").cast(pl.Float64, strict=False)
|
||||
if "预测数值" in f.columns else pl.lit(None, pl.Float64))
|
||||
f = f.with_columns(
|
||||
vt,
|
||||
pref.alias("_pref"),
|
||||
pct.alias("_pct"),
|
||||
mid.alias("_mid"),
|
||||
pl.lit(report_date, dtype=pl.Date).alias("REPORT_DATE"),
|
||||
pl.lit(_ANNUALIZE_FACTOR.get(report_date.month), dtype=pl.Float64).alias("_annf"),
|
||||
pl.col("公告日期").cast(pl.Date, strict=False).alias("eff"),
|
||||
pl.col("预告类型").cast(pl.Utf8).replace(
|
||||
FORECAST_TYPE_SCORE, default=None, return_dtype=pl.Float64
|
||||
).alias("_score"),
|
||||
).filter(pl.col("vt_symbol").is_in(code_set) & pl.col("eff").is_not_null())
|
||||
if f.height:
|
||||
frames.append(f.select(["vt_symbol", "eff", "_pref", "_score", "_pct"]))
|
||||
frames.append(f.select(
|
||||
["vt_symbol", "eff", "REPORT_DATE", "_pref", "_score", "_pct", "_mid", "_annf"]))
|
||||
if not frames:
|
||||
return pl.DataFrame(schema=schema)
|
||||
return pl.DataFrame(schema=schema), pl.DataFrame(schema=schema)
|
||||
fc = pl.concat(frames).sort(["vt_symbol", "eff", "_pref"])
|
||||
# 同 (vt_symbol, eff) 取优先级最高行(_pref 大者排序在后 → last)
|
||||
return fc.group_by(["vt_symbol", "eff"]).agg(
|
||||
# 同 (vt, 公告日, 报告期) 取优先级最高行(_pref 大者排序在后 → last);
|
||||
# 年化预告净利 = 净利行中值 × 年化系数(非净利行 fallback → null)
|
||||
events = fc.group_by(["vt_symbol", "eff", "REPORT_DATE"]).agg(
|
||||
pl.col("_score").last().alias("forecast_type_score"),
|
||||
pl.col("_pct").last().alias("forecast_change_pct"),
|
||||
pl.col("_pref").last().alias("_is_np"),
|
||||
pl.col("_mid").last().alias("_mid"),
|
||||
pl.col("_annf").last().alias("_annf"),
|
||||
).with_columns(
|
||||
pl.when(pl.col("_is_np") == 1)
|
||||
.then(pl.col("_mid") * pl.col("_annf")).otherwise(None)
|
||||
.alias("forecast_np_annualized"),
|
||||
)
|
||||
# F07 配对: 每 (股, 报告期) 最新公告日的净利行中值
|
||||
fc_pair = (events.filter(pl.col("_is_np") == 1 & pl.col("_mid").is_not_null())
|
||||
.sort(["vt_symbol", "REPORT_DATE", "eff"])
|
||||
.group_by(["vt_symbol", "REPORT_DATE"]).agg(
|
||||
pl.col("eff").last().alias("eff"),
|
||||
pl.col("_mid").last().alias("_fc_mid"))
|
||||
.select(["vt_symbol", "REPORT_DATE", "_fc_mid", "eff"]))
|
||||
# 事件流: 同 (股, 公告日) 多报告期行罕见(同年同日两期预告)——取最新报告期
|
||||
# 为当前信号(P0 语义 = 每公告日一行)
|
||||
fc_events = (events.sort(["vt_symbol", "eff", "REPORT_DATE"])
|
||||
.group_by(["vt_symbol", "eff"]).last()
|
||||
.select(["vt_symbol", "eff", "forecast_type_score",
|
||||
"forecast_change_pct", "forecast_np_annualized"]))
|
||||
return fc_events, fc_pair
|
||||
|
||||
|
||||
# ==================== 对外主入口 ====================
|
||||
@@ -421,13 +676,17 @@ def _build_grid(codes: list[str], day_list: list) -> pl.DataFrame:
|
||||
}, schema={"vt_symbol": pl.Utf8, "datetime": pl.Datetime("us")})
|
||||
|
||||
|
||||
def _load_feature_events(codes: list[str], data_dir: str) -> tuple[pl.DataFrame, pl.DataFrame]:
|
||||
"""报告期特征事件 + forecast 事件(全 codes 一次加载,分块 join 共用右表)."""
|
||||
stmt_cols = [c for c in FEATURE_COLUMNS if c not in _FORECAST_COLS]
|
||||
def _load_feature_events(codes: list[str], data_dir: str) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]:
|
||||
"""报告期特征事件 + forecast 事件 + 兑现差事件(全 codes 一次加载,
|
||||
分块 join 共用右表)."""
|
||||
stmt_cols = [c for c in FEATURE_COLUMNS
|
||||
if c not in _FORECAST_COLS and c not in _BEAT_COLS]
|
||||
reports = _load_statements(codes, data_dir)
|
||||
if reports.height == 0:
|
||||
stmt_events = pl.DataFrame(schema={
|
||||
"vt_symbol": pl.Utf8, "eff": pl.Datetime("us"), **{c: pl.Float64 for c in stmt_cols}})
|
||||
beat_events = pl.DataFrame(schema={
|
||||
"vt_symbol": pl.Utf8, "eff": pl.Datetime("us"), "forecast_beat": pl.Float64})
|
||||
else:
|
||||
feat = _compute_report_features(reports)
|
||||
# NOTICE_DATE 缺失报告期整期跳过(红线: 宁缺毋假)
|
||||
@@ -437,12 +696,43 @@ def _load_feature_events(codes: list[str], data_dir: str) -> tuple[pl.DataFrame,
|
||||
pl.col("notice_eff").cast(pl.Datetime("us")).alias("eff"),
|
||||
*stmt_cols,
|
||||
).sort("eff")
|
||||
fc_events = _load_forecast_events(codes, data_dir).select(
|
||||
beat_events = _build_beat_events(feat, codes, data_dir)
|
||||
fc_events, _ = _load_forecast_events(codes, data_dir)
|
||||
fc_events = fc_events.select(
|
||||
pl.col("vt_symbol"),
|
||||
pl.col("eff").cast(pl.Datetime("us")),
|
||||
*_FORECAST_COLS,
|
||||
).sort("eff")
|
||||
return stmt_events, fc_events
|
||||
return stmt_events, fc_events, beat_events
|
||||
|
||||
|
||||
def _build_beat_events(feat: pl.DataFrame, codes: list[str], data_dir: str) -> pl.DataFrame:
|
||||
"""F07 预告兑现差事件流: (实际NP − 预告中值)/abs(预告中值),同 REPORT_DATE 配对.
|
||||
|
||||
前视红线(P1 任务书): 兑现差含实际 NP,只有实际报告披露后才可知——
|
||||
PIT 锚 = max(该报告期 income 有效披露日 notice_eff, 预告公告日),
|
||||
不早于两者较晚者(预告公告晚于年报的罕见情形不被提前泄露)。
|
||||
实际 NP 用报告期累计归母净利(预告口径即期间累计);预告中值=0/缺 → NaN。
|
||||
"""
|
||||
schema = {"vt_symbol": pl.Utf8, "eff": pl.Datetime("us"), "forecast_beat": pl.Float64}
|
||||
_, fc_pair = _load_forecast_events(codes, data_dir)
|
||||
if fc_pair.height == 0:
|
||||
return pl.DataFrame(schema=schema)
|
||||
beat = (
|
||||
feat.select("vt_symbol", "REPORT_DATE", "notice_eff", pl.col("np"))
|
||||
.join(fc_pair, on=["vt_symbol", "REPORT_DATE"], how="inner")
|
||||
.with_columns(
|
||||
_safe_ratio(pl.col("np") - pl.col("_fc_mid"),
|
||||
pl.col("_fc_mid").abs()).alias("forecast_beat"),
|
||||
pl.max_horizontal("notice_eff", "eff").alias("_anchor"),
|
||||
)
|
||||
.filter(pl.col("forecast_beat").is_not_null() & pl.col("_anchor").is_not_null())
|
||||
)
|
||||
return beat.select(
|
||||
pl.col("vt_symbol"),
|
||||
pl.col("_anchor").cast(pl.Datetime("us")).alias("eff"),
|
||||
pl.col("forecast_beat"),
|
||||
).sort("eff")
|
||||
|
||||
|
||||
def iter_fundamental_feature_chunks(
|
||||
@@ -452,14 +742,18 @@ def iter_fundamental_feature_chunks(
|
||||
data_dir: str = DEFAULT_STATIC_DIR,
|
||||
trading_dates: pl.Series | list | None = None,
|
||||
batch_codes: int = BATCH_CODES,
|
||||
columns: list[str] | None = None,
|
||||
):
|
||||
"""按 vt_symbol 分批产出 PIT 日频特征块(生成器,NAS 全量防 OOM 主入口).
|
||||
|
||||
每块 = 一批 codes × 全部日期 × FEATURE_COLUMNS,顺序即 codes 列表顺序;
|
||||
批内 grid 用完即弃,事件右表(报告期+forecast)全批共用仅此一份。
|
||||
batch_eval 侧应逐块 join alpha_df 分片后 concat,避免持有本帧全量副本。
|
||||
每块 = 一批 codes × 全部日期 × columns(默认 FEATURE_COLUMNS 全量),
|
||||
顺序即 codes 列表顺序;批内 grid 用完即弃,事件右表(报告期+forecast+
|
||||
兑现差)全批共用仅此一份。batch_eval 侧应逐块 join alpha_df 分片后
|
||||
concat,避免持有本帧全量副本;columns 子集可只 join 本批表达式引用列,
|
||||
全量 68 列 × 1480 万行 ≈ 8G——按引用瘦身是 NAS 7.9G 内存的关键杠杆。
|
||||
"""
|
||||
stmt_events, fc_events = _load_feature_events(codes, data_dir)
|
||||
out_cols = list(columns) if columns is not None else list(FEATURE_COLUMNS)
|
||||
stmt_events, fc_events, beat_events = _load_feature_events(codes, data_dir)
|
||||
day_list = _prepare_days(start, end, trading_dates)
|
||||
for i in range(0, len(codes), batch_codes):
|
||||
chunk_codes = codes[i:i + batch_codes]
|
||||
@@ -468,14 +762,19 @@ def iter_fundamental_feature_chunks(
|
||||
# 该提示无信息量,就地抑制(sort 即正确性保险)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
# 三条事件流各自 asof 后丢弃右表键 eff(留置会以 eff_right 后缀
|
||||
# 累积,第三次 join 撞名)
|
||||
out = grid.join_asof(
|
||||
stmt_events, left_on="datetime", right_on="eff",
|
||||
by="vt_symbol", strategy="backward")
|
||||
by="vt_symbol", strategy="backward").drop("eff")
|
||||
out = out.sort("datetime").join_asof(
|
||||
fc_events, left_on="datetime", right_on="eff",
|
||||
by="vt_symbol", strategy="backward")
|
||||
by="vt_symbol", strategy="backward").drop("eff")
|
||||
out = out.sort("datetime").join_asof(
|
||||
beat_events, left_on="datetime", right_on="eff",
|
||||
by="vt_symbol", strategy="backward").drop("eff")
|
||||
yield out.sort(["vt_symbol", "datetime"]).select(
|
||||
["vt_symbol", "datetime", *FEATURE_COLUMNS])
|
||||
["vt_symbol", "datetime", *out_cols])
|
||||
grid = out = None # 批间释放(下一批重绑定)
|
||||
|
||||
|
||||
@@ -486,8 +785,9 @@ def build_fundamental_features(
|
||||
data_dir: str = DEFAULT_STATIC_DIR,
|
||||
trading_dates: pl.Series | list | None = None,
|
||||
batch_codes: int = BATCH_CODES,
|
||||
columns: list[str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""构建 PIT 日频财务特征: vt_symbol × datetime × FEATURE_COLUMNS.
|
||||
"""构建 PIT 日频财务特征: vt_symbol × datetime × columns.
|
||||
|
||||
Args:
|
||||
codes: vt_symbol 列表(如 "600000.SSE")
|
||||
@@ -495,17 +795,19 @@ def build_fundamental_features(
|
||||
data_dir: 静态域根目录(NAS=/volume1/stock/sanguo_vnpy_v2/data/static)
|
||||
trading_dates: 交易日子集(传 bars 的 unique datetime 免造非交易日行)
|
||||
batch_codes: 按股分批大小(全量防 OOM;测试可调小验分块等值)
|
||||
columns: 输出特征列子集(默认 FEATURE_COLUMNS 全量;引用瘦身用)
|
||||
|
||||
Returns:
|
||||
每行 = 决策日可见的最新报告期特征(NOTICE_DATE ≤ 决策日,asof 前向填充)。
|
||||
"""
|
||||
out_cols = list(columns) if columns is not None else list(FEATURE_COLUMNS)
|
||||
schema = {"vt_symbol": pl.Utf8, "datetime": pl.Datetime("us"),
|
||||
**{c: pl.Float64 for c in FEATURE_COLUMNS}}
|
||||
**{c: pl.Float64 for c in out_cols}}
|
||||
if not codes:
|
||||
return pl.DataFrame(schema=schema)
|
||||
return pl.concat(
|
||||
iter_fundamental_feature_chunks(
|
||||
codes, start, end, data_dir=data_dir,
|
||||
trading_dates=trading_dates, batch_codes=batch_codes),
|
||||
trading_dates=trading_dates, batch_codes=batch_codes, columns=out_cols),
|
||||
how="vertical")
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
# 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))
|
||||
Reference in New Issue
Block a user