akg-factor-bridge/probe.py

274 lines
14 KiB
Python
Raw 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.

"""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"])
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}")