akg-factor-bridge/factors.py

179 lines
8.3 KiB
Python
Raw Normal View History

2026-07-24 14:20:54 +08:00
"""四路子因子构造。输入日期区间 [start, end]YYYY-MM-DD输出
DataFrame[trade_date, stock_code, factor_value]全部限制在 KG 覆盖池 universe
stock_code 输出形态不限common.write_factor 统一转前缀式
建模参数EVENT_POLARITY / *_HALF_LIFE / *_WINDOW**因子决策**见设计文档
§3.3 §8-3此处取草案默认待用户确认后调方向(direction)统一在合成配置里声明
子因子表只存原始值 event 天然带符号故此处保留极性
"""
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",
}
# ---- 事件极性草案§3.3,待 §8-3 确认)----
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 已前缀式。"""
uni = {common.to_prefix(x) for x in common.load_universe()}
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 = df[df["stock_code"].isin(uni)]
return df[["trade_date", "stock_code", "factor_value"]]
2026-07-24 15:07:31 +08:00
def _read_gp_price(start, end):
"""gp_day_data 现价:代码列名不定(平台实测=symbol非 ts_code——照 rsi_14d_etl
惯例按候选列逐个试用第一个能查通的"""
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:
df = db.read_mysql("price",
f"SELECT `timestamp` AS trade_date, `{c}` AS ts_code, close "
f"FROM gp_day_data WHERE `timestamp` BETWEEN %s AND %s", (start, end))
print(f" (upside 现价用 gp_day_data.{c})")
return df
except Exception as e: # noqa: BLE001 —— 列名不对就换下一个候选
last = e
raise RuntimeError(f"gp_day_data 代码列都不行(试了 {cands}: {last!r}")
2026-07-24 14:20:54 +08:00
# ---------------------------------------------------------------- 预期空间
def build_upside(start, end):
"""upside = 一致预期目标价中枢 / 当日现价 1as-of现价日取 asof<=当日最新一致预期)。
现价来自平台 gp_day_data代码列 config.PRICE_CODE_COL待实机核实"""
uni = common.load_universe()
cons = db.read_pg(
"SELECT ts_code, asof_date, target_mid_avg FROM v_factor_consensus WHERE asof_date <= %s",
(end,))
cons = cons[cons["ts_code"].isin(uni)].copy()
if cons.empty:
return _EMPTY
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")
# 归一到前缀式两边对齐gp_day_data 代码形态不定 → 都折前缀式后 join
price["k"] = price["ts_code"].map(common.to_prefix)
price = price[(price["close"] > 0)].dropna(subset=["close"])
cons["k"] = cons["ts_code"].map(common.to_prefix)
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/半衰期))。
ts_code 取文档锚v_factor_events 已解析无事件的股当天不出行= 合成侧填 0"""
uni = common.load_universe()
look = (pd.Timestamp(start) - pd.Timedelta(days=EVENT_WINDOW * 2)).date()
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))
ev = ev[ev["ts_code"].isin(uni)].copy()
if ev.empty:
return _EMPTY
2026-07-24 15:13:47 +08:00
ev["event_type"] = ev["event_type"].fillna("")
ev["direction"] = ev["direction"].fillna("") # 多数事件无 direction(NULL→NaN),先填空防 .strip 崩
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
cal = common.trading_days(start, end)
if not cal:
return _EMPTY
cal = pd.DatetimeIndex(cal)
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]")
pol = g["pol"].to_numpy(dtype=float)
for d in cal:
# 自然日龄 → 交易日龄近似 ×(5/7)v1 近似,见 README 待优化项)
age_td = ((d.value - disc.astype("int64")) / 86_400e9) * (5.0 / 7.0)
mask = (age_td >= 0) & (age_td <= EVENT_WINDOW)
if not mask.any():
continue
val = float((pol[mask] * np.exp(-age_td[mask] * decay)).sum())
if val != 0.0:
rows.append((d.date(), ts, val))
return pd.DataFrame(rows, columns=["trade_date", "stock_code", "factor_value"]) if rows else _EMPTY
# ---------------------------------------------------------------- 传导
def build_transmission(start, end):
"""传导分 = 指向该股所在环节的路径数 ×1 已动比例);同股同日多候选取最大。"""
uni = common.load_universe()
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))
tr = tr[tr["ts_code"].isin(uni)].copy()
if tr.empty:
return _EMPTY
tr["factor_value"] = (tr["n_paths"].astype(float)
* (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"]]
BUILDERS = {
"akg_upside": build_upside, "akg_heat": build_heat,
"akg_event": build_event, "akg_transmission": build_transmission,
}