diff --git a/plan.py b/plan.py index c2c89f4..d83a933 100644 --- a/plan.py +++ b/plan.py @@ -94,7 +94,27 @@ def _num(v) -> str: return "—" if v is None or pd.isna(v) else f"{v:.2f}" -def generate(date: str | None = None, top: int = 20, obs_top: int = 10) -> str: +def _pick(ranked: pd.Series, ev: dict, top: int, theme_cap: int): + """按分数从高到低取 top 条;每个传导主题最多 theme_cap 条(0=不设限)。 + + 为什么设限:传导目标是环节级,同环节全体成员共享同一条证据,不设限时 + 榜单会被三四个环节刷屏——20 个名额实际只是 4 注。限额后变成 + "多条线索 × 每条线索取最冷最便宜的几只",被挤掉的仍在完整档位表里。""" + out, cnt = [], {} + for k, s in ranked.items(): + e = ev.get(k) + theme = e[0] if e else "(无传导)" + if theme_cap and cnt.get(theme, 0) >= theme_cap: + continue + cnt[theme] = cnt.get(theme, 0) + 1 + out.append((k, s)) + if len(out) >= top: + break + return out + + +def generate(date: str | None = None, top: int = 20, obs_top: int = 10, + theme_cap: int = 5) -> str: ds = date or _latest_date("t_factor_akg_score") if not ds: raise SystemExit("t_factor_akg_score 还没有数据——先 build akg_score。") @@ -103,6 +123,15 @@ def generate(date: str | None = None, top: int = 20, obs_top: int = 10) -> str: if score.empty or gate.empty: raise SystemExit(f"{ds} 缺 akg_score / akg_gate——先 build 该日再出计划。") upside = _factor("t_factor_akg_upside", ds) + if upside.empty: + # 晚间 18:40 建当日 upside 表时行情源(~19:50 发布)还没到,当日表常为空。 + # 这里现算兜底:此刻库里已有晚到的当日价,as-of 口径不变(consensus<=当日)。 + import factors + df_up = factors.build_upside(ds, ds) + if df_up is not None and not df_up.empty: + x = df_up.copy() + x["k"] = x["stock_code"].map(common.to_prefix) + upside = x.groupby("k")["factor_value"].max().astype(float) hd = _latest_date("t_factor_akg_heat", ds) heat = _factor("t_factor_akg_heat", hd) if hd else pd.Series(dtype=float) names = _names() @@ -119,23 +148,26 @@ def generate(date: str | None = None, top: int = 20, obs_top: int = 10) -> str: f"\"谁已经动了\"看的是上个交易日收盘;拍点方案定版前均如此)。") L.append("") - L.append(f"## 主榜 Top {min(top, len(main))}(有券商预期、目标价不低于现价)") + cap_txt = f",每主题限额 {theme_cap}" if theme_cap else "" + main_rows = _pick(main, ev, top, theme_cap) + L.append(f"## 主榜 Top {len(main_rows)}(有券商预期、目标价不低于现价{cap_txt})") L.append("") L.append("| # | 代码 | 名称 | 总分 | 档位 | 传导证据 | 热度 | 预期空间 |") L.append("|---|------|------|------|------|----------|------|----------|") - for i, (k, s) in enumerate(main.head(top).items(), 1): + for i, (k, s) in enumerate(main_rows, 1): e = ev.get(k) etxt = f"{e[0]}({e[1]} 源,已动 {e[2]:.0%})" if e else "—" L.append(f"| {i} | {k} | {names.get(k, '—')} | {s:.1f} | {_tier_label(s)} " f"| {etxt} | {_num(heat.get(k))} | {_pct(upside.get(k))} |") L.append("") - L.append(f"## 观察档 Top {min(obs_top, len(obs))}" - f"(无券商预期、但在传导链上——没有估值锚,置信度低)") + obs_rows = _pick(obs, ev, obs_top, theme_cap) + L.append(f"## 观察档 Top {len(obs_rows)}" + f"(无券商预期、但在传导链上——没有估值锚,置信度低{cap_txt})") L.append("") L.append("| # | 代码 | 名称 | 分 | 传导证据 | 热度 |") L.append("|---|------|------|----|----------|------|") - for i, (k, s) in enumerate(obs.head(obs_top).items(), 1): + for i, (k, s) in enumerate(obs_rows, 1): e = ev.get(k) etxt = f"{e[0]}({e[1]} 源,已动 {e[2]:.0%})" if e else "—" L.append(f"| {i} | {k} | {names.get(k, '—')} | {s:.1f} " diff --git a/run.py b/run.py index 3769d60..6de2126 100644 --- a/run.py +++ b/run.py @@ -145,6 +145,8 @@ def main(): pl.add_argument("--date", help="默认取 score 表最新日") pl.add_argument("--top", type=int, default=20, help="主榜条数") pl.add_argument("--obs-top", type=int, default=10, help="观察档条数") + pl.add_argument("--theme-cap", type=int, default=5, + help="每个传导主题最多几条(防单板块刷屏;0=不设限)") p = sub.add_parser("probe") p.add_argument("--section", choices=["all", "pools", "price", "upside", "corr"], default="all", @@ -178,7 +180,7 @@ def main(): cmd_register() elif a.cmd == "plan": import plan - plan.generate(a.date, a.top, a.obs_top) + plan.generate(a.date, a.top, a.obs_top, a.theme_cap) elif a.cmd == "tracks": import tracks tracks.coverage_report()