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 最新批次 score(0~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 = 一致预期目标价中枢 / 当日现价 − 1(as-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
|
|
|
|
|
|
cons["asof_date"] = pd.to_datetime(cons["asof_date"])
|
|
|
|
|
|
price["trade_date"] = pd.to_datetime(price["trade_date"])
|
|
|
|
|
|
out = []
|
|
|
|
|
|
cons_sorted = cons.sort_values("asof_date")
|
|
|
|
|
|
for k, pg in price.groupby("k"):
|
|
|
|
|
|
cg = cons_sorted[cons_sorted["k"] == k]
|
|
|
|
|
|
if cg.empty:
|
|
|
|
|
|
continue
|
|
|
|
|
|
m = pd.merge_asof(pg.sort_values("trade_date"),
|
|
|
|
|
|
cg[["asof_date", "target_mid_avg"]],
|
|
|
|
|
|
left_on="trade_date", right_on="asof_date", direction="backward")
|
|
|
|
|
|
m = m.dropna(subset=["target_mid_avg"])
|
|
|
|
|
|
if m.empty:
|
|
|
|
|
|
continue
|
|
|
|
|
|
m["factor_value"] = m["target_mid_avg"].astype(float) / m["close"] - 1.0
|
|
|
|
|
|
m["stock_code"] = k
|
|
|
|
|
|
out.append(m[["trade_date", "stock_code", "factor_value"]])
|
|
|
|
|
|
return pd.concat(out) if out else _EMPTY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 事件
|
|
|
|
|
|
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["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,
|
|
|
|
|
|
}
|