akg-factor-bridge/plan_reconcile.py

356 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""选股计划逐票对账(只读)——"这只票为什么在/不在计划里"
背景:机会线索页(基座前端,环节级雷达,跑全部主题池)与每日选股计划
(个股级,券商覆盖 ∩ upside≥0 ∩ 十条赛道 ∩ 非风险,传导档优先排序)
答的不是同一个问题,读数经常"看起来大相径庭"。本工具把任意一只票的
全部判定摊开:赛道命中(哪路)、覆盖与 upside、热度、传导谁指向它
gate、score、榜位名次落榜给出确切原因——审计承诺下游对接文档
"每只候选可回溯")的落地件。
跑法【桥机 factorevaluation-UTC · ~/akg-factor-bridge】
docker compose exec -T akg-factor-bridge python plan_reconcile.py 300750 SH600438 002074.SZ
docker compose exec -T akg-factor-bridge python plan_reconcile.py # 不带票=打印当日主榜前20/观察档前10
docker compose exec -T akg-factor-bridge python plan_reconcile.py --date 2026-08-04 300750
代码三种形态都收600000.SH / SH600000 / 600000600/688 归 SH其余归 SZ
纯只读:因子表 / 基座视图 / yml 全部 SELECT 与本地解析,不写任何东西。
"""
from __future__ import annotations
import re
import sys
import pandas as pd
import common
import db
import tracks
_RISK_RE = re.compile(r"^(\*?S?ST|退市)")
def _norm_code(s: str) -> str:
"""任意形态 -> 前缀式SH600000。裸 6 位码按交易所惯例补前缀。"""
s = s.strip().upper()
if re.fullmatch(r"\d{6}\.(SH|SZ|BJ)", s):
return common.to_prefix(s)
if re.fullmatch(r"(SH|SZ|BJ)\d{6}", s):
return s
if re.fullmatch(r"\d{6}", s):
exch = "SH" if s[0] == "6" else ("BJ" if s[0] in "48" else "SZ")
return f"{exch}{s}"
raise SystemExit(f"认不出的代码形态: {s!r}(收 600000.SH / SH600000 / 600000")
def _factor(table: str, d: str) -> dict[str, float]:
df = db.read_mysql("factor", f"SELECT stock_code, factor_value FROM {table} "
f"WHERE trade_date=%s", (d,))
return {str(r.stock_code).strip(): float(r.factor_value) for r in df.itertuples()}
def _load(d: str):
gate = _factor("t_factor_akg_gate", d)
score = _factor("t_factor_akg_score", d)
upside = _factor("t_factor_akg_upside", d)
heat = _factor("t_factor_akg_heat", d)
tr = _factor("t_factor_akg_transmission", d)
# 赛道命中:前缀码 -> [(赛道, 命中键, 路)]图谱路在前resolve 已排好)
tdf, _missing = tracks.resolve_members(only_confirmed=True, dedup=False)
hits: dict[str, list[tuple[str, str, str]]] = {}
for r in tdf.itertuples():
hits.setdefault(common.to_prefix(str(r.ts_code)), []).append(
(r.track, r.theme, r.source_rule))
# 按锚强度排序(环节锚 > 链锚 > 主题弱锚。resolve_members 是按 yml 里
# 赛道的先后顺序产行的,不排序的话 hits[0] 取到的是"yml 中最靠前的赛道"
# 而不是"最强的锚"——08-05 实测宁德时代显示成算力、国轩高科显示成通信,
# 就是这么来的:真凭据在后面,前面那条是宽主题捞进来的。勿回退。
_RULE_RANK = {"graph_segment": 0, "graph_chain": 1, "pool_theme": 2}
for k in hits:
hits[k].sort(key=lambda h: _RULE_RANK.get(h[2], 9))
# 传导指向scan_date=d 的候选里谁的 quiet 含这只票
try:
tv = db.read_pg(
"SELECT ts_code, target, n_sources, moved_ratio, mkt_trade_date "
"FROM v_factor_transmission WHERE scan_date=%s", (d,))
except Exception as e: # noqa: BLE001 —— 视图不可用只影响传导明细
print(f"⚠️ v_factor_transmission 读取失败(传导明细缺席): {e!r}")
tv = pd.DataFrame(columns=["ts_code", "target", "n_sources",
"moved_ratio", "mkt_trade_date"])
tmap: dict[str, list[str]] = {}
for r in tv.itertuples():
tmap.setdefault(common.to_prefix(str(r.ts_code)), []).append(
f"{r.target}(源{int(r.n_sources)}×空间{1 - float(r.moved_ratio):.0%})")
stale = ""
if len(tv) and tv["mkt_trade_date"].notna().any():
md = str(tv["mkt_trade_date"].dropna().iloc[0])[:10]
if md != d:
stale = f"(⚠️ 传导快照日 {md} ≠ 档位日 {d},该日传导降级采信)"
# 图谱证据 + 简称
ev: dict[str, dict] = {}
try:
mem = db.read_pg("SELECT segment_name, ts_code, chain "
"FROM v_factor_segment_members WHERE ts_code IS NOT NULL")
for r in mem.itertuples():
k = common.to_prefix(str(r.ts_code).strip())
e = ev.setdefault(k, {"segs": set(), "chains": set()})
e["segs"].add(str(r.segment_name))
if r.chain and str(r.chain).strip():
e["chains"].add(str(r.chain).strip())
except Exception as e: # noqa: BLE001
print(f"⚠️ 环节投影读取失败(图谱证据缺席): {e!r}")
names: dict[str, str] = {}
try:
import json as _json
pools = db.read_pg("SELECT members FROM industry_pools")
for _, row in pools.iterrows():
ms = row["members"]
if isinstance(ms, str):
ms = _json.loads(ms)
for m in ms or []:
if (m or {}).get("ts_code"):
names.setdefault(common.to_prefix(m["ts_code"]), m.get("name") or "")
except Exception as e: # noqa: BLE001
print(f"⚠️ 简称加载失败: {e!r}")
# 榜位名次150 是主榜与观察档的分界,**下方不设界**——
# 观察档分 = 100 + clip(组内分, ±9.9),区间是 [90.1, 109.9]
# 首版写 sc>=100 把 [90.1,100) 那半截整段漏掉08-05 实测md 说 675、
# 工具只数出 232差 443 只。score 表本就只有 gate>0 的票出行,
# 低于 150 的一律是观察档,不需要下界。勿回退。
sc = pd.Series(score)
main = sc[sc >= 150].sort_values(ascending=False)
obs = sc[sc < 150].sort_values(ascending=False)
rank_main = {k: i + 1 for i, k in enumerate(main.index)}
rank_obs = {k: i + 1 for i, k in enumerate(obs.index)}
return dict(gate=gate, score=score, upside=upside, heat=heat, tr=tr,
hits=hits, tmap=tmap, stale=stale, ev=ev, names=names,
main=main, obs=obs, rank_main=rank_main, rank_obs=rank_obs)
def _fmt(v, pat="{:.2f}"):
return "" if v is None else pat.format(v)
def latest_date() -> str | None:
"""当日档位日 = t_factor_akg_gate 的 MAX(trade_date);空表返回 None。只读。"""
row = db.read_mysql("factor", "SELECT MAX(trade_date) d FROM t_factor_akg_gate")
v = None if row.empty else row.iloc[0, 0]
if v is None or pd.isna(v):
return None
return pd.Timestamp(v).date().isoformat()
def verdict(k: str, L: dict) -> dict:
"""把 explain() 的『判决』拆成结构化只读结果(供 /plan/verdict、今日页调用
不打印、不写库。`verdict_text` 与命令行 `explain()`『▶ 判决:』之后的文案
逐字一致——页面上的一行解释与命令行对账因此完全同口径explain 现在也复用本函数)。
decision 取值main主榜/ observe观察档/ reject不采纳gate=0/ absent当日档位表无此票
"""
nm = L["names"].get(k, "")
g = L["gate"].get(k)
up = L["upside"].get(k)
ht = L["heat"].get(k)
trv = L["tr"].get(k)
sc = L["score"].get(k)
hits = L["hits"].get(k, [])
paths = L["tmap"].get(k, [])
e = L["ev"].get(k, {"segs": set(), "chains": set()})
risk = bool(_RISK_RE.match(nm.replace(" ", ""))) if nm else False
decision = tier = reason = None
rank = rank_total = None
if g == 2.0:
decision = "main"
rank = L["rank_main"].get(k)
rank_total = len(L["main"])
# 档界score=200+档×20+组内分(±9.9) → 强≥230.1、弱≥210.1、无≤209.9
tier = "强传导" if sc and sc >= 230 else ("弱传导" if sc and sc >= 210 else "无传导")
text = (f"主榜第 {rank}/{rank_total}score={sc:.1f}{tier}档)"
+ ("——计划默认只显示前 20名次靠后不等于不在计划里"
if rank and rank > 20 else ""))
elif g == 1.0:
decision = "observe"
rank = L["rank_obs"].get(k)
rank_total = len(L["obs"])
text = (f"观察档第 {rank}/{rank_total}score={sc:.1f}"
f"无券商覆盖、低置信)")
elif g == 0.0:
decision = "reject"
import config as _cfg
_tol = float(getattr(_cfg, "UPSIDE_NEG_TOLERANCE", 0.0) or 0.0)
if risk:
reason = "重大风险闸ST/退市族07-31 拍板默认杜绝"
elif up is not None and up < -_tol:
reason = (f"有覆盖但 upside={up:+.1%} < {-_tol:+.0%}——贵了不买是绝对下限"
+ ("" if _tol == 0 else f"(负容忍 {_tol:.0%} 已启用仍不够)"))
elif up is not None and hits:
reason = "覆盖、upside、赛道三者都过——按机制不该是 0把本行发我核疑似口径错位"
elif up is not None:
reason = ("有覆盖、upside≥0但不在十条赛道赛道闸拦下"
+ ("——图上有环节证据,属映射够不着的错杀池,看首批锚能否接住"
if e["segs"] else ""))
elif not hits and (trv or 0) <= 0:
reason = "无覆盖、无传导、不在赛道——两锚皆无"
else:
reason = "无覆盖且当日不在传导链上(观察档条件差最后一步)"
text = f"不采纳gate=0。原因{reason}"
else:
decision = "absent"
text = ("当日档位表无此票"
+ ("(可能不在覆盖池 universe或当日未出行" if not hits
else "——在赛道却无档位行,把本行发我核"))
return {
"code": k,
"name": nm,
"gate": g,
"decision": decision,
"verdict_text": text,
"reason": reason,
"score": (round(float(sc), 1) if sc is not None else None),
"rank": rank,
"rank_total": rank_total,
"tier": tier,
"upside": (round(float(up), 4) if up is not None else None),
"heat": (round(float(ht), 2) if ht is not None else None),
"transmission": (round(float(trv), 2) if trv is not None else None),
"tracks": [{"track": t, "key": key, "rule": rule}
for t, key, rule in hits],
"transmission_targets": list(paths),
"graph_segments": sorted(e["segs"]),
"graph_chains": sorted(e["chains"]),
"risk": risk,
}
def explain(k: str, L: dict) -> None:
"""CLI 打印:明细(赛道/覆盖/传导/图谱)+ 判决行。判决行复用 verdict(),与 API 同口径。"""
nm = L["names"].get(k, "")
up = L["upside"].get(k)
ht = L["heat"].get(k)
trv = L["tr"].get(k)
hits = L["hits"].get(k, [])
paths = L["tmap"].get(k, [])
e = L["ev"].get(k, {"segs": set(), "chains": set()})
print(f"\n{k} {nm}")
if hits:
shown = "".join(f"{t}{key}({rule})" for t, key, rule in hits[:4])
print(f" 赛道命中 {len(hits)} 路:{shown}" + ("" if len(hits) > 4 else ""))
else:
print(" 赛道命中:无(十条赛道三路映射都够不着)")
print(f" 覆盖/upside{'有覆盖upside=' + _fmt(up, '{:+.1%}') if up is not None else '无券商覆盖'}"
f" 热度:{_fmt(ht)} 传导分:{_fmt(trv)}")
if paths:
print(f" 传导指向:{''.join(paths[:4])}" + ("" if len(paths) > 4 else ""))
if e["segs"]:
print(f" 图谱证据:环节 {len(e['segs'])} 个({''.join(sorted(e['segs'])[:4])}"
+ ("" if len(e["segs"]) > 4 else "") + ""
+ (f",链名 {''.join(sorted(e['chains'])[:4])}" if e["chains"] else ",边上无链名"))
print(f" ▶ 判决:{verdict(k, L)['verdict_text']}")
def audit(L: dict) -> None:
"""锚质量审计(--audit主榜成员里有多少是靠宽主题弱锚进来的。
主题弱锚pool_theme是 industry_pools 的概念池成员,池宽则噪声大——
08-05 实测:宁德时代记成算力、国轩高科记成通信、东方日升记成商业航空航天。
档位判定本身不看是哪路锚(在赛道就算数),所以噪声不改变对错、只改变
"凭什么";但宽主题同时也在**放人进主榜**,这才是要拍板的地方。
本审计回答两问:逐赛道的主榜成员有多少带可审计的图谱锚;
哪些主题键在独家放人(该股在所有赛道都没有图谱锚)——它们就是下一批
"降级或剔除"的候选。链锚扩充落地后重跑本审计,看图谱锚占比抬升多少。"""
main_set = {k for k, g in L["gate"].items() if g == 2.0}
by_track: dict[str, dict[str, set]] = {}
theme_only_admits: dict[str, set] = {}
n_graph = 0
for k in main_set:
hs = L["hits"].get(k, [])
if not hs:
continue
has_graph_any = any(r != "pool_theme" for _, _, r in hs)
if has_graph_any:
n_graph += 1
for t, key, rule in hs:
d = by_track.setdefault(t, {"all": set(), "graph": set()})
d["all"].add(k)
if rule != "pool_theme":
d["graph"].add(k)
if rule == "pool_theme" and not has_graph_any:
theme_only_admits.setdefault(key, set()).add(k)
n_in_track = sum(1 for k in main_set if L["hits"].get(k))
print(f"\n—— 锚质量审计:主榜 {len(main_set)} 只,其中 {n_in_track} 只在赛道内 ——")
print(f" 带图谱锚(环节/链,可审计到图谱边): {n_graph}"
f"{n_graph / max(1, n_in_track):.0%}"
f"仅靠主题弱锚: {n_in_track - n_graph}")
print("\n 逐赛道(成员数 / 其中带图谱锚):")
for t, d in sorted(by_track.items(), key=lambda kv: -len(kv[1]["all"])):
print(f" {t:<8} {len(d['all']):>4} / {len(d['graph']):>4}"
f"{len(d['graph']) / max(1, len(d['all'])):.0%}")
print("\n 独家放人的主题键前 20这些股在所有赛道都没有图谱锚"
"只靠该主题进的主榜——降级候选):")
for key, ks in sorted(theme_only_admits.items(),
key=lambda kv: -len(kv[1]))[:20]:
sample = "".join(L["names"].get(x, x) for x in sorted(ks)[:3])
print(f" {key:<16} {len(ks):>4} 只 例:{sample}")
def main() -> int:
args = [a for a in sys.argv[1:]]
do_audit = "--audit" in args
if do_audit:
args.remove("--audit")
d = None
if "--date" in args:
i = args.index("--date")
d = args[i + 1]
del args[i:i + 2]
if d is None:
row = db.read_mysql("factor", "SELECT MAX(trade_date) d FROM t_factor_akg_gate")
v = None if row.empty else row.iloc[0, 0]
if v is None or pd.isna(v):
print("档位表为空——先跑当日构建。")
return 2
d = pd.Timestamp(v).date().isoformat()
L = _load(d)
print(f"选股计划对账 @ 档位日 {d}{L['stale']}")
print(f"主榜 {len(L['main'])} 只 / 观察档 {len(L['obs'])}150 分界;主榜默认展示前 20")
if do_audit:
audit(L)
if args:
for a in args:
explain(_norm_code(a), L)
elif not do_audit:
print("\n—— 主榜前 20传导档优先与计划 API 同序;本清单不设每主题限额,"
"计划 API 默认 theme_cap=5 故名单会略有出入)——")
for i, (k, s) in enumerate(L["main"].head(20).items(), 1):
hits = L["hits"].get(k, [])
anchor = "?"
if hits:
t, key, rule = hits[0]
anchor = f"{t}{key}" + ("(弱)" if rule == "pool_theme" else "(图谱)")
print(f" {i:>2}. {k} {L['names'].get(k, ''): <6} score={s:.1f} "
f"upside={_fmt(L['upside'].get(k), '{:+.1%}')} "
f"热度={_fmt(L['heat'].get(k))} "
f"赛道={anchor}"
+ (f" 传导指向={L['tmap'][k][0]}" if L["tmap"].get(k) else ""))
print("\n—— 观察档前 10 ——")
for i, (k, s) in enumerate(L["obs"].head(10).items(), 1):
print(f" {i:>2}. {k} {L['names'].get(k, ''): <6} score={s:.1f} "
f"热度={_fmt(L['heat'].get(k))}"
+ (f" 传导指向={L['tmap'][k][0]}" if L["tmap"].get(k) else ""))
print("\n用法:把机会线索卡片上的代表公司码传进来逐票对账,"
"例如 python plan_reconcile.py 300750 002074 688041")
return 0
if __name__ == "__main__":
raise SystemExit(main())