Files
sanguo_vnpy_v2/tests/factor/conftest.py
T
claude_dev 60dd84ad4a feat(factor): P1-B批adapter四新域读取层+CCC三表自算 [nas]
- 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)
2026-09-09 12:12:00 +08:00

329 lines
19 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.
"""Test configuration for factor module tests."""
import sys
import os
# Add vnpy source to path
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
if _VNPY_SRC not in sys.path:
sys.path.insert(0, _VNPY_SRC)
# ==================== 合成财务静态域(财务因子批测试共用) ====================
# 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, datetime
import polars as pl
REPORT_DATES = [
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..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-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)
AP_OF = lambda i: 120 + 8 * max(i - 12, 0) # P1-B: 应付账款(E08 CCC 分子)
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,
"drop_periods": [], "null_notice_periods": []},
"000001.SZ": {"vt": "000001.SZSE", "scale": 1.0,
"drop_periods": ["2022-03-31"], # 缺 2022Q1 → 单季差分/TTM 链 NaN
"null_notice_periods": ["2022-09-30"]}, # NOTICE_DATE 缺失 → 该报告期跳过
"300001.SZ": {"vt": "300001.SZSE", "scale": 2.0,
"drop_periods": [], "null_notice_periods": []},
# 三只分块等值测试扩容股(全史无残缺,不同 scale 增截面多样性)
"600004.SH": {"vt": "600004.SSE", "scale": 0.5,
"drop_periods": [], "null_notice_periods": []},
"000333.SZ": {"vt": "000333.SZSE", "scale": 1.7,
"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": []},
}
def _notice_date(report_date: str) -> str:
"""A 股典型披露节奏(年报次年 4-25 / 一季报 4-28 / 中报 8-29 / 三季报 10-27)."""
y, m = int(report_date[:4]), int(report_date[5:7])
if m == 3:
return f"{y}-04-28"
if m == 6:
return f"{y}-08-29"
if m == 9:
return f"{y}-10-27"
return f"{y + 1}-04-25"
def _cum_in_year(q_values: list[float], i: int) -> float:
"""报告期 i 的年内累计值 = 当年前几季单季之和."""
year_start = (i // 4) * 4
return float(sum(q_values[year_start:i + 1]))
def build_synthetic_static(root: str) -> str:
"""写合成静态域 parquet 树(data_dir),返回 static 根目录路径.
目录结构与 NAS 一致: static/{income,balance,cashflow}/{code}.{SH|SZ}_{table}.parquet
forecast 按报告期全市场文件: static/forecast/{YYYYMMDD}_forecast.parquet
"""
static_dir = os.path.join(str(root), "static")
for table in ("income", "balance", "cashflow"):
os.makedirs(os.path.join(static_dir, table), exist_ok=True)
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:
continue
notice = None if rd in null_notice else _notice_date(rd)
rev_c, np_c = s * _cum_in_year(REV_Q, i), s * _cum_in_year(NP_Q, i)
common = {
"REPORT_DATE": f"{rd} 00:00:00",
"NOTICE_DATE": (f"{notice} 00:00:00" if notice else None),
"UPDATE_DATE": f"{notice} 00:00:00" if notice else None,
}
income_rows.append({**common,
"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,
# 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 - 12, 0)),
"GOODWILL": 50.0, "SHARE_CAPITAL": float(SC_OF(i)),
"SHORT_LOAN": s * (100 + max(i - 12, 0)),
# P1 新原料: 存货/货币资金(银行无存货 → null 传播)
"INVENTORY": None if is_bank else s * (200 + 5 * max(i - 12, 0)),
# P1-B: 应付账款(银行无应付账款概念 → null 传播,CCC 剔金融)
"ACCOUNTS_PAYABLE": None if is_bank else s * AP_OF(i),
"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,
# 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", "预测指标": "归属于上市公司股东的净利润",
"预测数值": 30.0, "业绩变动幅度": 56.79, "预告类型": "预增", "公告日期": date(2023, 7, 15)},
{"股票代码": "000001", "预测指标": "归属于上市公司股东的净利润",
"预测数值": 40.0, "业绩变动幅度": -30.0, "预告类型": "预减", "公告日期": date(2023, 7, 20)},
{"股票代码": "300001", "预测指标": "营业收入",
"预测数值": 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", "预测指标": "净利润",
"预测数值": 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"))
# ==================== P1-B 批四新域(dividend/gdhs/top_holders/vb) ====================
# 契约=NAS 实测 2026-09-08: dividend 日期/数值列为字符串('' 为空);
# gdhs 截止日列名「股东户数统计截止日-本次」(无第二连字符);vb date 为字符串。
# ---- dividend 按股文件(A/B 两只;C/BANK 无文件 → send_total_12m NaN)----
# A 事件(eff = 回退链首个非空公告日):
# 2022-06-15 送转 0.2 / 2023-03-10 送 0.5 / 2023-09-20 送 0.3
# 2023-12-01(仅 dividPreNoticeDate,回退链用例) 送 0.4
# 全链空行(dividStocksPs=9.9 哨兵,应被丢弃)
# 2024-01-05 送 1.0(迟到事件,2024 起才可见)
os.makedirs(os.path.join(static_dir, "dividend"), exist_ok=True)
pl.DataFrame([
{"code": "sh.600000", "dividPreNoticeDate": "", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "2022-06-15", "dividPlanDate": "",
"dividStocksPs": "0.200000", "dividCashPsBeforeTax": "1.000000"},
{"code": "sh.600000", "dividPreNoticeDate": "", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "2023-03-10", "dividPlanDate": "",
"dividStocksPs": "0.500000", "dividCashPsBeforeTax": "2.000000"},
{"code": "sh.600000", "dividPreNoticeDate": "", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "2023-09-20", "dividPlanDate": "",
"dividStocksPs": "0.300000", "dividCashPsBeforeTax": "3.000000"},
{"code": "sh.600000", "dividPreNoticeDate": "2023-12-01", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "", "dividPlanDate": "",
"dividStocksPs": "0.400000", "dividCashPsBeforeTax": "4.000000"},
{"code": "sh.600000", "dividPreNoticeDate": "", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "", "dividPlanDate": "",
"dividStocksPs": "9.900000", "dividCashPsBeforeTax": "9.900000"},
{"code": "sh.600000", "dividPreNoticeDate": "", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "2024-01-05", "dividPlanDate": "",
"dividStocksPs": "1.000000", "dividCashPsBeforeTax": "5.000000"},
]).write_parquet(os.path.join(static_dir, "dividend", "600000.SH_dividend.parquet"))
# B: 纯现金分红事件(dividStocksPs=0 → send_total_12m = 0 非 NaN)
pl.DataFrame([
{"code": "sz.000001", "dividPreNoticeDate": "", "dividAgmPumDate": "",
"dividPlanAnnounceDate": "2023-03-10", "dividPlanDate": "",
"dividStocksPs": "0.000000", "dividCashPsBeforeTax": "0.500000"},
]).write_parquet(os.path.join(static_dir, "dividend", "000001.SZ_dividend.parquet"))
# ---- gdhs 按期文件(事件表: 截止日→户数,PIT=公告日期)----
os.makedirs(os.path.join(static_dir, "gdhs"), exist_ok=True)
def _gdhs_rows(items):
return [{"代码": c, "名称": f"{c}", "股东户数-本次": h,
"股东户数统计截止日-本次": cutoff, "公告日期": ann,
"股东户数-增减比例": -1.0} # 噪声列(红线: 不可直接用,应无影响)
for c, h, cutoff, ann in items]
pl.DataFrame(_gdhs_rows([
("000001", 46000, date(2022, 6, 30), date(2022, 7, 20)),
])).write_parquet(os.path.join(static_dir, "gdhs", "20220630_gdhs.parquet"))
pl.DataFrame(_gdhs_rows([
("600000", 84000, date(2022, 9, 30), date(2022, 10, 20)), # A 首事件(Δ=null)
("000001", 48000, date(2022, 9, 30), date(2022, 10, 20)),
])).write_parquet(os.path.join(static_dir, "gdhs", "20220930_gdhs.parquet"))
pl.DataFrame(_gdhs_rows([
("600000", 80000, date(2022, 12, 31), date(2023, 1, 10)),
("000001", 50000, date(2022, 12, 31), date(2023, 1, 10)),
])).write_parquet(os.path.join(static_dir, "gdhs", "20221231_gdhs.parquet"))
pl.DataFrame(_gdhs_rows([
("600000", 72000, date(2023, 3, 31), date(2023, 4, 20)),
])).write_parquet(os.path.join(static_dir, "gdhs", "20230331_gdhs.parquet"))
# 同 (股, 截止日) 两行不同公告日 → 去重取最新公告(08-01 修订取代 07-20)
pl.DataFrame(_gdhs_rows([
("600000", 68000, date(2023, 6, 30), date(2023, 7, 25)),
("000001", 50000, date(2023, 6, 30), date(2023, 7, 20)),
]) + _gdhs_rows([
("000001", 50500, date(2023, 6, 30), date(2023, 8, 1)),
])).write_parquet(os.path.join(static_dir, "gdhs", "20230630_gdhs.parquet"))
pl.DataFrame(_gdhs_rows([
("600000", 77760, date(2023, 9, 30), date(2023, 10, 15)),
])).write_parquet(os.path.join(static_dir, "gdhs", "20230930_gdhs.parquet"))
# ---- top_holders: 原始按股×期小文件树(供预聚合脚本测试)+ 聚合产物 ----
# 600000 五期占比合计: 50/57/60/65/62;000001 单期 40(单期 → Δ=null)
th_dir = os.path.join(static_dir, "top_holders")
os.makedirs(th_dir, exist_ok=True)
_TH_SUMS = {("600000.SH", "20220630"): 5.0, ("600000.SH", "20220930"): 5.7,
("600000.SH", "20221231"): 6.0, ("600000.SH", "20230331"): 6.5,
("600000.SH", "20230630"): 6.2, ("000001.SZ", "20221231"): 4.0}
for (file_code, period), per_row in _TH_SUMS.items():
rows = []
for r in range(10):
rows.append({
"名次": r + 1, "股东名称": f"股东{r}", "股东性质": "基金",
"股份类型": "流通A股", "持股数": 1000 + r,
"占总流通股本持股比例": (None if (file_code == "600000.SH" and r == 9)
else per_row),
# 增减混合类型(「不变」字符串+数值字符串,coalesce/求和不受影响)
"增减": "不变" if r % 2 else f"{1000 + r}", "变动比率": 0.0,
})
pl.DataFrame(rows).write_parquet(
os.path.join(th_dir, f"{file_code}_{period}_top_holders.parquet"))
# 聚合产物(adapter 只读这个;与脚本对同一小树的输出逐值一致)
root = os.path.dirname(static_dir)
agg_dir = os.path.join(root, "factor_cache")
os.makedirs(agg_dir, exist_ok=True)
pl.DataFrame([
{"file_code": fc, "period": datetime.strptime(p, "%Y%m%d").date(),
"hold_pct": 10.0 * v, "n_holders": 10}
for (fc, p), v in _TH_SUMS.items()
]).write_parquet(os.path.join(agg_dir, "top_holders_agg.parquet"))
# ---- valuation_baostock 按年文件(与 static 同级的兄弟目录)----
vb_dir = os.path.join(root, "valuation_baostock")
os.makedirs(vb_dir, exist_ok=True)
pl.DataFrame([
{"symbol": "600000", "exchange": "SH", "date": "2023-08-25",
"peTTM": 12.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 2.4,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
{"symbol": "600000", "exchange": "SH", "date": "2023-08-28",
"peTTM": 11.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 2.2,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
{"symbol": "600000", "exchange": "SH", "date": "2023-08-29",
"peTTM": 10.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 2.0,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
{"symbol": "000001", "exchange": "SZ", "date": "2023-08-29",
"peTTM": 20.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 0.8,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
# 亏损股: baostock 不给负 pe → NaN;pe=0 守卫用例
{"symbol": "300001", "exchange": "SZ", "date": "2023-08-29",
"peTTM": None, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 5.0,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
{"symbol": "000001", "exchange": "SZ", "date": "2023-08-30",
"peTTM": 0.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 0.8,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
# exchange 全称变体(SSE/SZSE 旧契约形态)一行 → 映射兼容锁定
{"symbol": "600004", "exchange": "SSE", "date": "2023-08-29",
"peTTM": 40.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 4.0,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
]).write_parquet(os.path.join(vb_dir, "2023.parquet"))
# 2026 文件: 08-13 起(bs 日喂起点;01-01~08-12 缺口由 static/valuation 补)
pl.DataFrame([
{"symbol": "600000", "exchange": "SH", "date": "2026-08-13",
"peTTM": 20.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 4.0,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
{"symbol": "600000", "exchange": "SH", "date": "2026-08-14",
"peTTM": 19.0, "psTTM": 6.0, "pcfNcfTTM": 8.0, "pbMRQ": 3.9,
"turn": 0.5, "pctChg": 1.0, "isST": 0},
]).write_parquet(os.path.join(vb_dir, "2026.parquet"))
# ---- static/valuation(ak em 中文列;仅补 vb 2026-01-01~08-12 缺口)----
os.makedirs(os.path.join(static_dir, "valuation"), exist_ok=True)
pl.DataFrame([
{"数据日期": date(2026, 1, 5), "PE(TTM)": 25.0, "市净率": 5.0},
{"数据日期": date(2026, 8, 12), "PE(TTM)": 24.0, "市净率": 4.8},
]).write_parquet(os.path.join(static_dir, "valuation", "600000.SH_valuation.parquet"))
return static_dir
import pytest
@pytest.fixture(scope="session")
def synthetic_static(tmp_path_factory) -> str:
"""session 级合成静态域根目录(test_fundamental_* 共用)."""
return build_synthetic_static(tmp_path_factory.mktemp("fund_static"))
@pytest.fixture(scope="session")
def np_q_series() -> list[float]:
"""合成归母净利单季序列(SUE 期望值独立重算用)."""
return list(NP_Q)