test(factor): P1夹具扩至6股×24报告期——2018-2019预热史+P1原料列+银行模板股+北交/迟到预告用例 [nas]
This commit is contained in:
+57
-21
@@ -9,27 +9,31 @@ if _VNPY_SRC not in sys.path:
|
||||
|
||||
|
||||
# ==================== 合成财务静态域(财务因子批测试共用) ====================
|
||||
# 3 只股 × 16 报告期(2020Q1~2023Q4,2020 为 SUE 滚动窗预热),NOTICE_DATE 错位:
|
||||
# Q1→当年4-28 / H1→当年8-29 / Q3→当年10-27 / 年报→次年4-25
|
||||
# 6 只股 × 24 报告期(2018Q1~2023Q4;2018-2019 为 16 季滚动窗/5 年 CAGR 预热史),
|
||||
# NOTICE_DATE 错位: Q1→当年4-28 / H1→当年8-29 / Q3→当年10-27 / 年报→次年4-25
|
||||
# 数值全部手算可验证(断言用),公式见各 build 函数内注释。
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
|
||||
REPORT_DATES = [
|
||||
f"{y}-{m}" for y in (2020, 2021, 2022, 2023) for m in ("03-31", "06-30", "09-30", "12-31")
|
||||
f"{y}-{m}" for y in (2018, 2019, 2020, 2021, 2022, 2023) for m in ("03-31", "06-30", "09-30", "12-31")
|
||||
]
|
||||
|
||||
# 单季 REV/NP 序列(累计=年内前缀和): 报告期索引 i=0..15(2020Q1..2023Q4)
|
||||
# 2020 为 SUE 滚动窗预热史;2021+ 的断言数值由 2021 段起算
|
||||
REV_Q = [90, 130, 140, 150] + [100, 120, 150, 160, 110, 140, 150, 180, 130, 140, 170, 180]
|
||||
NP_Q = [9, 13, 14, 15] + [10, 12, 15, 16, 11, 14, 15, 18, 13, 14, 17, 18]
|
||||
# 单季 REV/NP 序列(累计=年内前缀和): 报告期索引 i=0..23(2018Q1..2023Q4)
|
||||
# 2020 起的段与 P0 期完全一致(旧断言零回归);2018-2019 为新增预热史
|
||||
REV_Q = ([70, 100, 110, 120] + [80, 110, 120, 130] # 2018 / 2019
|
||||
+ [90, 130, 140, 150] + [100, 120, 150, 160] # 2020 / 2021
|
||||
+ [110, 140, 150, 180] + [130, 140, 170, 180]) # 2022 / 2023
|
||||
NP_Q = ([7, 10, 11, 12] + [8, 11, 12, 13] # 2018 / 2019
|
||||
+ [9, 13, 14, 15] + [10, 12, 15, 16] # 2020 / 2021
|
||||
+ [11, 14, 15, 18] + [13, 14, 17, 18]) # 2022 / 2023
|
||||
|
||||
# 资产负债表(时点存量,随报告期线性演化;2021Q1 起算,idx=i-4)
|
||||
TA_OF = lambda i: 1000 + 50 * max(i - 4, 0)
|
||||
EQ_OF = lambda i: 500 + 20 * max(i - 4, 0)
|
||||
AR_OF = lambda i: 100 + 10 * max(i - 4, 0)
|
||||
SC_OF = lambda i: 100 if i < 12 else 110 # 2023 起股本扩张 10%(NSI=0.1)
|
||||
# 资产负债表(时点存量,随报告期线性演化;2021Q1 起算,idx=i-12)
|
||||
TA_OF = lambda i: 1000 + 50 * max(i - 12, 0)
|
||||
EQ_OF = lambda i: 500 + 20 * max(i - 12, 0)
|
||||
AR_OF = lambda i: 100 + 10 * max(i - 12, 0)
|
||||
SC_OF = lambda i: 100 if i < 20 else 110 # 2023 起股本扩张 10%(NSI=0.1)
|
||||
|
||||
SYN_STOCKS = {
|
||||
"600000.SH": {"vt": "600000.SSE", "scale": 1.0,
|
||||
@@ -46,6 +50,10 @@ SYN_STOCKS = {
|
||||
"drop_periods": [], "null_notice_periods": []},
|
||||
"300124.SZ": {"vt": "300124.SZSE", "scale": 3.0,
|
||||
"drop_periods": [], "null_notice_periods": []},
|
||||
# 银行模板股(OPERATE_COST 缺失 → _is_fin;INVENTORY 缺失测 null 传播):
|
||||
# 盈利质量/成长/费用类特征应置 NaN,盈利能力/估值族保留(§7 红线 5)
|
||||
"601398.SH": {"vt": "601398.SSE", "scale": 4.0, "bank": True,
|
||||
"drop_periods": [], "null_notice_periods": []},
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +87,7 @@ def build_synthetic_static(root: str) -> str:
|
||||
|
||||
for file_code, spec in SYN_STOCKS.items():
|
||||
s, drop, null_notice = spec["scale"], set(spec["drop_periods"]), set(spec["null_notice_periods"])
|
||||
is_bank = spec.get("bank", False)
|
||||
income_rows, balance_rows, cashflow_rows = [], [], []
|
||||
for i, rd in enumerate(REPORT_DATES):
|
||||
if rd in drop:
|
||||
@@ -91,39 +100,66 @@ def build_synthetic_static(root: str) -> str:
|
||||
"UPDATE_DATE": f"{notice} 00:00:00" if notice else None,
|
||||
}
|
||||
income_rows.append({**common,
|
||||
"TOTAL_OPERATE_INCOME": rev_c, "OPERATE_COST": 0.6 * rev_c,
|
||||
"TOTAL_OPERATE_INCOME": rev_c,
|
||||
# 银行模板无营业成本(OPERATE_COST 缺失 → 金融股判定)
|
||||
"OPERATE_COST": None if is_bank else 0.6 * rev_c,
|
||||
"PARENT_NETPROFIT": np_c, "DEDUCT_PARENT_NETPROFIT": 0.9 * np_c,
|
||||
"TOTAL_PROFIT": 1.1 * np_c, "INVEST_INCOME": 0.05 * np_c,
|
||||
"FAIRVALUE_CHANGE_INCOME": 0.01 * np_c,
|
||||
"ASSET_IMPAIRMENT_LOSS": 0.02 * np_c, "CREDIT_IMPAIRMENT_LOSS": 0.01 * np_c})
|
||||
"ASSET_IMPAIRMENT_LOSS": 0.02 * np_c, "CREDIT_IMPAIRMENT_LOSS": 0.01 * np_c,
|
||||
# P1 新原料(比率设计成可手算: τ=0.25 / INT=0.2·NP / 研发5% / 销售8%)
|
||||
"RESEARCH_EXPENSE": 0.05 * rev_c, "SALE_EXPENSE": 0.08 * rev_c,
|
||||
"FE_INTEREST_EXPENSE": 0.2 * np_c, "INCOME_TAX": 0.275 * np_c,
|
||||
"BASIC_EPS": _cum_in_year(NP_Q, i) / SC_OF(i)}) # EPS 与 scale 无关
|
||||
# IBD 只给 SHORT_LOAN 一列(其余组件列缺失,测 schema 缺列容错)
|
||||
balance_rows.append({**common,
|
||||
"TOTAL_ASSETS": s * TA_OF(i), "TOTAL_PARENT_EQUITY": s * EQ_OF(i),
|
||||
"ACCOUNTS_RECE": s * AR_OF(i), "OTHER_RECE": s * (5 + max(i - 4, 0)),
|
||||
"ACCOUNTS_RECE": s * AR_OF(i), "OTHER_RECE": s * (5 + max(i - 12, 0)),
|
||||
"GOODWILL": 50.0, "SHARE_CAPITAL": float(SC_OF(i)),
|
||||
"SHORT_LOAN": s * (100 + max(i - 4, 0))})
|
||||
"SHORT_LOAN": s * (100 + max(i - 12, 0)),
|
||||
# P1 新原料: 存货/货币资金(银行无存货 → null 传播)
|
||||
"INVENTORY": None if is_bank else s * (200 + 5 * max(i - 12, 0)),
|
||||
"MONETARYFUNDS": s * (150 + 10 * max(i - 12, 0))})
|
||||
cashflow_rows.append({**common,
|
||||
"NETCASH_OPERATE": 1.2 * np_c, "SALES_SERVICES": 1.05 * rev_c,
|
||||
"ACCEPT_INVEST_CASH": 0.1 * rev_c})
|
||||
"ACCEPT_INVEST_CASH": 0.1 * rev_c,
|
||||
# P1 新原料(DA 合计 = 0.10·REV / capex 15% / 债务净发行 = +0.10·REV)
|
||||
"FA_IR_DEPR": 0.06 * rev_c, "IA_AMORTIZE": 0.02 * rev_c,
|
||||
"LPE_AMORTIZE": 0.01 * rev_c, "USERIGHT_ASSET_AMORTIZE": 0.01 * rev_c,
|
||||
"CONSTRUCT_LONG_ASSET": 0.15 * rev_c,
|
||||
"RECEIVE_LOAN_CASH": 0.3 * rev_c, "ISSUE_BOND": 0.05 * rev_c,
|
||||
"PAY_DEBT_CASH": 0.25 * rev_c})
|
||||
|
||||
for table, rows in (("income", income_rows), ("balance", balance_rows), ("cashflow", cashflow_rows)):
|
||||
pl.DataFrame(rows).write_parquet(
|
||||
os.path.join(static_dir, table, f"{file_code}_{table}.parquet"))
|
||||
|
||||
# forecast: 按报告期全市场文件(中文列,归母净利润行优先 + 无净利润行 fallback)
|
||||
# 预测数值 = 预告中值(元): A=30(实际 27 → beat=-0.1) / B=40(beat=-0.325)
|
||||
os.makedirs(os.path.join(static_dir, "forecast"), exist_ok=True)
|
||||
pl.DataFrame([
|
||||
{"股票代码": "600000", "预测指标": "归属于上市公司股东的净利润",
|
||||
"业绩变动幅度": 56.79, "预告类型": "预增", "公告日期": date(2023, 7, 15)},
|
||||
"预测数值": 30.0, "业绩变动幅度": 56.79, "预告类型": "预增", "公告日期": date(2023, 7, 15)},
|
||||
{"股票代码": "000001", "预测指标": "归属于上市公司股东的净利润",
|
||||
"业绩变动幅度": -30.0, "预告类型": "预减", "公告日期": date(2023, 7, 20)},
|
||||
"预测数值": 40.0, "业绩变动幅度": -30.0, "预告类型": "预减", "公告日期": date(2023, 7, 20)},
|
||||
{"股票代码": "300001", "预测指标": "营业收入",
|
||||
"业绩变动幅度": 5.0, "预告类型": "略增", "公告日期": date(2023, 7, 10)},
|
||||
"预测数值": 999.0, "业绩变动幅度": 5.0, "预告类型": "略增", "公告日期": date(2023, 7, 10)},
|
||||
# 北交所两段前缀(92x/43x)→ .BJSE 映射用例
|
||||
{"股票代码": "920001", "预测指标": "归属于上市公司股东的净利润",
|
||||
"预测数值": 50.0, "业绩变动幅度": 20.0, "预告类型": "预增", "公告日期": date(2023, 7, 18)},
|
||||
{"股票代码": "430047", "预测指标": "净利润",
|
||||
"预测数值": 20.0, "业绩变动幅度": 10.0, "预告类型": "预增", "公告日期": date(2023, 7, 19)},
|
||||
]).write_parquet(os.path.join(static_dir, "forecast", "20230630_forecast.parquet"))
|
||||
pl.DataFrame([
|
||||
{"股票代码": "600000", "预测指标": "净利润",
|
||||
"业绩变动幅度": 100.0, "预告类型": "扭亏", "公告日期": date(2023, 10, 15)},
|
||||
"预测数值": 65.0, "业绩变动幅度": 100.0, "预告类型": "扭亏", "公告日期": date(2023, 10, 15)},
|
||||
]).write_parquet(os.path.join(static_dir, "forecast", "20230930_forecast.parquet"))
|
||||
# 迟到预告用例(F07 锚语义): 2022 年报披露 2023-04-25,预告公告 2023-05-10
|
||||
# 晚于披露日 → 兑现差锚 = max(披露日, 公告日) = 05-10(实际 NP 58,中值 55)
|
||||
pl.DataFrame([
|
||||
{"股票代码": "600000", "预测指标": "归属于上市公司股东的净利润",
|
||||
"预测数值": 55.0, "业绩变动幅度": 15.0, "预告类型": "预增", "公告日期": date(2023, 5, 10)},
|
||||
]).write_parquet(os.path.join(static_dir, "forecast", "20221231_forecast.parquet"))
|
||||
return static_dir
|
||||
|
||||
|
||||
|
||||
@@ -132,8 +132,8 @@ def test_growth_and_capital_features(feat):
|
||||
def test_sue_foster_standardization(feat, np_q_series):
|
||||
# 独立重算: diff4 = Q_t − Q_{t-4}, SUE = diff4 / std(过去 8 期 diff4, ddof=1)
|
||||
import statistics
|
||||
q = [float(x) for x in np_q_series] # 16 期序列,2020 为 SUE 滚动窗预热
|
||||
diff4 = [q[i] - q[i - 4] for i in range(4, 16)] # diff4[r] ↔ 报告期 r+4
|
||||
q = [float(x) for x in np_q_series] # 24 期序列,2018-2019 为 SUE 滚动窗预热
|
||||
diff4 = [q[i] - q[i - 4] for i in range(4, 24)] # diff4[r] ↔ 报告期 r+4
|
||||
|
||||
def sue_at(rep_idx: int):
|
||||
r = rep_idx - 4
|
||||
@@ -142,18 +142,20 @@ def test_sue_foster_standardization(feat, np_q_series):
|
||||
win = diff4[r - 7:r + 1]
|
||||
return diff4[r] / statistics.stdev(win)
|
||||
|
||||
# 2023Q3(报告期 i=14, 披露 2023-10-27): 窗 diff4[3..10]
|
||||
assert val(feat, A, "2023-10-27", "sue_np") == pytest.approx(sue_at(14))
|
||||
# 前一日仍见 2023H1(i=13)
|
||||
assert val(feat, A, "2023-10-26", "sue_np") == pytest.approx(sue_at(13))
|
||||
# 2023Q3(报告期 i=22, 披露 2023-10-27): 窗 diff4[11..18]
|
||||
assert val(feat, A, "2023-10-27", "sue_np") == pytest.approx(sue_at(22))
|
||||
# 前一日仍见 2023H1(i=21)
|
||||
assert val(feat, A, "2023-10-26", "sue_np") == pytest.approx(sue_at(21))
|
||||
# 2023Q4 披露在 2024-04-25,窗末仍是 Q3 值
|
||||
assert val(feat, A, "2023-12-31", "sue_np") == pytest.approx(sue_at(14))
|
||||
# 合成史 16 期 → i=11(2022Q4)起才有完整 8 期窗,更早报告期 SUE=NaN(间接受 PIT 保护)
|
||||
assert val(feat, A, "2023-12-31", "sue_np") == pytest.approx(sue_at(22))
|
||||
# 完整 8 期窗需 r≥7 → 报告期 i≥11(2019Q4)起才有,更早报告期 SUE=NaN(间接受 PIT 保护)
|
||||
|
||||
|
||||
def test_forecast_event_features(feat):
|
||||
# A: 2023-07-15 预增(+3, 56.79);2023-10-15 扭亏(+2, 100.0)覆盖
|
||||
assert val(feat, A, "2023-07-14", "forecast_type_score") is None
|
||||
# A: 2022 年报预告(公告 2023-05-10,预增+3/幅度 15)先行可见;
|
||||
# 2023-07-15 H1 预告(+3, 56.79)覆盖;2023-10-15 扭亏(+2, 100.0)再覆盖
|
||||
assert val(feat, A, "2023-07-14", "forecast_type_score") == 3.0
|
||||
assert val(feat, A, "2023-07-14", "forecast_change_pct") == pytest.approx(15.0)
|
||||
assert val(feat, A, "2023-07-15", "forecast_type_score") == 3.0
|
||||
assert val(feat, A, "2023-07-15", "forecast_change_pct") == pytest.approx(56.79)
|
||||
assert val(feat, A, "2023-10-14", "forecast_type_score") == 3.0
|
||||
|
||||
Reference in New Issue
Block a user