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("❌ 当日无可算 upside(consensus/行情缺)——先跑 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) 实际用 T−1 热度(设计 §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 到达 → 实际用 T−1,设计 §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()})")
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 候选池 P:S1 只有门槛② 可算(门槛① 赛道 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,池内):")
|
2026-07-30 10:00:55 +08:00
|
|
|
|
# 整表 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}")
|