Files
sanguo_vnpy_v2/sanguo_factor/fundamental_forecast.py
T
2026-09-10 18:59:49 +08:00

136 lines
7.2 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.
# sanguo_factor/fundamental_forecast.py
"""forecast 业绩预告事件层: static/forecast 按报告期全市场文件 → 事件流
(自 fundamental_adapter.py 拆出,纯结构重构).
fc_events(公告日 asof 信号)+ fc_pair(F07 预告兑现差配对原料)双出口;
归母净利润行优先,年化预告净利只对净利行生效.
"""
from __future__ import annotations
import os
from datetime import datetime
import polars as pl
from .fundamental_schema import FORECAST_TYPE_SCORE
# ==================== forecast 事件层 ====================
# 预告净利年化系数(按报告期进度;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}
# PIT 生效日偏移开关(eff+N 敏感性对照实验,调用时读取). 预告常在收盘后公告,
# 默认 0 = 公告日 D 当天即可用其信息;设 N 时所有预告事件生效日整体推后 N 个
# 自然日(D 日不可见,D+N 起可见),同时作用于 fc_events 与 fc_pair(F07 兑现差
# 锚的预告腿);三表/分红/户数/十大流通股东/vb 域不受此开关影响.
# 跑批侧两种设置方式(取其一):
# ①容器/进程环境变量: SANGUO_FORECAST_PIT_OFFSET_DAYS=1(docker run -e 即可)
# ②进程内直设(batch_eval 在调 iter_fundamental_feature_chunks 前):
# import sanguo_factor.fundamental_forecast as _ff
# _ff.FORECAST_PIT_OFFSET_DAYS = 1
FORECAST_PIT_OFFSET_DAYS = int(os.environ.get("SANGUO_FORECAST_PIT_OFFSET_DAYS", "0") or 0)
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 取自文件名)。
生效日 eff 受模块级 FORECAST_PIT_OFFSET_DAYS 偏移(默认 0=公告日本身),
两出口(fc_events/fc_pair)同步生效。
"""
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), pl.DataFrame(schema=schema)
code_set = set(codes)
frames = []
for fname in sorted(os.listdir(fc_dir)):
if not fname.endswith(".parquet"):
continue
try:
f = pl.read_parquet(os.path.join(fc_dir, fname))
except Exception:
continue
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)
eff = pl.col("公告日期").cast(pl.Date, strict=False)
if FORECAST_PIT_OFFSET_DAYS: # eff+N 敏感性开关(见模块级常量注释)
eff = eff.dt.offset_by(f"{FORECAST_PIT_OFFSET_DAYS}d")
# 交易所映射: 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)
pref = (ind.str.contains("净利润") & ~ind.str.contains("")).cast(pl.Int32)
else:
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"),
eff.alias("eff"),
pl.col("预告类型").cast(pl.Utf8).replace_strict(
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", "REPORT_DATE", "_pref", "_score", "_pct", "_mid", "_annf"]))
if not frames:
return pl.DataFrame(schema=schema), pl.DataFrame(schema=schema)
fc = pl.concat(frames).sort(["vt_symbol", "eff", "_pref"])
# 同 (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