akg-factor-bridge/factors.py

311 lines
16 KiB
Python
Raw Normal View History

2026-07-24 14:20:54 +08:00
"""四路子因子构造。输入日期区间 [start, end]YYYY-MM-DD输出
2026-07-27 09:49:05 +08:00
DataFrame[trade_date, stock_code, factor_value]stock_code 输出形态不限
common.write_factor 统一转前缀式
覆盖范围由 config.SUBFACTOR_UNIVERSE 控制pool=池内 / market=全市场 §4 评审
传导不受该开关影响它天生就是池内语义
2026-07-24 14:20:54 +08:00
建模参数EVENT_POLARITY / *_HALF_LIFE / *_WINDOW**因子决策**见设计文档
2026-07-27 09:49:05 +08:00
§5.3 §9-5此处取草案默认待用户确认后调
2026-07-24 14:20:54 +08:00
"""
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",
}
2026-07-27 09:49:05 +08:00
# ---- 事件极性草案(设计 §5.3,待 §9-5 确认)----
2026-07-24 14:20:54 +08:00
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")
2026-07-27 09:49:05 +08:00
df = common.universe_filter(df, col="stock_code", as_prefix=True)
2026-07-24 14:20:54 +08:00
return df[["trade_date", "stock_code", "factor_value"]]
2026-07-27 09:49:05 +08:00
def _price_code_col() -> str:
"""gp_day_data 的代码列名(平台实测 = symbol非 ts_code
LIMIT 1 探列名而不是拿全区间查询去试错原来那种写法在 history 模式下
第一次试探就会拉一次全区间数据"""
2026-07-24 15:07:31 +08:00
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:
2026-07-27 09:49:05 +08:00
db.read_mysql("price", f"SELECT `{c}` FROM gp_day_data LIMIT 1")
print(f" (现价用 gp_day_data.{c})")
return c
2026-07-24 15:07:31 +08:00
except Exception as e: # noqa: BLE001 —— 列名不对就换下一个候选
last = e
raise RuntimeError(f"gp_day_data 代码列都不行(试了 {cands}: {last!r}")
2026-07-27 09:49:05 +08:00
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)
2026-07-24 14:20:54 +08:00
# ---------------------------------------------------------------- 预期空间
def build_upside(start, end):
"""upside = 一致预期目标价中枢 / 当日现价 1as-of现价日取 asof<=当日最新一致预期)。
2026-07-27 09:49:05 +08:00
两个已知口径特征评审 §6.3不是 bug但读数时要知道
· consensus_daily target_mid_avg **90 天内全部研报行的简单平均**
不按机构去重不按时间加权且实测 max_price 非空仅 0.4%min_price 31%
目标价中枢主要由单值目标价构成
· gp_day_data **前复权**锚在最新日每次除权历史 close 会被整体重写
所以 upside 的历史值不可复现 freeze.py 冻结当时用到的 close
"""
2026-07-24 14:20:54 +08:00
cons = db.read_pg(
2026-07-27 09:49:05 +08:00
"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)
2026-07-24 14:20:54 +08:00
if cons.empty:
return _EMPTY
2026-07-27 09:49:05 +08:00
2026-07-24 15:07:31 +08:00
price = _read_gp_price(start, end)
2026-07-24 14:20:54 +08:00
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
2026-07-24 15:13:47 +08:00
# 强制两侧键同分辨率 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"]]
2026-07-24 14:20:54 +08:00
# ---------------------------------------------------------------- 事件
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/半衰期))。
2026-07-27 09:49:05 +08:00
ts_code 取文档锚v_factor_events 已解析无事件的股当天不出行= 合成侧填 0
"""
2026-07-24 14:20:54 +08:00
look = (pd.Timestamp(start) - pd.Timedelta(days=EVENT_WINDOW * 2)).date()
2026-07-27 09:49:05 +08:00
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))
2026-07-24 14:20:54 +08:00
if ev.empty:
return _EMPTY
2026-07-27 09:49:05 +08:00
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)} 条)")
2026-07-24 15:13:47 +08:00
ev["event_type"] = ev["event_type"].fillna("")
2026-07-27 09:49:05 +08:00
ev["direction"] = ev["direction"].fillna("") # 多数事件无 direction(NULL→NaN)
2026-07-24 14:20:54 +08:00
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
2026-07-27 09:49:05 +08:00
2026-07-24 14:20:54 +08:00
cal = common.trading_days(start, end)
if not cal:
2026-07-27 09:49:05 +08:00
print(f" ⚠️ {start}~{end} 无交易日 → 事件因子空转"
f"(这是「日历为空」,不是「没有事件」)")
2026-07-24 14:20:54 +08:00
return _EMPTY
cal = pd.DatetimeIndex(cal)
2026-07-27 09:49:05 +08:00
cal_i = cal.values.astype("datetime64[ns]").astype("int64") # (D,)
2026-07-24 14:20:54 +08:00
decay = np.log(2) / EVENT_HALF_LIFE
rows = []
for ts, g in ev.groupby("ts_code"):
2026-07-27 09:49:05 +08:00
disc = pd.to_datetime(g["disclosure_date"]).values \
.astype("datetime64[ns]").astype("int64") # (E,)
2026-07-24 14:20:54 +08:00
pol = g["pol"].to_numpy(dtype=float)
2026-07-27 09:49:05 +08:00
# 自然日龄 → 交易日龄近似 ×(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)
2026-07-24 14:20:54 +08:00
# ---------------------------------------------------------------- 传导
def build_transmission(start, end):
2026-07-27 09:49:05 +08:00
"""传导分 = 指向该股所在环节的 **distinct 源数** ×1 已动比例);
同股同日多候选取最大
口径修正评审 硬伤2原用 n_paths = jsonb_array_length(paths)
· graph_store.cascade() 的变长边 `*1..N` **每种长度各返回一条路径**
AB AXB 同时出现末节点都是 B跨路径不去重
· graph_store.transmission_targets() updown/supply/drives 三桶之间也不去重
于是同一 source 对同一 target 重复计入 n_paths factor_value 的主量级
改用 distinct source 也更贴合传导模块自述的语义多源汇聚 = 传导逻辑更硬
"""
2026-07-27 10:25:34 +08:00
cols_new = ("scan_date, target, ts_code, n_sources, n_paths, moved_ratio, "
"members_total, moved, n_quiet_stored, mkt_trade_date")
2026-07-27 09:49:05 +08:00
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() # 传导恒按池过滤(天生池内语义)
2026-07-24 14:20:54 +08:00
tr = tr[tr["ts_code"].isin(uni)].copy()
if tr.empty:
return _EMPTY
2026-07-27 09:49:05 +08:00
_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)))
2026-07-24 14:20:54 +08:00
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"]]
2026-07-27 09:49:05 +08:00
def _warn_upstream_truncation(tr: pd.DataFrame) -> None:
"""把上游「静默失真」变成显式告警——07-28 按基座二批后的新制度重写判据。
2026-07-27 09:49:05 +08:00
基座二批后topic_context cap=1000scan 大主题闸=600quiet 全量落库
mkt_trade_date 落库旧判据members_total>=30 cap30 / n_quiet_stored>=12
撞截断在新制度下全是假阳性07-27 实测 6/7 条误报废弃
采信规则stale 剔除/打折/标记待三批-5 拍板当前全量采信只告警
2026-07-27 09:49:05 +08:00
"""
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]
2026-07-27 09:49:05 +08:00
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"(基座大主题闸值)——基座制度回退或闸失效,查 transmission.scan")
if {"n_quiet_stored", "members_total", "moved"} <= set(tr.columns):
mt = pd.to_numeric(tr["members_total"], errors="coerce")
mv = pd.to_numeric(tr["moved"], errors="coerce")
nq = pd.to_numeric(tr["n_quiet_stored"], errors="coerce")
bad = tr[(mt - mv) != nq]
if not bad.empty:
n = bad["target"].nunique() if "target" in bad else len(bad)
print(f" ⚠️ {n} 个候选 n_quiet_stored ≠ members_total moved ——"
f"quiet 截断复活或台账/视图不一致07-27 新制度起不该发生;"
f"若构建窗口含 07-24 及更早的旧制度日,此告警对旧日属预期)")
2026-07-27 09:49:05 +08:00
2026-07-24 14:20:54 +08:00
BUILDERS = {
"akg_upside": build_upside, "akg_heat": build_heat,
"akg_event": build_event, "akg_transmission": build_transmission,
}