"""四路子因子构造。输入日期区间 [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 最新批次 score(0~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 = 一致预期目标价中枢 / 当日现价 − 1(as-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, moved_ratio, " "members_total, moved, 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: """把上游「静默失真」变成显式告警——07-28 按基座二批后的新制度重写判据。 基座二批后:topic_context cap=1000、scan 大主题闸=600、quiet 全量落库、 mkt_trade_date 落库。旧判据(members_total>=30 撞 cap30 / n_quiet_stored>=12 撞截断)在新制度下全是假阳性(07-27 实测 6/7 条误报),废弃。 采信规则(stale 剔除/打折/标记)待三批-5 拍板,当前全量采信、只告警。 """ 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"(基座大主题闸值)——基座制度回退或闸失效,查 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 及更早的旧制度日,此告警对旧日属预期)") BUILDERS = { "akg_upside": build_upside, "akg_heat": build_heat, "akg_event": build_event, "akg_transmission": build_transmission, }