akg-factor-bridge/plan.py

651 lines
33 KiB
Python
Raw Normal View History

"""每日选股计划R4数据装配 / Markdown 渲染 / 产出,三段分离。
collect() -> dict 结构化计划api.py 直接当 JSON 返回
render_md() -> str dict 渲染 Markdown
generate() CLI cron 的入口collect + render + 落盘 + 打印
数据全部来自已落库的表不重算
平台因子表 t_factor_akg_score / _gate / _upside / _heat 当日截面
基座只读视图 v_factor_transmission 传导证据
基座 industry_pools 股票名称
升降档一节对比前一交易日的档位表数据到达本身是信号首次覆盖 /
新进传导链即升档
2026-09-03 主观量化系统方案_2026-09-03 3.3 候选卡每票多带数据基座的因果论断
证据线只展示不进判决sources.logic_claimsgenerate 出计划时把市场四项两市成交额广度
融资恐贪sources.market_context写进快照的 market 08:45 追加的 regime 段并列
接口 /plan 只从快照读这两段
"""
from __future__ import annotations # 注解不在定义时求值:开发机的 Python 3.9 也能导入本模块跑离线单测
import datetime as dt
import json
import os
import pandas as pd
import card
import common
import config
import db
import sources
import version
# 分数编码(与 factors.build_score 一致):主榜 = 200 + 传导档位×20 + 组内分,
# 观察档 = 100 + 组内分,组内分 clip ±9.9。150 落在两带中间的空档上,用作分界。
_MAIN_MIN = 150.0
def _factor(table: str, ds: str) -> pd.Series:
df = db.read_mysql(
"factor",
f"SELECT stock_code, factor_value FROM {table} WHERE trade_date = %s", (ds,))
if df.empty:
return pd.Series(dtype=float)
return df.set_index("stock_code")["factor_value"].astype(float)
def _latest_date(table: str, upto: str | None = None):
if upto:
df = db.read_mysql(
"factor", f"SELECT MAX(trade_date) d FROM {table} "
f"WHERE trade_date <= %s", (upto,))
else:
df = db.read_mysql("factor", f"SELECT MAX(trade_date) d FROM {table}")
v = None if df.empty else df.iloc[0, 0]
return None if v is None or pd.isna(v) else pd.Timestamp(v).date().isoformat()
def _prev_date(table: str, before: str):
df = db.read_mysql(
"factor", f"SELECT MAX(trade_date) d FROM {table} "
f"WHERE trade_date < %s", (before,))
v = None if df.empty else df.iloc[0, 0]
return None if v is None or pd.isna(v) else pd.Timestamp(v).date().isoformat()
def _names() -> dict:
out = {}
pools = db.read_pg("SELECT members FROM industry_pools")
for _, r in pools.iterrows():
ms = r["members"]
if isinstance(ms, str):
ms = json.loads(ms)
for m in ms or []:
ts, name = (m or {}).get("ts_code"), (m or {}).get("name")
if ts and name:
out.setdefault(common.to_prefix(ts), str(name))
return out
def _evidence(ds: str):
"""每股最强一条传导证据:主题、源数、已动比例;另返回涉及的行情快照日。"""
tr = db.read_pg(
"SELECT ts_code, target, n_sources, moved_ratio, mkt_trade_date, "
"members_total, moved FROM v_factor_transmission WHERE scan_date = %s", (ds,))
if tr.empty:
return {}, set()
tr["k"] = tr["ts_code"].map(common.to_prefix)
tr["n_sources"] = pd.to_numeric(tr["n_sources"], errors="coerce").fillna(0)
tr["moved_ratio"] = pd.to_numeric(tr["moved_ratio"], errors="coerce").fillna(0)
tr["strength"] = tr["n_sources"] * (1.0 - tr["moved_ratio"])
tr = tr.sort_values("strength", ascending=False).drop_duplicates("k")
# 元组前三项是既有口径(多处按下标取),第四、五项是环节的成员数与已动数(关注环节表用)
ev = {r.k: (str(r.target), int(r.n_sources), float(r.moved_ratio),
None if pd.isna(r.members_total) else int(r.members_total),
None if pd.isna(r.moved) else int(r.moved))
for r in tr.itertuples()}
days = {str(x) for x in tr["mkt_trade_date"].dropna().unique()}
return ev, days
def _tier_label(score: float) -> str:
return {0: "无传导", 1: "弱传导", 2: "强传导"}.get(
int((score - 190.0) // 20), "?")
def _val(series: pd.Series, k: str):
v = series.get(k)
return None if v is None or pd.isna(v) else float(v)
def _assemble_cards(ds: str, codes: list, ev: dict, upside: pd.Series,
mkt_days: set, risk: set | None = None) -> tuple[dict, list]:
"""候选卡装配2026-09-02 方案第 2.2 节第三项):对档位表里的全部票(主榜与观察档,
裁剪之前读三路证据逐票判决规则在 card.py取数在 sources.py这里只做对齐
返回 (cards, segments_pointed)cards 按前缀码索引含判决理由缺失风险卡内序
与证据线原值segments_pointed "关注环节"聚合今日被传导指向的每个环节的源数
链符成员数已启动成员领涨者候选数这是拍板记录第一项"强传导档降为关注环节"
在计划文本层的落地"""
import factors
moved = sources.moved_members(ds)
daily = sources.stock_daily(ds)
night = sources.night_conclusions(codes, ds)
# 因果论断2026-09-03数据基座抽取的论断挂在卡上作证据线只展示不进判决视图未建时为空。
logic = sources.logic_claims(codes, ds)
if risk is None: # collect 会传入读过一次的名单;单独调用时自己读
try:
risk = factors._risk_set() or set() # noqa: SLF001 —— 同仓自用
except Exception: # noqa: BLE001 —— 风险名单拿不到时不当 ST 处理,与 gate 的宽容一致
risk = set()
stale = bool(mkt_days) and any(x != ds for x in mkt_days)
cards: dict = {}
for k in codes:
mv, e, d, n = moved.get(k), ev.get(k), daily.get(k, {}), night.get(k, {})
theme = (mv or {}).get("theme") or (e[0] if e else None)
n_sources = (mv or {}).get("n_sources") or (e[1] if e else None)
up = _val(upside, k)
evd = {"pointed": bool(mv or e), "theme": theme, "n_sources": n_sources,
"chain_fit": (mv or {}).get("chain_fit"),
"pct0": d.get("pct0"), "covered": up is not None, "upside": up,
"risk_name": k in risk,
"accum_state": n.get("accum_state"), "accum_score": n.get("accum_score"),
"accum_age": n.get("accum_age"), "y_signal": n.get("signal"),
"stale_snapshot": stale, "logic": logic.get(k) or []}
j = card.judge(evd, start_pct=config.CARD_START_PCT,
accum_max_age=config.CARD_ACCUM_MAX_AGE,
2026-09-04 08:59:11 +08:00
neg_tol=config.UPSIDE_NEG_TOLERANCE,
logic_stale_days=config.LOGIC_STALE_DAYS,
require_started=config.CARD_REQUIRE_STARTED)
cards[k] = {
**j,
"theme": theme, "n_sources": n_sources, "chain_fit": evd["chain_fit"],
"started_source": "moved_view" if mv else None,
"logic_claims": evd["logic"],
"pct0": d.get("pct0"), "net_z": d.get("net_z"), "heat_chg": d.get("heat_chg"),
"accum": ({"state": n.get("accum_state"), "score": n.get("accum_score"),
"age": n.get("accum_age"), "pos_tag": n.get("accum_pos_tag"),
"date": n.get("conclusion_date")} if n else None),
"night": ({"signal": n.get("signal"), "support": n.get("support"),
"pressure": n.get("pressure"), "date": n.get("conclusion_date")}
if n else None),
}
for i, (k, c) in enumerate(sorted(cards.items(), key=lambda kv: card.sort_key(kv[1])), 1):
c["card_rank"] = i
segs: dict = {}
for k, mv in moved.items():
s = segs.setdefault(mv["theme"], {
"segment": mv["theme"], "n_sources": mv["n_sources"], "chain_fit": mv["chain_fit"],
"members_total": mv.get("members_total"), "moved": mv.get("moved"),
"started": [], "candidates": 0, "leader": None})
s["started"].append(k)
if cards.get(k, {}).get("verdict") == card.VERDICT_CANDIDATE:
s["candidates"] += 1
for e in ev.values(): # 只有未动名单的环节也是"被指向",列入但无已启动
s = segs.setdefault(e[0], {"segment": e[0], "n_sources": e[1], "chain_fit": None,
"members_total": None, "moved": None,
"started": [], "candidates": 0, "leader": None})
if s["members_total"] is None and len(e) > 4:
s["members_total"], s["moved"] = e[3], e[4]
for s in segs.values():
best, best_pct = None, None
for k in s["started"]:
# 领涨者按行情视图找,不限于档位表里的票:被指向且已启动但不在主榜与观察档的
# (无覆盖且不在赛道、或被风险闸挡)也要显示,这是"定位对不对"的读数
p = (daily.get(k) or {}).get("pct0")
if p is not None and (best_pct is None or p > best_pct):
best, best_pct = k, p
s["leader"] = {"code": best, "pct0": best_pct} if best else None
s["started_count"] = len(s["started"])
s["started_in_tiers"] = sum(1 for k in s["started"] if k in cards)
ordered = sorted(segs.values(), key=lambda s: (-s["candidates"], -(s["n_sources"] or 0),
-(s["chain_fit"] or 0), s["segment"]))
return cards, ordered
def collect(date: str | None = None, top: int = 20, obs_top: int = 10,
theme_cap: int = 5) -> dict:
"""装配一天的计划为结构化字典。数据缺失抛 RuntimeErrorapi 侧转 404
2026-09-02 起附带候选卡main / observe 的装配排序裁剪一字不动下游 PMS 只读
这两段的既有字段每行只是多联入判决类字段顶层新增 generated_atplan_version
card_countscandidates候选单全量不受裁剪watchsegments_pointed
`_full` 是全量主榜与观察档行只给 generate 落快照用api 返回前会去掉"""
ds = date or _latest_date("t_factor_akg_score")
if not ds:
raise RuntimeError("t_factor_akg_score 还没有数据——先 build akg_score。")
score = _factor("t_factor_akg_score", ds)
gate = _factor("t_factor_akg_gate", ds)
if score.empty or gate.empty:
raise RuntimeError(f"{ds} 缺 akg_score / akg_gate——先 build 该日再出计划。")
upside = _factor("t_factor_akg_upside", ds)
if upside.empty:
# 当日 upside 表为空时现算兜底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()
ev, mkt_days = _evidence(ds)
main = score[score >= _MAIN_MIN].sort_values(ascending=False)
obs = score[score < _MAIN_MIN].sort_values(ascending=False)
generated_at = dt.datetime.now().isoformat(timespec="seconds")
try: # 风险名单只读一次:候选卡与升降档原因共用
import factors
risk = factors._risk_set() or set() # noqa: SLF001 —— 同仓自用
except Exception: # noqa: BLE001
risk = set()
cards, segments_pointed = _assemble_cards(
ds, list(main.index) + list(obs.index), ev, upside, mkt_days, risk=risk)
def _pick(ranked: pd.Series, n: int):
"""分数从高到低取 n 条;每个传导主题最多 theme_cap 条0=不设限)——
传导目标是环节级同环节成员共享同一条证据不限额会被少数环节刷屏"""
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) >= n:
break
return out
def _row(rank: int, k: str, s: float, with_tier: bool) -> dict:
e = ev.get(k)
c = cards.get(k) or {}
evidence = ({"theme": e[0], "n_sources": e[1], "moved_ratio": round(e[2], 4)}
if e else None)
# 已启动成员在传导视图里没有证据行(视图只摊平未动名单):只在原本为空时用
# 已动成员视图的目标环节与源数补上,不覆盖已有值——否则到 PMS 会全落进"无主题"桶。
if evidence is None and c.get("started_source") == "moved_view" and c.get("theme"):
evidence = {"theme": c["theme"], "n_sources": c.get("n_sources"),
"moved_ratio": None, "source": "moved_view"}
r = {"rank": rank, "code": k, "name": names.get(k),
"score": round(float(s), 2),
"evidence": evidence,
"heat": _val(heat, k), "upside": _val(upside, k)}
if with_tier:
r["tier"] = _tier_label(s)
if c:
# 2026-09-03 新增只联入basis判决依据一句话、logic因果论断带出处的文字行
# card.chain_fit链符入池上下文用、card.logic_claims论断原值、card.failed_gates。
r.update(verdict=c["verdict"], reasons=c["reasons"], missing=c["missing"],
risk=c["risk"], card_rank=c["card_rank"],
basis=c.get("basis"), logic=c.get("logic") or [],
card={"pct0": c.get("pct0"), "net_z": c.get("net_z"),
"heat_chg": c.get("heat_chg"), "accum": c.get("accum"),
"night": c.get("night"), "gates": c.get("gates"),
"confirm": c.get("confirm"), "failed_gates": c.get("failed_gates"),
"chain_fit": c.get("chain_fit"),
"logic_claims": c.get("logic_claims") or []})
return r
def _full_rows(ranked: pd.Series, with_tier: bool) -> list:
return [_row(i, k, s, with_tier) for i, (k, s) in enumerate(ranked.items(), 1)]
def _by_verdict(v: str) -> list:
rows = [_row(0, k, score[k], k in main.index) for k, c in cards.items()
if c.get("verdict") == v]
rows.sort(key=lambda r: r["card_rank"])
for i, r in enumerate(rows, 1):
r["rank"] = i
return rows
changes = None
prev_ds = _prev_date("t_factor_akg_gate", ds)
if prev_ds:
prev = _factor("t_factor_akg_gate", prev_ds)
both = pd.concat([prev.rename("prev"), gate.rename("cur")],
axis=1).fillna(-1.0) # -1 = 当日不在面板
lab = {-1.0: "池外", 0.0: "不采纳", 1.0: "观察档", 2.0: "主榜"}
up_df = both[both["cur"] > both["prev"]].sort_values("cur", ascending=False)
down_df = both[both["cur"] < both["prev"]].sort_values("prev", ascending=False)
# ---- 升降原因07-31 加):区分首次覆盖 / 估值转正 / 新进传导链等。
# 依据前一日的 upside / 传导因子表;表空时现算兜底。原因是启发式归类
# (取最主要的一条),精确审计以档位表与因子表为准。
prev_up = _factor("t_factor_akg_upside", prev_ds)
if prev_up.empty:
try:
import factors
dfu = factors.build_upside(prev_ds, prev_ds)
if dfu is not None and not dfu.empty:
x = dfu.copy()
x["k"] = x["stock_code"].map(common.to_prefix)
prev_up = x.groupby("k")["factor_value"].max().astype(float)
except Exception: # noqa: BLE001 —— 兜底失败则原因退化为通用文案
pass
prev_tr = _factor("t_factor_akg_transmission", prev_ds)
# 赛道闸与风险闸的成员集合07-31 修:赛道闸开启后,"不在赛道"曾被
# 误标成"风险闸/档位调整"、"赛道锚生效"曾被误标成"新进传导链")。
# 两个集合都尊重各自开关:闸没开时对应原因自然不会出现。
tset = None
try:
import factors
tset = factors._track_set() # noqa: SLF001 —— 同仓自用
except Exception: # noqa: BLE001 —— 拿不到就退化为通用文案
pass
def _why(k: str, pg: float, cg: float) -> str:
if cg == 2.0: # 升入主榜
pu = _val(prev_up, k)
if pu is None:
return "首次覆盖"
return "估值转正" if pu < 0 else "重获资格"
if cg == 1.0: # 升入观察档
if k in ev: # 今天真在传导链上
pt = _val(prev_tr, k)
return "新进传导链" if pt is None or pt <= 0 else "档位调整"
if tset is not None and k in tset:
return "赛道锚生效" # 赛道成员身份给的锚(闸切换/成员变动)
return "档位调整"
if pg == 2.0: # 从主榜降出
if k in risk:
return "风险闸"
cu = _val(upside, k)
if cu is None:
return "覆盖脱落"
if cu < 0:
return "估值转负"
if tset is not None and k not in tset:
return "赛道闸外" # 有覆盖也不贵,但不在十赛道内
return "档位调整"
if pg == 1.0: # 从观察档降出
if k in risk:
return "风险闸"
if k not in ev and (tset is None or k not in tset):
return "离开传导链"
return "档位调整"
return "出入面板"
def _mv(d: pd.DataFrame):
return [{"code": k, "name": names.get(k),
"from": lab.get(r["prev"], "?"), "to": lab.get(r["cur"], "?"),
"reason": _why(k, r["prev"], r["cur"])}
for k, r in d.iterrows()]
changes = {"base_date": prev_ds,
"upgrades_total": int(len(up_df)),
"downgrades_total": int(len(down_df)),
"upgrades": _mv(up_df.head(15)),
"downgrades": _mv(down_df.head(15))}
card_counts = {v: sum(1 for c in cards.values() if c.get("verdict") == v)
for v in (card.VERDICT_CANDIDATE, card.VERDICT_WATCH, card.VERDICT_SHOW)}
return {
"date": ds,
"generated_at": generated_at,
"plan_version": version.git_short_rev(),
"counts": {"main": int(len(main)), "observe": int(len(obs)),
"gate_covered": int(len(gate))},
"market_snapshot_days": sorted(mkt_days),
"heat_date": hd,
"theme_cap": theme_cap,
"main": [_row(i, k, s, True)
for i, (k, s) in enumerate(_pick(main, top), 1)],
"observe": [_row(i, k, s, False)
for i, (k, s) in enumerate(_pick(obs, obs_top), 1)],
"changes": changes,
"gate_on": bool(config.ENABLE_TRACK_GATE),
"encoding": "主榜分=200+传导档位×20+组内分(还没热、还便宜);"
"观察档分=100+0.6z(传导)+0.4z(−热度)",
# ---- 候选卡2026-09-02分数与档位之外的另一份产物不受 top / theme_cap 裁剪 ----
"card_params": {"start_pct": config.CARD_START_PCT,
"require_started": config.CARD_REQUIRE_STARTED,
"accum_max_age": config.CARD_ACCUM_MAX_AGE,
"neg_tol": config.UPSIDE_NEG_TOLERANCE,
"logic_per_stock": config.LOGIC_CLAIMS_PER_STOCK,
"rules": "候选=环节被指向∧当日涨幅达标∧券商覆盖且非ST∧明确吸筹∧无硬风险"
"关注=三门槛全过∧无硬风险∧确认线缺失或陈旧(系统无法判断,交人裁决);"
"其余仅展示(只差覆盖、潜在吸筹等非明确状态都不升格,台账 013"
"因果论断只展示不进判决"},
"card_counts": card_counts,
"candidates": _by_verdict(card.VERDICT_CANDIDATE),
"watch": _by_verdict(card.VERDICT_WATCH),
"segments_pointed": segments_pointed,
"_full": {"main": _full_rows(main, True), "observe": _full_rows(obs, False)},
}
def _fmt_pct(v) -> str:
return "" if v is None else f"{v:+.0%}"
def _fmt_num(v) -> str:
return "" if v is None else f"{v:.2f}"
def _fmt_ev(e) -> str:
if not e:
return ""
if e.get("moved_ratio") is None: # 已动成员视图补的证据行,没有已动比例
return f"{e['theme']}{e.get('n_sources') or ''} 源,本票已启动)"
return f"{e['theme']}{e['n_sources']} 源,已动 {e['moved_ratio']:.0%}"
def _fmt_pct0(v) -> str:
return "" if v is None else f"{v:+.1f}%"
def _fmt_accum(ac: dict) -> str:
if not ac or not ac.get("state"):
return "无评分"
st = str(ac["state"]).split("·")[0]
age = ac.get("age")
return f"{st}{age} 日前)" if isinstance(age, int) else st
def _fmt_logic(lines, width: int = 60) -> str:
"""候选单与关注单表格里的因果论断列:第一条截到 width 字,多于一条时带条数;表格里不能有竖线。"""
lines = [str(x) for x in (lines or []) if x]
if not lines:
return ""
first = lines[0].replace("|", "")
if len(first) > width:
first = first[:width] + ""
return f"{first}(共 {len(lines)} 条)" if len(lines) > 1 else first
def _fmt_yi(v) -> str:
"""已经是"亿元"的数直接显示。两市成交额由取数层换算好(原表单位是千元),不在这里换算。"""
return "" if v is None else f"{v:,.0f} 亿"
def _fmt_amount(v) -> str:
"""以元为单位的金额换算成亿显示。融资余额用它(那张表的单位是元)。"""
if v is None:
return ""
return f"{v / 1e8:,.0f} 亿" if abs(v) >= 1e8 else f"{v:,.0f}"
def _fmt_market(m: dict) -> str:
"""环境段市场四项的一行文字;缺的项写"",读数原因在 JSON 的 market.errors 里。"""
t, b, mg, fg = m.get("turnover") or {}, m.get("breadth") or {}, m.get("margin") or {}, m.get("fear_greed") or {}
parts = []
if t:
ratio = t.get("ratio_vs_prev5")
parts.append(f"两市成交额 {_fmt_yi(t.get('amount_yi'))}"
+ (f"(前五日均值的 {ratio:.2f} 倍)" if ratio else "")
+ (f",数据日 {t['data_date']}" if t.get("data_date") and t.get("data_date") != m.get("date") else ""))
else:
parts.append("两市成交额 —")
if b:
med = b.get("pct_median")
parts.append(f"广度 上涨 {b.get('up')} / 下跌 {b.get('down')} 家,涨停近似 {b.get('limit_up_approx')} 家,"
f"涨幅中位数 {med:+.2f}%" if med is not None else
f"广度 上涨 {b.get('up')} / 下跌 {b.get('down')}")
else:
parts.append("广度 —")
if mg:
bal, chg = mg.get("financing_balance"), mg.get("change_percent_5d")
parts.append(f"融资余额 {_fmt_amount(bal)}"
+ (f"(五日变化 {chg:+.2f}%" if chg is not None else "")
+ (f"{mg['date']}" if mg.get("date") else ""))
else:
parts.append("融资余额 —")
if fg and fg.get("index_value") is not None:
parts.append(f"恐贪指数 {fg['index_value']:.0f}" + (f"{fg['date']}" if fg.get("date") else ""))
else:
parts.append("恐贪指数 —")
return "市场环境:" + "".join(parts) + "(只展示与复盘分组,不作交易前置)。"
def render_md(d: dict) -> str:
L = [f"# 每日选股计划 · {d['date']}", ""]
c = d["counts"]
L.append(f"主榜 {c['main']} 只 / 观察档 {c['observe']} 只 / "
f"全池档位覆盖 {c['gate_covered']} 只。")
stale = [x for x in d["market_snapshot_days"] if x != d["date"]]
if stale:
L.append(f"注:本日传导用的行情快照 = {''.join(stale)}"
f"(与计划日不同——历史降级日口径)。")
L.append("")
# ---- 候选单与关注环节2026-09-02放在主榜之前这是新的主产物 ----
cc = d.get("card_counts") or {}
cands = d.get("candidates") or []
L.append(f"## 候选单(环节被指向、当日已启动、券商覆盖且非 ST、明确吸筹、无硬风险"
f"{cc.get('候选', len(cands))} 只,全量列出不受裁剪)")
L.append("")
if not cands:
L.append("(今日无候选——候选为空不是故障:环节没被指向、成员没启动或没有明确吸筹,都会为空。)")
else:
L.append("| # | 代码 | 名称 | 环节 | 源数 | 当日涨幅 | 吸筹 | 预期空间 | 理由 | 因果论断(出处) |")
L.append("|---|------|------|------|------|----------|------|----------|------|------------------|")
for r in cands:
c = r.get("card") or {}
ac = c.get("accum") or {}
ev_ = r.get("evidence") or {}
L.append(f"| {r['rank']} | {r['code']} | {r['name'] or ''} | {ev_.get('theme') or ''} "
f"| {ev_.get('n_sources') or ''} | {_fmt_pct0(c.get('pct0'))} "
f"| {_fmt_accum(ac)} | {_fmt_pct(r.get('upside'))} "
f"| {''.join(r.get('reasons') or [])} | {_fmt_logic(r.get('logic'))} |")
L.append("")
segs = d.get("segments_pointed") or []
L.append(f"## 关注环节(今日被传导指向的 {len(segs)} 个环节:定位对不对看这里,挑票看候选单)")
L.append("")
if segs:
L.append("| 环节 | 源数 | 链符 | 成员 | 已启动 | 领涨 | 候选 |")
L.append("|------|------|------|------|--------|------|------|")
for s in segs:
ld = s.get("leader") or {}
L.append(f"| {s['segment']} | {s.get('n_sources') or ''} | {_fmt_num(s.get('chain_fit'))} "
f"| {s.get('members_total') if s.get('members_total') is not None else ''} "
f"| {s.get('started_count', 0)} "
f"| {(ld.get('code') or '') + (' ' + _fmt_pct0(ld.get('pct0')) if ld.get('code') else '')} "
f"| {s.get('candidates', 0)} |")
L.append("")
watch = d.get("watch") or []
L.append(f"## 关注单(三门槛全过、无硬风险,但吸筹确认线缺失或陈旧——系统无法判断,交人裁决;"
f"{cc.get('关注', len(watch))} 只,列前 20")
L.append("")
if watch:
L.append("| # | 代码 | 名称 | 环节 | 当日涨幅 | 吸筹 | 缺什么 | 因果论断(出处) |")
L.append("|---|------|------|------|----------|------|--------|------------------|")
for r in watch[:20]:
c = r.get("card") or {}
ev_ = r.get("evidence") or {}
L.append(f"| {r['rank']} | {r['code']} | {r['name'] or ''} | {ev_.get('theme') or ''} "
f"| {_fmt_pct0(c.get('pct0'))} | {_fmt_accum(c.get('accum') or {})} "
f"| {''.join(r.get('missing') or [])} | {_fmt_logic(r.get('logic'))} |")
L.append("")
reg = d.get("regime")
if reg:
L.append(f"环境标签:{reg.get('status')},弱势指数 {reg.get('weak_count')}/8"
f"{',弱势日' if reg.get('weak_day') else ''}(只展示与复盘分组,不作交易前置)。")
L.append("")
mk = d.get("market")
if mk:
L.append(_fmt_market(mk))
L.append("")
cap_txt = f",每主题限额 {d['theme_cap']}" if d["theme_cap"] else ""
gate_txt = "、在十五五赛道内" if d.get("gate_on") else ""
L.append(f"## 主榜 Top {len(d['main'])}"
f"(有券商预期、目标价不低于现价{gate_txt}{cap_txt}")
L.append("")
L.append("| # | 代码 | 名称 | 总分 | 档位 | 传导证据 | 热度 | 预期空间 |")
L.append("|---|------|------|------|------|----------|------|----------|")
for r in d["main"]:
L.append(f"| {r['rank']} | {r['code']} | {r['name'] or ''} | {r['score']:.1f} "
f"| {r['tier']} | {_fmt_ev(r['evidence'])} "
f"| {_fmt_num(r['heat'])} | {_fmt_pct(r['upside'])} |")
L.append("")
L.append(f"## 观察档 Top {len(d['observe'])}"
f"(无券商预期、但{'在赛道或传导链上' if d.get('gate_on') else '在传导链上'}"
f"——没有估值锚,置信度低{cap_txt}")
L.append("")
L.append("| # | 代码 | 名称 | 分 | 传导证据 | 热度 |")
L.append("|---|------|------|----|----------|------|")
for r in d["observe"]:
L.append(f"| {r['rank']} | {r['code']} | {r['name'] or ''} | {r['score']:.1f} "
f"| {_fmt_ev(r['evidence'])} | {_fmt_num(r['heat'])} |")
L.append("")
L.append("## 今日升降档")
L.append("")
ch = d["changes"]
if not ch:
L.append("(没有更早的档位表可比,升降档从下一个交易日开始。)")
else:
L.append(f"对比 {ch['base_date']}:升档 {ch['upgrades_total']} 只,"
f"降档 {ch['downgrades_total']} 只。"
f"升档=拿到新锚(首次覆盖 / 新进传导链),本身就是值得看的信号。")
if ch["upgrades"]:
L.append("")
L.append("**升档**")
L += [f"- {m['code']} {m['name'] or ''}{m['from']}{m['to']}"
f"{m.get('reason', '')}"
for m in ch["upgrades"]]
if ch["upgrades_total"] > len(ch["upgrades"]):
L.append(f"- ……共 {ch['upgrades_total']} 只,其余见档位表")
if ch["downgrades"]:
L.append("")
L.append("**降档**")
L += [f"- {m['code']} {m['name'] or ''}{m['from']}{m['to']}"
f"{m.get('reason', '')}"
for m in ch["downgrades"]]
if ch["downgrades_total"] > len(ch["downgrades"]):
L.append(f"- ……共 {ch['downgrades_total']} 只,其余见档位表")
L.append("")
L.append("---")
L.append(f"口径:{d['encoding']}")
return "\n".join(L)
def generate(date: str | None = None, top: int = 20, obs_top: int = 10,
theme_cap: int = 5) -> str:
try:
data = collect(date, top, obs_top, theme_cap)
except RuntimeError as e:
raise SystemExit(str(e))
# 环境段的市场四项2026-09-03出计划时读一次落进快照接口 /plan 只从快照读,盘中不再取数;
# 每项读失败为空并把原因记在 market.errors不阻断。区制段仍由 08:45 的追加步骤写入。
data["market"] = sources.market_context(data["date"])
text = render_md(data)
os.makedirs(config.PLAN_SNAPSHOT_DIR, exist_ok=True)
out = os.path.join(config.PLAN_SNAPSHOT_DIR, f"plan_{data['date']}.md")
with open(out, "w", encoding="utf-8") as f:
f.write(text + "\n")
# ---- 当日 JSON 快照2026-09-02 方案第 2.2 节第一项):主榜与观察档全部行、全部证据线,
# 不裁剪、不设主题限额;带生成时刻与代码版本。它是复盘与对账的唯一底本;
# 08:45 的 regime-append 步骤会往里追加 regime 段,/plan 的 regime 段只读这份。----
full = data.pop("_full", None) or {}
snap = {**data, "main_shown": data["main"], "observe_shown": data["observe"],
"main": full.get("main", []), "observe": full.get("observe", []),
"shown_params": {"top": top, "obs_top": obs_top, "theme_cap": theme_cap}}
jpath = os.path.join(config.PLAN_SNAPSHOT_DIR, f"plan_{data['date']}.json")
tmp = jpath + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False, indent=1, default=str)
os.replace(tmp, jpath)
print(text)
print(f"\n已写入 {out} 与快照 {jpath}"
f"(主榜 {len(snap['main'])} 行、观察档 {len(snap['observe'])} 行、"
f"候选 {len(data.get('candidates') or [])} 只,版本 {data.get('plan_version')}")
return out