119 lines
6.1 KiB
Python
119 lines
6.1 KiB
Python
# 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}
|
||
|
||
|
||
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, "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)
|
||
# 交易所映射: 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"),
|
||
pl.col("公告日期").cast(pl.Date, strict=False).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
|