产业链细化逻辑

This commit is contained in:
zlt 2026-08-05 11:56:25 +08:00
parent 5c3efbff94
commit f7ea196545
1 changed files with 222 additions and 0 deletions

222
plan_reconcile.py Normal file
View File

@ -0,0 +1,222 @@
"""选股计划逐票对账(只读)——"这只票为什么在/不在计划里"
背景机会线索页基座前端环节级雷达跑全部主题池与每日选股计划
个股级券商覆盖 upside0 十条赛道 非风险传导档优先排序
答的不是同一个问题读数经常"看起来大相径庭"本工具把任意一只票的
全部判定摊开赛道命中哪路覆盖与 upside热度传导谁指向它
gatescore榜位名次落榜给出确切原因审计承诺下游对接文档
"每只候选可回溯"的落地件
跑法桥机 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))
# 传导指向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}")
# 榜位名次:主榜=score>=150 降序;观察档=100<=score<150 降序
sc = pd.Series(score)
main = sc[sc >= 150].sort_values(ascending=False)
obs = sc[(sc >= 100) & (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 explain(k: str, L: dict) -> None:
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
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 ",边上无链名"))
if g == 2.0:
n = L["rank_main"].get(k)
# 档界score=200+档×20+组内分(±9.9) → 强≥230.1、弱≥210.1、无≤209.9
tier = "强传导" if sc and sc >= 230 else ("弱传导" if sc and sc >= 210 else "无传导")
print(f" ▶ 判决:主榜第 {n}/{len(L['main'])}score={sc:.1f}{tier}档)"
+ ("——计划默认只显示前 20名次靠后不等于不在计划里" if n and n > 20 else ""))
elif g == 1.0:
print(f" ▶ 判决:观察档第 {L['rank_obs'].get(k)}/{len(L['obs'])}score={sc:.1f}"
f"无券商覆盖、低置信)")
elif g == 0.0:
if risk:
reason = "重大风险闸ST/退市族07-31 拍板默认杜绝"
elif up is not None and up < 0:
reason = f"有覆盖但 upside={up:+.1%} < 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 = "无覆盖且当日不在传导链上(观察档条件差最后一步)"
print(f" ▶ 判决不采纳gate=0。原因{reason}")
else:
print(" ▶ 判决:当日档位表无此票"
+ ("(可能不在覆盖池 universe或当日未出行" if not hits else "——在赛道却无档位行,把本行发我核"))
def main() -> int:
args = [a for a in sys.argv[1:]]
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 args:
for a in args:
explain(_norm_code(a), L)
else:
print("\n—— 主榜前 20传导档优先与计划 API 同序)——")
for i, (k, s) in enumerate(L["main"].head(20).items(), 1):
hits = L["hits"].get(k, [])
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"赛道={hits[0][0] if hits else '?'}"
+ (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())