2026-07-30 12:01:11 +08:00
|
|
|
|
"""赛道映射(硬门槛 C 的实现载体,设计 §3.2)。
|
|
|
|
|
|
|
2026-07-30 14:45:15 +08:00
|
|
|
|
config/frontier_tracks.yml 是唯一事实源,三条映射路径并行:
|
|
|
|
|
|
kg_themes 主题名 → industry_pools(弱锚 fallback,source_rule=pool_theme)
|
|
|
|
|
|
kg_chains 链名 → 图谱 IN_SEGMENT 边的产业链修饰符(强锚,graph_chain)
|
|
|
|
|
|
kg_segments 环节名 → 图谱环节投影(强锚,graph_segment)
|
|
|
|
|
|
后两条读基座第五插槽视图 v_factor_segment_members(akg.project_segments
|
|
|
|
|
|
每晨 06:10 刷新)。同一只股票多路命中时**图谱路径优先记账**——这就是设计
|
|
|
|
|
|
文档"两级 source_rule:graph 强 / fallback 弱"的落地。成员表快照落
|
|
|
|
|
|
data/track_members_<日期>.csv,每行带 source_rule,可审计到入选依据。
|
2026-07-30 12:01:11 +08:00
|
|
|
|
"""
|
|
|
|
|
|
import datetime as dt
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
import config
|
|
|
|
|
|
import db
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
except ImportError: # 镜像未装 PyYAML 时给出可执行的修复指令,而不是裸崩
|
|
|
|
|
|
yaml = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _need_yaml():
|
|
|
|
|
|
if yaml is None:
|
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
|
"缺 PyYAML:requirements.txt 已加,请在桥机重建镜像——\n"
|
|
|
|
|
|
" docker compose build akg-factor-bridge && "
|
|
|
|
|
|
"docker compose up -d akg-factor-bridge")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_yml(path: str | None = None) -> dict:
|
|
|
|
|
|
_need_yaml()
|
|
|
|
|
|
with open(path or config.TRACKS_YML, "r", encoding="utf-8") as f:
|
|
|
|
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:45:15 +08:00
|
|
|
|
def _pool_theme_members() -> dict:
|
|
|
|
|
|
"""industry_pools 当前态:主题名 → 成员列表(dict,含 ts_code/name)。"""
|
2026-07-30 12:01:11 +08:00
|
|
|
|
pools = db.read_pg("SELECT theme, members FROM industry_pools")
|
|
|
|
|
|
by_theme = {}
|
|
|
|
|
|
for _, r in pools.iterrows():
|
|
|
|
|
|
ms = r["members"]
|
|
|
|
|
|
if isinstance(ms, str):
|
|
|
|
|
|
ms = json.loads(ms)
|
|
|
|
|
|
by_theme[str(r["theme"]).strip()] = ms or []
|
2026-07-30 14:45:15 +08:00
|
|
|
|
return by_theme
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _graph_members():
|
|
|
|
|
|
"""环节投影(第五插槽)→ 两个索引:环节名→成员、链名→成员(仅上市成员)。
|
|
|
|
|
|
|
|
|
|
|
|
视图不可用(投影表未建/基座不可达)时返回空索引并提示——图谱路径跳过,
|
|
|
|
|
|
主题路径照常工作,不挡任何调用方。空串链名(抽取未写修饰的边)在此忽略。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
df = db.read_pg(
|
|
|
|
|
|
"SELECT segment_name, ts_code, member_name, chain "
|
|
|
|
|
|
"FROM v_factor_segment_members WHERE ts_code IS NOT NULL")
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
print(f" (环节投影视图不可用,图谱映射路径跳过: {e!r})")
|
|
|
|
|
|
return {}, {}
|
|
|
|
|
|
by_seg, by_chain = {}, {}
|
|
|
|
|
|
for r in df.itertuples():
|
|
|
|
|
|
m = (str(r.ts_code), None if pd.isna(r.member_name) else str(r.member_name))
|
|
|
|
|
|
by_seg.setdefault(str(r.segment_name).strip(), []).append(m)
|
|
|
|
|
|
if r.chain is not None and not pd.isna(r.chain) and str(r.chain).strip():
|
|
|
|
|
|
by_chain.setdefault(str(r.chain).strip(), []).append(m)
|
|
|
|
|
|
return by_seg, by_chain
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_members(only_confirmed: bool = True, path: str | None = None,
|
|
|
|
|
|
dedup: bool = True):
|
|
|
|
|
|
"""yml + 三路数据源 → (成员表, 未命中键清单)。
|
|
|
|
|
|
|
|
|
|
|
|
成员表列:ts_code, name, track, status, theme, source_rule——theme 列放
|
|
|
|
|
|
命中它的那个键(主题名/链名/环节名)。图谱路径的行排在主题路径之前,
|
|
|
|
|
|
去重时同股同赛道保留最先出现的行 ⇒ 图谱锚优先记账。
|
|
|
|
|
|
未命中 = yml 里写了、数据源里查无此键,元素为 (赛道, 键类型, 键名)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
d = load_yml(path)
|
|
|
|
|
|
by_theme = _pool_theme_members()
|
|
|
|
|
|
by_seg, by_chain = _graph_members()
|
2026-07-30 12:01:11 +08:00
|
|
|
|
excl = {str(x).strip() for x in (d.get("exclude_themes") or [])}
|
|
|
|
|
|
rows, missing = [], []
|
|
|
|
|
|
for tr in d.get("tracks") or []:
|
|
|
|
|
|
if only_confirmed and tr.get("status") != "confirmed":
|
|
|
|
|
|
continue
|
2026-07-30 14:45:15 +08:00
|
|
|
|
tname, status = tr["name"], tr.get("status")
|
|
|
|
|
|
for seg in tr.get("kg_segments") or []: # 图谱路径(强锚)在前
|
|
|
|
|
|
s = str(seg).strip()
|
|
|
|
|
|
if s not in by_seg:
|
|
|
|
|
|
missing.append((tname, "环节", s))
|
|
|
|
|
|
continue
|
|
|
|
|
|
for ts, nm in by_seg[s]:
|
|
|
|
|
|
rows.append((ts, nm, tname, status, s, "graph_segment"))
|
|
|
|
|
|
for ch in tr.get("kg_chains") or []:
|
|
|
|
|
|
c = str(ch).strip()
|
|
|
|
|
|
if c not in by_chain:
|
|
|
|
|
|
missing.append((tname, "链", c))
|
|
|
|
|
|
continue
|
|
|
|
|
|
for ts, nm in by_chain[c]:
|
|
|
|
|
|
rows.append((ts, nm, tname, status, c, "graph_chain"))
|
|
|
|
|
|
for theme in tr.get("kg_themes") or []: # 主题路径(fallback)在后
|
2026-07-30 12:01:11 +08:00
|
|
|
|
t = str(theme).strip()
|
|
|
|
|
|
if t in excl:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if t not in by_theme:
|
2026-07-30 14:45:15 +08:00
|
|
|
|
missing.append((tname, "主题", t))
|
2026-07-30 12:01:11 +08:00
|
|
|
|
continue
|
|
|
|
|
|
for m in by_theme[t]:
|
|
|
|
|
|
ts = (m or {}).get("ts_code")
|
|
|
|
|
|
if ts:
|
2026-07-30 14:45:15 +08:00
|
|
|
|
rows.append((ts, m.get("name"), tname, status, t, "pool_theme"))
|
|
|
|
|
|
df = pd.DataFrame(rows, columns=["ts_code", "name", "track", "status",
|
|
|
|
|
|
"theme", "source_rule"])
|
2026-07-30 12:44:56 +08:00
|
|
|
|
if dedup:
|
|
|
|
|
|
df = df.drop_duplicates(["ts_code", "track"])
|
2026-07-30 12:01:11 +08:00
|
|
|
|
return df, missing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def snapshot(only_confirmed: bool = True, path: str | None = None):
|
2026-07-30 14:45:15 +08:00
|
|
|
|
"""成员表落 data/ 版本化快照(每行带 source_rule 与 updated_at,审计列)。"""
|
2026-07-30 12:01:11 +08:00
|
|
|
|
df, missing = resolve_members(only_confirmed, path)
|
|
|
|
|
|
os.makedirs("data", exist_ok=True)
|
|
|
|
|
|
out = f"data/track_members_{dt.date.today().isoformat()}.csv"
|
2026-07-30 14:45:15 +08:00
|
|
|
|
(df.assign(layer="",
|
2026-07-30 12:01:11 +08:00
|
|
|
|
updated_at=dt.datetime.now().isoformat(timespec="seconds"))
|
|
|
|
|
|
.to_csv(out, index=False))
|
|
|
|
|
|
return out, df, missing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def coverage_report(path: str | None = None) -> pd.DataFrame:
|
2026-07-30 14:45:15 +08:00
|
|
|
|
"""赛道覆盖体检:逐赛道给出成员数(含图谱锚数)、各路径命中率,含 candidate。"""
|
|
|
|
|
|
# dedup=False:命中率要在去重前数——成员完全被同赛道更早键覆盖的键,
|
|
|
|
|
|
# 去重后一行不剩,会被误计为"没命中"(07-30 实测教训)。
|
2026-07-30 12:44:56 +08:00
|
|
|
|
df, missing = resolve_members(only_confirmed=False, path=path, dedup=False)
|
2026-07-30 12:01:11 +08:00
|
|
|
|
d = load_yml(path)
|
2026-07-30 14:45:15 +08:00
|
|
|
|
print("赛道覆盖体检(主题=industry_pools 当前态;图谱=环节投影;"
|
|
|
|
|
|
"成员数已去重,命中率按去重前算):")
|
2026-07-30 12:01:11 +08:00
|
|
|
|
for tr in d.get("tracks") or []:
|
|
|
|
|
|
sub = df[df["track"] == tr["name"]]
|
2026-07-30 14:45:15 +08:00
|
|
|
|
graph_sub = sub[sub["source_rule"] != "pool_theme"]
|
|
|
|
|
|
n_all = sub["ts_code"].nunique()
|
|
|
|
|
|
n_graph = graph_sub["ts_code"].nunique()
|
|
|
|
|
|
n_tkeys = len(tr.get("kg_themes") or [])
|
|
|
|
|
|
hit_t = sub[sub["source_rule"] == "pool_theme"]["theme"].nunique()
|
|
|
|
|
|
n_gkeys = (len(tr.get("kg_segments") or [])
|
|
|
|
|
|
+ len(tr.get("kg_chains") or []))
|
|
|
|
|
|
hit_g = graph_sub["theme"].nunique()
|
|
|
|
|
|
miss = [f"{kind}:{key}" for name, kind, key in missing
|
|
|
|
|
|
if name == tr["name"]]
|
2026-07-30 12:01:11 +08:00
|
|
|
|
tag = "" if tr.get("status") == "confirmed" else "(candidate)"
|
2026-07-30 14:45:15 +08:00
|
|
|
|
line = (f" {tr['name']}{tag}: 成员 {n_all} 只(图谱锚 {n_graph})"
|
|
|
|
|
|
f" | 主题命中 {hit_t}/{n_tkeys}")
|
|
|
|
|
|
if n_gkeys:
|
|
|
|
|
|
line += f" | 图谱键命中 {hit_g}/{n_gkeys}"
|
2026-07-30 12:01:11 +08:00
|
|
|
|
if miss:
|
|
|
|
|
|
line += f" | 未命中: {'、'.join(miss)}"
|
|
|
|
|
|
print(line)
|
|
|
|
|
|
conf = df[df["status"] == "confirmed"]
|
2026-07-30 14:45:15 +08:00
|
|
|
|
n_conf = conf["ts_code"].nunique()
|
|
|
|
|
|
n_conf_graph = conf[conf["source_rule"] != "pool_theme"]["ts_code"].nunique()
|
|
|
|
|
|
print(f" —— confirmed 合计(去重): {n_conf} 只,其中带图谱锚 {n_conf_graph} 只")
|
2026-07-30 12:01:11 +08:00
|
|
|
|
return df
|