Files
sanguo_vnpy_v2/tests/factor/conftest.py
T
2026-09-10 18:59:49 +08:00

343 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
import pytest
# 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)
@pytest.fixture(autouse=True, scope="session")
def _pin_forecast_pit_offset_env():
# 密闭测试(session 级,须先于任何 module 级 feat fixture 构建): 防外部环境变量
# SANGUO_FORECAST_PIT_OFFSET_DAYS 渗入改变预告域生效日语义。其余测试一律测
# 默认口径 0;偏移行为由 test_fundamental_forecast_pit_offset.py 专门覆盖
# (其内部用 monkeypatch 自行设值/还原,不受本钉死影响)。
os.environ.pop("SANGUO_FORECAST_PIT_OFFSET_DAYS", None)
import sanguo_factor.fundamental_forecast as _ff
_ff.FORECAST_PIT_OFFSET_DAYS = 0
yield
# ==================== 合成财务静态域(财务因子批测试共用) ====================
# 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)