akg-factor-bridge/probe.py

274 lines
14 KiB
Python
Raw Permalink Normal View History

2026-07-27 09:49:05 +08:00
"""G1 体检(只读探查,不写任何库)。设计依据:量化因子导出与合成设计.md §8-G1
+ 2026-07-26 交叉评审
python run.py probe # 四节全跑
python run.py probe --section corr # 单跑pools | price | upside | corr
四节
[pools ] industry_pools 列结构 + 逐池清单 + 成员元素键采样
frontier_tracks.yml 的映射要对着真实主题名/字段起草不猜 KG 命名
[price ] gp_day_data 按年 distinct 天数 复核 §6.2 起始日疑点5584 vs 理论 ~7000
[upside] 覆盖池 upside 截面分布 + q 档位表 §9-3 估值门槛分位 q
[corr ] 池内 (z_T, z_H, z_V) 相关矩阵 + 权重体检 §9-8 权重结构评审 §2
注意pools 节直读基座 industry_pools 这是一次性诊断的例外§4 的运行时路径
只许走只读视图看清 schema G2 把池清单固化成第五个插槽视图再供 tracks.py
若连接账号只被授了视图权限此节会报 permission denied换基座 owner 账号跑一次即可
任一节失败不影响其余节
"""
import numpy as np
import pandas as pd
import common
import db
import factors
def _sec(t):
print(f"\n{'=' * 64}\n[{t}]\n{'=' * 64}")
# ---------------------------------------------------------------- pools
def probe_pools():
_sec("pools · industry_pools 结构与主题清单")
cols = db.read_pg(
"SELECT column_name, data_type FROM information_schema.columns "
"WHERE table_name = 'industry_pools' "
" AND table_schema NOT IN ('pg_catalog','information_schema') "
"ORDER BY ordinal_position")
if cols.empty:
print("❌ 取不到 industry_pools 列信息(表不存在或无权限)")
return
print("列结构:")
for c, t in zip(cols["column_name"], cols["data_type"]):
print(f" {c}: {t}")
scalar = [c for c, t in zip(cols["column_name"], cols["data_type"])
if t not in ("jsonb", "json", "ARRAY")]
sel = ", ".join(f'"{c}"' for c in scalar) if scalar else "'(无标量列)' AS note"
df = db.read_pg(
f"SELECT {sel}, jsonb_array_length(COALESCE(members, '[]'::jsonb)) AS n_members "
f"FROM industry_pools ORDER BY n_members DESC")
print(f"\n逐池清单(共 {len(df)} 个池;标量列全给,赛道映射草案对着这个起):")
with pd.option_context("display.max_rows", None, "display.max_columns", None,
"display.width", 220, "display.max_colwidth", 80):
print(df.to_string(index=False))
keys = db.read_pg(
"SELECT k AS member_key, count(*) AS n FROM industry_pools p "
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.members, '[]'::jsonb)) m "
"CROSS JOIN LATERAL jsonb_object_keys(m) k GROUP BY k ORDER BY n DESC")
print("\n成员元素键频次(看成员级带不带环节/层级/概念字段):")
print(keys.to_string(index=False))
print(" 注(评审 §3实测成员级只有 ts_code/name/tier/confidence/supporting —— "
"**没有 segment 也没有 layer**,赛道门槛 C 的 kg_segments 映射在四视图上无源,"
"需第五/六个插槽视图或 claims 救急口径。")
smp = db.read_pg(
"SELECT m::text AS s FROM industry_pools p "
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.members, '[]'::jsonb)) m "
"LIMIT 3")
print("\n成员元素原样采样:")
for s in smp["s"]:
print(f" {s}")
# ---------------------------------------------------------------- price
def probe_price():
_sec("price · gp_day_data 按年 distinct 天数§6.2 起始日复核)")
print("(全表聚合,视服务器规格可能要 1~3 分钟……)")
df = db.read_mysql("price",
"SELECT YEAR(`timestamp`) AS y, COUNT(DISTINCT `timestamp`) AS days, "
"COUNT(*) AS n_rows FROM gp_day_data GROUP BY YEAR(`timestamp`) ORDER BY y")
print(df.to_string(index=False))
full = df[df["days"] >= 230]
if full.empty:
print("\n⚠️ 没有任何年份 ≥230 天——行情比预想稀疏§7 upside 回填深度需重议。")
else:
print(f"\n首个 ≥230 天(≈全年连续)的年份:{int(full['y'].iloc[0])} "
f"—— §7 upside 理论回填上限以此为准;之前年份视为零星记录。")
# ---------------------------------------------------------------- upside
def probe_upside():
_sec("upside · 覆盖池截面分布与 q 档位表§9-3 / §9-4")
d = db.read_mysql("price", "SELECT MAX(`timestamp`) AS d FROM gp_day_data").iloc[0, 0]
if d is None or pd.isna(d):
print("❌ gp_day_data 为空——先跑 views 看连通性。")
return
ds = pd.Timestamp(d).date().isoformat()
print(f"截面日 = 最新行情日 {ds}as-of 口径与 build_upside 完全一致)")
df = factors.build_upside(ds, ds)
n_uni = len(common.load_universe())
if df is None or df.empty:
print("❌ 当日无可算 upsideconsensus/行情缺)——先跑 views 看数据前沿。")
return
v = pd.to_numeric(df["factor_value"], errors="coerce").dropna()
print(f"覆盖池 {n_uni} 只:当日有 upside {len(v)} 只,"
f"无券商覆盖 {n_uni - len(v)} 只(按 §3.3 全被门槛②挡掉——§9-4 张力的量级)")
print("\n分位数:")
for q in (0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95):
print(f" P{int(q * 100):02d}: {float(v.quantile(q)):+.4f}")
print(f" mean : {v.mean():+.4f}(均值显著为正 = 券商乐观偏差的直读§3.3")
print(f" <0 占比: {(v < 0).mean():.1%}θ_v=0 单靠绝对下限能挡掉的比例)")
print("\nq 档位表θ_v = max(0, 池内 q 分位) → 门槛②通过只数):")
for q in (0.00, 0.10, 0.20, 0.25, 0.30, 0.40, 0.50):
theta = max(0.0, float(v.quantile(q)))
print(f" q={q:.2f} → θ_v={theta:+.4f} → 通过 {int((v >= theta).sum())}")
print("\n注:本表基于覆盖池 U 全体G2 赛道成员表落地后按 U∩C 重切一遍再定稿 q。")
# ---------------------------------------------------------------- corr
def _robust_z(v: pd.Series) -> pd.Series:
"""稳健标准化(中位数 / MAD对齐平台 MathKernel.winsorize_mad 的处置口径。"""
med = v.median()
mad = (v - med).abs().median()
if not mad or np.isnan(mad) or mad <= 0:
return pd.Series(0.0, index=v.index)
return (v - med) / (1.4826 * mad)
def _latest_heat_day(on_or_before: str) -> str | None:
"""热度是 T+1 到达的,故 akg_score(T) 实际用 T1 热度(设计 §3.4 末)。
这里如实复现该口径 <= 给定日的最新热度日"""
df = db.read_mysql(
"heat", "SELECT MAX(trade_date) AS d FROM stock_fund_heat_scores "
"WHERE trade_date <= %s", (on_or_before,))
d = df.iloc[0, 0]
return None if d is None or pd.isna(d) else pd.Timestamp(d).date().isoformat()
def probe_corr():
"""池内三项相关矩阵 + 权重体检。
回答两个问题评审 §2
corr(z_H, z_V) 有多高 > 0.5三项加权实际是两项§9-8 要重开
0.5/0.3/0.2 到底是加权混合还是传导优先直接在真实截面上数
top-K 里有几只是传导票这比任何模拟都有说服力
"""
_sec("corr · 池内 (z_T, z_H, z_V) 相关矩阵与权重体检(评审 §2")
d = db.read_pg("SELECT MAX(scan_date) AS d FROM v_factor_transmission").iloc[0, 0]
if d is None or pd.isna(d):
print("❌ 无传导台账——先确认 transmission_scan 已跑。")
return
ds = pd.Timestamp(d).date().isoformat()
hd = _latest_heat_day(ds)
print(f"截面日 = 最新传导日 {ds};热度取 {hd}T+1 到达 → 实际用 T1设计 §3.4 末)")
if hd is None:
print("❌ 无可用热度日。")
return
up = factors.build_upside(ds, ds)
ht = factors.build_heat(hd, hd)
tr = factors.build_transmission(ds, ds)
if up is None or up.empty:
print("❌ 当日无 upside无法构造候选池。")
return
def _s(df, name):
if df is None or df.empty:
return pd.Series(dtype=float, name=name)
x = df.copy()
x["k"] = x["stock_code"].map(common.to_prefix)
return (x.groupby("k")["factor_value"].max()
.astype(float).rename(name))
panel = pd.concat([_s(up, "upside"), _s(ht, "heat"), _s(tr, "transmission")],
axis=1)
n_uni = len(common.load_universe())
print(f"\n覆盖池 U = {n_uni} 只;当日面板 {len(panel)}"
f"upside {panel['upside'].notna().sum()} / "
f"heat {panel['heat'].notna().sum()} / "
f"transmission {panel['transmission'].notna().sum()}")
# ---- 候选池 PS1 只有门槛② 可算(门槛① 赛道 C 待 G2 的成员表)----
P = panel[panel["upside"].notna() & (panel["upside"] >= 0.0)].copy()
print(f"候选池 P仅门槛② upside>=0赛道门槛 C 待 G2= {len(P)}")
if len(P) < 10:
print("⚠️ 候选池过小,以下统计量意义有限。")
if P.empty:
return
# ---- 缺失处置(对齐设计 §3.4----
P["transmission"] = P["transmission"].fillna(0.0) # 传导缺 = 0取基准值
P["heat"] = P["heat"].fillna(P["heat"].median()) # 热度缺 = 池内中位数
n_tr = int((P["transmission"] > 0).sum())
print(f"P 内有传导的股票 = {n_tr} 只({n_tr / len(P):.1%}"
f" ← 这个数才是真正决定榜首的量级(评审 §7")
# ---- 三项标准化(传导不能用 MAD池内多数为 0 → MAD=0 除零,见 §3.4----
lt = np.log1p(P["transmission"])
sd = lt.std()
P["z_T"] = (lt - lt.mean()) / sd if sd and sd > 0 else 0.0
P["z_H"] = -_robust_z(P["heat"])
P["z_V"] = _robust_z(P["upside"])
print("\n① 相关矩阵Spearman池内")
# 整表 DataFrame.corr(spearman) 是 pandas 内置实现;单对 Series.corr(spearman)
# 却会 import scipy容器未装07-30 首跑即崩)——复用整表结果,零新依赖。
cm = P[["z_T", "z_H", "z_V"]].corr(method="spearman")
print(cm.round(3).to_string())
c_hv = float(cm.loc["z_H", "z_V"])
2026-07-27 09:49:05 +08:00
if abs(c_hv) > 0.5:
print(f"\n ⚠️ corr(z_H, z_V) = {c_hv:+.3f} —— 「还没热」与「便宜」高度共线,"
f"\n 「三项加权」实际是两项§9-8 的权重讨论应重开"
f"(且门槛② 与 z_V 本就是同一变量进两次)。")
else:
print(f"\n corr(z_H, z_V) = {c_hv:+.3f} —— 共线性可接受,三项各自有独立信息。")
# ---- ② 权重体检0.5/0.3/0.2 是混合还是词典序?----
print("\n② 权重体检:不同 w_T 下 top-K 里有几只是传导票")
print(f" P 内共 {n_tr} 只传导票 / {len(P)} 只候选)")
if n_tr == 0:
print(" 当日无传导票,本节跳过。")
else:
hdr = " w_T " + "".join(f" top{k:<3}" for k in (10, 20, 30, 50))
print(hdr)
for wT in (0.50, 0.30, 0.20, 0.15, 0.10):
wrest = 1.0 - wT
s = wT * P["z_T"] + wrest * (0.6 * P["z_H"] + 0.4 * P["z_V"])
line = f" {wT:.2f} "
for k in (10, 20, 30, 50):
top = s.nlargest(min(k, len(P))).index
hit = int((P.loc[top, "transmission"] > 0).sum())
line += f" {hit:>2}/{min(k, n_tr):<3}"
print(line + (" ← 当前设计" if abs(wT - 0.50) < 1e-9 else ""))
print("\n 读法:若 w_T=0.50 那行的命中数≈min(K, 传导票数),说明传导票"
"\n 几乎必然占满前列 —— 0.3/0.2 只在组内排序、跨组不起作用,"
"\n 即当前参数事实上是「传导优先」而非「加权混合」(评审 §2"
"\n 若确实如此,建议改结构(两段式)而不是改数值。")
# 两段式对照primary=传导档位, tiebreak=0.6z_H+0.4z_V
tier = pd.Series(0.0, index=P.index)
hit_m = P["transmission"] > 0
if hit_m.any():
med = lt[hit_m].median()
tier[hit_m] = np.where(lt[hit_m] >= med, 2.0, 1.0)
s2 = tier * 10.0 + (0.6 * P["z_H"] + 0.4 * P["z_V"])
agree = len(set(s2.nlargest(min(20, len(P))).index)
& set((0.5 * P["z_T"] + 0.3 * P["z_H"] + 0.2 * P["z_V"])
.nlargest(min(20, len(P))).index))
print(f"\n 两段式(档位+组内)与当前公式的 top20 重合度:{agree}/20"
f" —— 越接近 20 越说明两者本就等价,改结构零代价。")
print("\n③ 门槛的量级(供 §9-3 定 q / 判断 C 的必要性):")
print(f" U={n_uni} → 有 upside {int(panel['upside'].notna().sum())} "
f"→ 过门槛② {len(P)} → 其中有传导 {n_tr}")
print(" 若最后一个数长期是 0~3组合层面就是 1~3 只票,且来自被 12×12 截断的"
"\n 候选列表——集中度需要在文档里明写(评审 §7")
SECTIONS = {"pools": probe_pools, "price": probe_price,
"upside": probe_upside, "corr": probe_corr}
def run(section="all"):
picked = list(SECTIONS) if section == "all" else [section]
print("G1 体检(只读)· 将跑节:" + ", ".join(picked))
for name in picked:
try:
SECTIONS[name]()
except Exception as e: # noqa: BLE001 —— 单节失败不拖累其余(体检尽量多产出)
print(f"\n❌ [{name}] 失败: {e!r}")