akg-factor-bridge/factors.py

307 lines
15 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.

"""四路子因子构造。输入日期区间 [start, end]YYYY-MM-DD输出
DataFrame[trade_date, stock_code, factor_value]。stock_code 输出形态不限,
common.write_factor 统一转前缀式。
覆盖范围由 config.SUBFACTOR_UNIVERSE 控制pool=池内 / market=全市场,见 §4 评审);
传导不受该开关影响——它天生就是池内语义。
建模参数EVENT_POLARITY / *_HALF_LIFE / *_WINDOW是**因子决策**,见设计文档
§5.3 与 §9-5此处取草案默认待用户确认后调。
"""
import numpy as np
import pandas as pd
import config
import common
import db
FACTORS = {
"akg_upside": "t_factor_akg_upside",
"akg_heat": "t_factor_akg_heat",
"akg_event": "t_factor_akg_event",
"akg_transmission": "t_factor_akg_transmission",
}
# ---- 事件极性草案(设计 §5.3,待 §9-5 确认)----
EVENT_POLARITY = {
"股份回购": 1.0, "重大合同中标": 1.0, "股权激励授予": 0.5,
"诉讼仲裁": -1.0, "行政处罚": -1.0, "股权质押": -0.5,
"发行上市": 0.0, "并购交割": 0.0, "other": 0.0,
# 增减持 / 业绩预告:符号取决于 direction下 EVENT_DIR
"增减持": 0.0, "业绩预告": 0.0,
}
EVENT_DIR = {"预增": 1.0, "预减": -1.0, "增持": 1.0, "减持": -1.0}
EVENT_HALF_LIFE = 10 # 交易日
EVENT_WINDOW = 60 # 交易日(超窗不计)
_EMPTY = pd.DataFrame(columns=["trade_date", "stock_code", "factor_value"])
# ---------------------------------------------------------------- 热度
def build_heat(start, end):
"""热度 = stock_fund_heat_scores 最新批次 score0~1。stock_code 已前缀式。"""
df = db.read_mysql("heat",
"""SELECT s.trade_date, s.stock_code, s.score AS factor_value
FROM stock_fund_heat_scores s
JOIN (SELECT trade_date, MAX(batch_no) bn FROM stock_fund_heat_scores
WHERE trade_date BETWEEN %s AND %s GROUP BY trade_date) m
ON m.trade_date = s.trade_date AND m.bn = s.batch_no""",
(start, end))
if df.empty:
return _EMPTY
df["stock_code"] = df["stock_code"].astype(str).str.strip()
df["factor_value"] = pd.to_numeric(df["factor_value"], errors="coerce")
df = common.universe_filter(df, col="stock_code", as_prefix=True)
return df[["trade_date", "stock_code", "factor_value"]]
def _price_code_col() -> str:
"""gp_day_data 的代码列名(平台实测 = symbol非 ts_code
用 LIMIT 1 探列名,而不是拿全区间查询去试错——原来那种写法在 history 模式下
第一次试探就会拉一次全区间数据。"""
cands = [config.PRICE_CODE_COL] + [c for c in ("symbol", "ts_code")
if c != config.PRICE_CODE_COL]
last = None
for c in cands:
try:
db.read_mysql("price", f"SELECT `{c}` FROM gp_day_data LIMIT 1")
print(f" (现价用 gp_day_data.{c})")
return c
except Exception as e: # noqa: BLE001 —— 列名不对就换下一个候选
last = e
raise RuntimeError(f"gp_day_data 代码列都不行(试了 {cands}: {last!r}")
def _read_gp_price(start, end):
"""gp_day_data 现价,**按月分块**读取。
原来一次拉全区间:`--mode history --start 2006-01-01` 会把千万级行拉进 pandas。
不在 SQL 里按代码过滤——代码形态600000.SH / SH600000 / 600000两边不一致
SQL 侧过滤容易全空且难排查,统一折前缀式后在 pandas 侧过滤。
"""
col = _price_code_col()
parts, cur = [], pd.Timestamp(start)
endts = pd.Timestamp(end)
step = max(1, config.PRICE_CHUNK_DAYS)
while cur <= endts:
hi = min(cur + pd.Timedelta(days=step - 1), endts)
parts.append(db.read_mysql(
"price",
f"SELECT `timestamp` AS trade_date, `{col}` AS ts_code, close "
f"FROM gp_day_data WHERE `timestamp` BETWEEN %s AND %s",
(cur.date().isoformat(), hi.date().isoformat())))
cur = hi + pd.Timedelta(days=1)
if not parts:
return pd.DataFrame(columns=["trade_date", "ts_code", "close"])
return pd.concat(parts, ignore_index=True)
# ---------------------------------------------------------------- 预期空间
def build_upside(start, end):
"""upside = 一致预期目标价中枢 / 当日现价 1as-of现价日取 asof<=当日最新一致预期)。
⚠️ 两个已知口径特征(评审 §6.3,不是 bug但读数时要知道
· consensus_daily 的 target_mid_avg 是**90 天内全部研报行的简单平均**
(不按机构去重、不按时间加权),且实测 max_price 非空仅 0.4%、min_price 31%
—— 目标价中枢主要由单值目标价构成;
· gp_day_data 是**前复权**、锚在最新日,每次除权历史 close 会被整体重写,
所以 upside 的历史值不可复现 —— 用 freeze.py 冻结当时用到的 close。
"""
cons = db.read_pg(
"SELECT ts_code, asof_date, target_mid_avg FROM v_factor_consensus "
"WHERE asof_date <= %s", (end,))
if cons.empty:
return _EMPTY
cons["k"] = cons["ts_code"].map(common.to_prefix)
# 注consensus_daily 本就只对池成员聚合(基座 _pool_ts_codes
# 故 market 模式下 upside 覆盖不会真的变宽——这里过滤只为口径一致。
cons = common.universe_filter(cons, col="k", as_prefix=True)
if cons.empty:
return _EMPTY
price = _read_gp_price(start, end)
if price.empty:
return _EMPTY
price["close"] = pd.to_numeric(price["close"], errors="coerce")
price["k"] = price["ts_code"].map(common.to_prefix)
price = price[(price["close"] > 0)].dropna(subset=["close"])
cons = cons[cons["k"].isin(set(price["k"]))]
if cons.empty:
return _EMPTY
# 强制两侧键同分辨率 datetime64[ns]pandas 2.x 不同来源可能 us/ns 混,
# merge_asof 会报 incompatible merge keys
cons["asof_date"] = pd.to_datetime(cons["asof_date"]).astype("datetime64[ns]")
price["trade_date"] = pd.to_datetime(price["trade_date"]).astype("datetime64[ns]")
left = price[["trade_date", "k", "close"]].sort_values("trade_date")
right = cons[["asof_date", "k", "target_mid_avg"]].sort_values("asof_date")
m = pd.merge_asof(left, right, left_on="trade_date", right_on="asof_date",
by="k", direction="backward") # 每股取 asof<=当日最新目标价
m = m.dropna(subset=["target_mid_avg", "close"])
if m.empty:
return _EMPTY
m["factor_value"] = m["target_mid_avg"].astype(float) / m["close"] - 1.0
m = m.rename(columns={"k": "stock_code"})
return m[["trade_date", "stock_code", "factor_value"]]
# ---------------------------------------------------------------- 事件
def _polarity(event_type, direction):
d = (direction or "").strip()
if d in EVENT_DIR:
return EVENT_DIR[d]
return EVENT_POLARITY.get(event_type, 0.0)
def build_event(start, end):
"""事件分 = Σ 近窗口内事件 极性 × 时间衰减exp(-交易日龄·ln2/半衰期))。
ts_code 取文档锚v_factor_events 已解析)。无事件的股当天不出行(= 缺 → 合成侧填 0
"""
look = (pd.Timestamp(start) - pd.Timedelta(days=EVENT_WINDOW * 2)).date()
try:
ev = db.read_pg(
"SELECT ts_code, disclosure_date, event_type, direction, "
" confidence, doc_id, source_type "
"FROM v_factor_events WHERE disclosure_date BETWEEN %s AND %s", (look, end))
except Exception as e: # noqa: BLE001 —— 视图还是 v1无 doc_id/source_type时退回
print(f" (v_factor_events 无 doc_id/source_type退回旧列——建议先更新视图: {e!r})")
ev = db.read_pg(
"SELECT ts_code, disclosure_date, event_type, direction "
"FROM v_factor_events WHERE disclosure_date BETWEEN %s AND %s", (look, end))
if ev.empty:
return _EMPTY
ev = common.universe_filter(ev, col="ts_code", as_prefix=False).copy()
if ev.empty:
return _EMPTY
# ---- 年报污染防护(评审 §6.5------------------------------------------
# v_factor_events 的锚 documents.meta->>'company_ts_code' 年报同样有,而一份年报
# 能抽十几条 EVENT且含**历史**诉讼/处罚 —— 会在年报披露日形成巨大负值尖峰。
# 基座 hotspot._pick_event_anomalies 为此专门做了防刷屏other 不进 / 同主体同
# 类型只取最新 / 单主体≤2桥侧原来零保护。三道从强到弱
if "source_type" in ev.columns:
keep = ev["source_type"].astype(str).isin(config.EVENT_SOURCE_TYPES)
if (~keep).any():
drop_by = ev.loc[~keep, "source_type"].value_counts().to_dict()
print(f" (事件:按 source_type 剔除 {int((~keep).sum())}{drop_by})")
ev = ev[keep]
if ev.empty:
print(" ⚠️ 按 source_type 白名单过滤后为空——核对 EVENT_SOURCE_TYPES")
return _EMPTY
if "confidence" in ev.columns: # 同键取置信度最高的一条
ev = ev.sort_values("confidence", ascending=False, na_position="last")
ev = ev.drop_duplicates(["ts_code", "event_type", "direction", "disclosure_date"])
if "doc_id" in ev.columns: # 单文档封顶
before = len(ev)
ev = ev.groupby("doc_id", group_keys=False).head(config.EVENT_MAX_PER_DOC)
if len(ev) < before:
print(f" (事件:单文档封顶 {config.EVENT_MAX_PER_DOC} 条,剔除 {before - len(ev)} 条)")
ev["event_type"] = ev["event_type"].fillna("")
ev["direction"] = ev["direction"].fillna("") # 多数事件无 direction(NULL→NaN)
ev["pol"] = [_polarity(t, d) for t, d in zip(ev["event_type"], ev["direction"])]
ev = ev[ev["pol"] != 0.0]
if ev.empty:
return _EMPTY
cal = common.trading_days(start, end)
if not cal:
print(f" ⚠️ {start}~{end} 无交易日 → 事件因子空转"
f"(这是「日历为空」,不是「没有事件」)")
return _EMPTY
cal = pd.DatetimeIndex(cal)
cal_i = cal.values.astype("datetime64[ns]").astype("int64") # (D,)
decay = np.log(2) / EVENT_HALF_LIFE
rows = []
for ts, g in ev.groupby("ts_code"):
disc = pd.to_datetime(g["disclosure_date"]).values \
.astype("datetime64[ns]").astype("int64") # (E,)
pol = g["pol"].to_numpy(dtype=float)
# 自然日龄 → 交易日龄近似 ×(5/7)v1 近似,见 README 待优化项)
age = (cal_i[:, None] - disc[None, :]) / 86_400e9 * (5.0 / 7.0) # (D,E)
m = (age >= 0) & (age <= EVENT_WINDOW)
if not m.any():
continue
age_safe = np.where(m, age, 0.0) # 先夹再 exp防 exp(超大正数) 溢出
vals = np.where(m, pol[None, :] * np.exp(-age_safe * decay), 0.0).sum(axis=1)
for i in np.nonzero(vals)[0]:
rows.append((cal[i].date(), ts, float(vals[i])))
return (pd.DataFrame(rows, columns=["trade_date", "stock_code", "factor_value"])
if rows else _EMPTY)
# ---------------------------------------------------------------- 传导
def build_transmission(start, end):
"""传导分 = 指向该股所在环节的 **distinct 源数** ×1 已动比例);
同股同日多候选取最大。
口径修正(评审 硬伤2原用 n_paths = jsonb_array_length(paths),但
· graph_store.cascade() 的变长边 `*1..N` **每种长度各返回一条路径**
A→B 与 A→X→B 同时出现、末节点都是 B跨路径不去重
· graph_store.transmission_targets() 的 updown/supply/drives 三桶之间也不去重。
于是同一 source 对同一 target 重复计入,而 n_paths 是 factor_value 的主量级。
改用 distinct source 数,也更贴合传导模块自述的语义「多源汇聚 = 传导逻辑更硬」。
"""
cols_new = ("scan_date, target, ts_code, n_sources, n_paths_raw, moved_ratio, "
"members_total, n_quiet_stored, mkt_trade_date")
try:
tr = db.read_pg(
f"SELECT {cols_new} FROM v_factor_transmission "
f"WHERE scan_date BETWEEN %s AND %s", (start, end))
col_val = "n_sources"
except Exception as e: # noqa: BLE001 —— 视图还是 v1 时退回,但明确告警
print(f" ⚠️ v_factor_transmission 还是旧版(无 n_sources——退回 n_paths"
f"该口径把重复路径计入了强度,请尽快更新视图: {e!r}")
tr = db.read_pg(
"SELECT scan_date, ts_code, n_paths, moved_ratio "
"FROM v_factor_transmission WHERE scan_date BETWEEN %s AND %s", (start, end))
col_val = "n_paths"
if tr.empty:
return _EMPTY
uni = common.load_universe() # 传导恒按池过滤(天生池内语义)
tr = tr[tr["ts_code"].isin(uni)].copy()
if tr.empty:
return _EMPTY
_warn_upstream_truncation(tr)
tr["factor_value"] = (pd.to_numeric(tr[col_val], errors="coerce").fillna(0.0)
* (1.0 - pd.to_numeric(tr["moved_ratio"],
errors="coerce").fillna(0.0)))
g = (tr.groupby(["scan_date", "ts_code"])["factor_value"].max().reset_index()
.rename(columns={"scan_date": "trade_date", "ts_code": "stock_code"}))
return g[["trade_date", "stock_code", "factor_value"]]
def _warn_upstream_truncation(tr: pd.DataFrame) -> None:
"""把三个上游「静默失真」变成显式告警(评审 硬伤1、§6.2)。
这些是基座侧的问题,桥修不了,但**必须看得见**——否则第一权重项在悄悄失真。
"""
if "mkt_trade_date" in tr.columns and tr["mkt_trade_date"].notna().any():
bad = tr[tr["mkt_trade_date"].astype(str) != tr["scan_date"].astype(str)]
if not bad.empty:
print(f" ⚠️ {bad['scan_date'].nunique()} 个 scan_date 的 movers 快照日与 "
f"scan_date 不符17:30 sync_market 晚点)——这些日的传导项不可信")
if "members_total" in tr.columns:
hit = tr[pd.to_numeric(tr["members_total"], errors="coerce")
>= config.UPSTREAM_MEMBER_CAP]
if not hit.empty:
n = hit["target"].nunique() if "target" in hit else len(hit)
print(f" ⚠️ {n} 个环节 members_total >= {config.UPSTREAM_MEMBER_CAP}"
f"撞上游 topic_context 的 LIMIT cap —— moved_ratio 建立在**任意顺序**的"
f"抽样上Cypher 无 ORDER BY先修基座再放量")
if "n_quiet_stored" in tr.columns:
hit = tr[pd.to_numeric(tr["n_quiet_stored"], errors="coerce")
>= config.UPSTREAM_QUIET_CAP]
if not hit.empty:
n = hit["target"].nunique() if "target" in hit else len(hit)
print(f" ⚠️ {n} 个环节 quiet 存满 {config.UPSTREAM_QUIET_CAP} 条,"
f"撞 transmission.py 的 quiet[:12] 截断 —— 真实未动成员更多,"
f"因子覆盖被展示逻辑锁住")
BUILDERS = {
"akg_upside": build_upside, "akg_heat": build_heat,
"akg_event": build_event, "akg_transmission": build_transmission,
}