akg-factor-bridge/factors.py

179 lines
8.3 KiB
Python
Raw Permalink 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],全部限制在 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"]]
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}")
# ---------------------------------------------------------------- 预期空间
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
price = _read_gp_price(start, end)
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
# 强制两侧键同分辨率 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"""
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
ev["event_type"] = ev["event_type"].fillna("")
ev["direction"] = ev["direction"].fillna("") # 多数事件无 direction(NULL→NaN),先填空防 .strip 崩
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,
}