akg-factor-bridge/plan.py

965 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""每日选股计划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 judgement
import logic_state
import logic_state_daily
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 _fmt_regime(reg) -> str:
"""市场环境那一行。三种情况分开写,因为读的人要做的事不一样。
原先写「环境标签UNKNOWN弱势指数 None/8」。三处毛病UNKNOWN 是枚举值,
8 是硬写的分母快照里其实带着真实的指数总数None 是取不到时的空值——
三样都是内部表示,不该印在给人读的报告里。
另外必须写清一件事:这个标签**不影响任何一只票买不买**。它只在事后复盘时用来
分组。不写的话,读的人看到「弱势日」会以为下面的名单被压过一轮。
"""
tail = "这个标签只在事后复盘时用来分组,不参与今天选股,也不影响任何一只票买不买。"
if (reg or {}).get("status") != "OK" or reg.get("weak_count") is None:
return ("市场环境:今天没拿到读数(择时决策系统的环境数据当天没送过来)。"
"这里留空不代表市场好或坏,下面的名单照常,不受影响。")
n = reg["weak_count"]
total = reg.get("weak_total")
line = config.REGIME_WEAK_COUNT
scope = f"{total} 个主参考指数里有 {n} 个走弱" if total else f"{n} 个主参考指数走弱"
if reg.get("weak_day"):
return f"市场环境:{scope}。走弱满 {line} 个就算弱势日,今天算。{tail}"
return f"市场环境:{scope},没到 {line} 个,今天不算弱势日。{tail}"
def _state_out(st) -> dict | None:
"""逻辑状态发给下游的形状:只留人要读的和判决要用的,不发内部中间量。
每路都带自己的截止日与一句话说明——四态的合成规则第一条就是每路必须带截止日,
下游要能看出"这一路的证据是哪天的",否则分不清"没有证据""证据很旧"
"""
if not isinstance(st, dict):
return None
return {"state": st.get("state"), "why": st.get("why"), "as_of": st.get("as_of"),
# 落定态在 state 里(下游按它分流);原始态、落定说明、上一次落定态三键一并发出,
# 让人看得出"今天原始判的是什么、为什么被按住没迁"2026-09-07 第三件桥侧前置)。
# 没做过落定的(旧调用、单测)原始态就是 state 本身。
"raw_state": st.get("raw_state", st.get("state")),
"settle_note": st.get("settle_note"), "prev_state": st.get("prev_state"),
"usable": st.get("usable") or [], "missing": st.get("missing") or [],
"reasons": (st.get("reasons") or [])[:6],
"paths": [{"path": p.get("path"), "signal": p.get("signal"),
"as_of": p.get("as_of"), "why": p.get("why")}
for p in (st.get("paths") or []) if isinstance(p, dict)]}
def _next_day(ds: str) -> str:
"""数据日的次日,也就是计划日。行业观点快照按计划日落库,行情与论断按数据日取,
两者在生产里本来就差一天:计划日凌晨构建,用的是上一个交易日的数据。"""
try:
return (dt.date.fromisoformat(ds) + dt.timedelta(days=1)).isoformat()
except ValueError:
return ds
def _segments_of(ds: str) -> dict:
"""每只票当日所在的全部被指向环节,按前缀码索引。
读的是完整的传导视图,不是只含已启动成员的那张——候选卡判"所在环节被指向"
认的就是完整这张,这里必须跟它一致,否则算出来的产业研判覆盖比卡上真实看到的低。
读不到返回空字典,产业研判这一路整体缺席,不让计划断产。
"""
try:
d = db.read_pg("SELECT ts_code, target FROM v_factor_transmission "
"WHERE scan_date = %s", (ds,))
except Exception as e: # noqa: BLE001
print(f" (传导视图读取失败,产业研判这一路整体缺席: {e!r}")
return {}
out: dict = {}
for r in d.itertuples():
out.setdefault(common.to_prefix(str(r.ts_code).strip()), []).append(str(r.target))
return out
def _logic_state_of(k: str, evd: dict, seg_of: dict, seg_view: dict,
broker: dict, ds: str, full_logic=None, seg_hist=None, company=None) -> dict:
"""一只票的逻辑状态四态。四路各自归一,再按合成规则合成。
乙路的取法:一只票可能挂在多个被指向的环节上,取第一个有行业观点的那个。
取不到就是缺失,卡上会写明缺的是哪一路——缺失既不算负面也不算正面证据,
但必须让人看得见系统缺的是什么,不能让人以为系统判过了。
"""
# 甲路吃全量论断2026-09-07没传全量时退回卡上那几条只为让旧调用与单测不断。
claims = full_logic if full_logic is not None else evd.get("logic")
a = logic_state.from_claims(claims, ds, stale_days=config.LOGIC_STALE_DAYS)
row = next((seg_view[t] for t in seg_of.get(k, []) if t in seg_view), None)
# 乙路带上这个簇近几个计划日的历史,让"偏多到偏空"的硬触发能维持住2026-09-07
hist = (seg_hist or {}).get(str(row.get("cluster_key") or "")) if row else None
b = logic_state.from_judgement(row, history=hist, hold_days=config.JUDGEMENT_HOLD_DAYS)
c = broker.get(k) or logic_state.signal(
logic_state.PATH_BROKER, logic_state.SIG_NONE,
why="两个等长窗口里算不出可比的每股收益预测")
paths = [a, b, c, logic_state.from_events()]
# 第五路 公司质地2026-09-09 接入方案第四节,开关 LOGIC_PATH_COMPANY慢信号单独不定态
# 质地差且盈余质量类差才达到存疑的进入条件。关掉开关这一路根本不进合成。
if config.LOGIC_PATH_COMPANY:
paths.append(logic_state.from_company((company or {}).get(k), ds, stale_days=config.COMPANY_REVIEW_STALE_DAYS))
return logic_state.compose(paths)
def _logic_inputs(codes: list, ds: str) -> dict:
"""逻辑状态四态的取数,一次取全(计划装配与接口现算共用这一处,口径改了两边一起变)。
甲路因果论断2026-09-03 起挂在卡上作证据线,只展示不进判决;视图未建时为空。
2026-09-07 审查修:四态的甲路要吃**全量**论断,卡上展示只取最近几条。原先两者共用
一份截断到三条的列表,跨期翻转与同期分歧都只在三条上算,翻转两头都会判错——
历史八空两好加最新一空,全量判无法判断、三条判逻辑存疑;历史五好近三空则反过来。
全量取一次,展示从里面切前几条,取数层在截断前算的质量画像照旧挂在每条上。
乙路(产业研判):行业观点快照在交易日 D 的晚上 20:40 写plan_date 记的是 D计划在 D+1
凌晨构建,数据日 ds 就是 D。所以要读的是 plan_date 不晚于 ds 的最近一版,不是 ds+1。
09-04 之前这里读的是 ds+1能对上只因为那天上午手工跑过一次快照——把一次手工操作
的时序写进了代码,到周一就全部落空(台账 033 记的"快照断了"其实是这个错,
调度中心一直在按时跑。load_previous(D+1) 取的正是 plan_date <= D 的每簇最新一行,
顺带也扛得住某个晚上没跑会退回上一版陈旧天数照常累加。seg_hist 是每簇近几个
计划日的快照,给乙路判"硬触发要不要维持"2026-09-07 审查修)。
丙路券商行动两个等长窗口的每股收益预测中位数与机构数。丁路无数据源logic_state 恒出缺失。
hist 是逐票日频表里每只票的近日行给抗抖动用2026-09-07 第三件桥侧前置);表没建或
读不到为空字典,那时落定态等于原始态,计划照出。这张表只在早上 generate 里写,这里只读。
"""
# 券商研报原始行取一次,券商行动(丙路)与安全边际三情景(第四件)共用——两路看同一批研报。
broker_rows = sources.broker_reports(codes, ds)
return {
"logic_full": sources.logic_claims(codes, ds, per_stock=config.LOGIC_CLAIMS_FULL),
"seg_view": judgement.by_segment_name(judgement.load_previous(_next_day(ds))),
"seg_hist": judgement.recent_rows(_next_day(ds), days=config.JUDGEMENT_HOLD_DAYS),
"broker_rows": broker_rows,
"broker": sources.broker_actions(codes, ds, rows=broker_rows),
"seg_of": _segments_of(ds),
"hist": logic_state_daily.history(ds, codes=None if len(codes) > 50 else codes),
# 公司质地2026-09-09 接入方案):基座个股深度评析索引表的摘要,第五路与卡上一行共用。读不到为空字典。
"company": sources.company_reviews(codes, ds),
}
def logic_states_for(codes: list, ds: str) -> dict:
"""给定几只票,按此刻的数据算它们的逻辑状态并按逐票日频表的历史落定,按前缀码索引。
给接口 /logic_state 用:持仓票在候选筛选第一步就被整行剔掉,计划装配根本不会走到它。
取数与计划装配走同一个 _logic_inputs口径逐字相同落定与计划装配走同一个 settle_one。
"""
codes = [str(c).strip() for c in (codes or []) if str(c).strip()]
if not codes:
return {}
inp = _logic_inputs(codes, ds)
out = {}
for k in codes:
raw = _logic_state_of(k, {"logic": []}, inp["seg_of"], inp["seg_view"], inp["broker"], ds,
full_logic=inp["logic_full"].get(k) or [], seg_hist=inp["seg_hist"], company=inp["company"])
out[k] = logic_state_daily.settle_one(k, raw, inp["hist"].get(k))
return out
def valuations_for(codes: list, ds: str) -> dict:
"""给定几只票的安全边际三情景(第四件),按前缀码索引。给接口 /logic_state 用:持仓页要显示
参考目标价中性情景2026-09-07 拍板:只显示不触发,不替代用户手设的目标价),而持仓票
不在计划里。取数与计划装配同一处:券商研报原始行、前复权收盘价。"""
codes = [str(c).strip() for c in (codes or []) if str(c).strip()]
if not codes:
return {}
rows = sources.broker_reports(codes, ds)
prices = sources.close_prices(ds)
return sources.valuation_scenarios(codes, ds, prices, rows=rows)
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)
# 逻辑状态四态的取数(三路输入加逐票日频表的近日行;取数口径与理由见 _logic_inputs
# 这三路都是"研究证据还在不在"的跟踪,与候选卡的三门槛判决是正交的两维:
# 判决回答今天要不要买,四态回答支撑它的研究证据还在不在。收敛规则在
# logic_state.apply_to_card是单调的——强化只能提前卡内序、永远不升判决。
inp = _logic_inputs(codes, ds)
logic_full, seg_view, seg_hist = inp["logic_full"], inp["seg_view"], inp["seg_hist"]
company = inp.get("company") or {}
broker, seg_of, hist = inp["broker"], inp["seg_of"], inp["hist"]
logic = {k: v[:config.LOGIC_CLAIMS_PER_STOCK] for k, v in logic_full.items()}
# 安全边际三情景2026-09-07 第四件):按同一预测期的每股收益与市盈率预测算悲观、中性、乐观
# 三个估值与现价的差,只展示不进判决。现价与预期空间同源(前复权行情表);两处任一读不到,
# 卡上那一行写"算不出"并说明原因,不拦票、不断产。
prices = sources.close_prices(ds)
valuation = sources.valuation_scenarios(codes, ds, prices, rows=inp["broker_rows"])
# 催化事件与定价状态2026-09-08《量价研判链吸收方案》3.4):四类券商正向事件从研报明细表算,
# 事件日字段从前复权行情表算,归成四情形。只展示加复盘分组,不进判决;任一读不到整体缺席不断产。
events = sources.analyst_events(codes, ds)
ev_fields = sources.event_day_fields(codes, ds, events)
# 相关快讯2026-09-08 台账 050财联社电报近 3 天点名这只票的条目,只当上下文送人看与送研判,
# 不判正负、不进判决、不进定价状态。表读不到整体缺席不断产。
news = sources.news_flashes(codes, ds)
# 行业催化2026-09-08 台账 051数据基座按环节评析材料标出的行业级催化挂在环节上
# 按票所在的被指向环节取,只展示加复盘分组,不进判决、不进逻辑状态。表没建就整体缺席。
_seg_of = inp["seg_of"]
ind_cat = sources.catalysts_for_codes(
{k: _seg_of.get(k, []) for k in codes},
sources.segment_catalysts(sorted({t for k in codes for t in _seg_of.get(k, [])}), 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 []}
raw_state = _logic_state_of(k, evd, seg_of, seg_view, broker, ds,
full_logic=logic_full.get(k) or [], seg_hist=seg_hist, company=company)
# 抗抖动2026-09-07 第三件桥侧前置):配上这只票在逐票日频表里的近日行,落定今天的状态。
# 进入逻辑存疑即刻成立,退出要连续几天不再存疑,其余迁移要连续几天同向;表里没有
# 这只票时落定态就是原始态。落定态进 state原始态另存 raw_state两个都发给下游。
state = logic_state_daily.settle_one(k, raw_state, hist.get(k))
j = card.judge(evd, start_pct=config.CARD_START_PCT,
accum_max_age=config.CARD_ACCUM_MAX_AGE,
neg_tol=config.UPSIDE_NEG_TOLERANCE,
logic_stale_days=config.LOGIC_STALE_DAYS,
require_started=config.CARD_REQUIRE_STARTED,
company_review=company.get(k), quality_gate=config.CARD_COMPANY_QUALITY_GATE)
cards[k] = {
**j, "logic_state": state,
"valuation": valuation.get(k), "valuation_text": card.valuation_view(valuation.get(k)),
"events": events.get(k), "events_text": card.events_view(events.get(k)),
"pricing_state": card.pricing_state(ev_fields.get(k)),
"news": news.get(k), "news_text": card.news_view(news.get(k)),
"industry_catalyst": ind_cat.get(k), "industry_catalyst_text": card.industry_catalyst_view(ind_cat.get(k)),
"company_review": company.get(k), "company_review_text": card.company_review_view(company.get(k)),
"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_at、plan_version、
card_counts、candidates候选单全量不受裁剪、watch、segments_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 [],
# 2026-09-04 新增:逻辑状态四态。只发状态、子因、每路的来龙去脉与
# 截止日,不发权重也不发判决改动——四态怎么作用于建仓通道是 PMS
# 那边的事,这里只提供状态与出处。
logic_state=_state_out(c.get("logic_state")),
# 安全边际三情景2026-09-07 第四件):原值给程序,整句给人;只展示不进判决。
valuation=c.get("valuation"), valuation_text=c.get("valuation_text"),
# 催化事件与定价状态2026-09-08原值给程序整句给人只展示不进判决。
events=c.get("events"), events_text=c.get("events_text"),
pricing_state=c.get("pricing_state"),
pricing_text=card.pricing_view(c.get("pricing_state")),
# 相关快讯2026-09-08 台账 050原值给程序整句给人只是上下文不进判决。
news=c.get("news"), news_text=c.get("news_text"),
# 行业催化2026-09-08 台账 051环节级原值给程序整句给人只展示不进判决。
industry_catalyst=c.get("industry_catalyst"),
industry_catalyst_text=c.get("industry_catalyst_text"),
# 公司深度2026-09-09 接入方案):质地档与评议摘要,原值给程序,整句给人;
# 质地进逻辑状态第五路与判决收敛(只降不升),评议文字只给人看与送研判。
company_review=c.get("company_review"), company_review_text=c.get("company_review_text"),
company_gate=c.get("company_gate"),
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'))} | {card.valuation_short(r.get('valuation'))} "
f"| {card.company_review_short(r.get('company_review'))} "
f"| {card.events_short(r.get('events'))} | {card.pricing_short(r.get('pricing_state'))} "
f"| {''.join(r.get('reasons') or [])} | {_fmt_logic(r.get('logic'))} |")
L.append("")
L.append("安全边际按同一预测期的每股收益与市盈率预测算:悲观是最低每股收益乘最低市盈率,中性是两项中位数,"
"乐观是两项最高;赔率是中性上行对悲观下行,不到一比一就是这个位置的赔率不吸引人。"
"它只展示、不进判决,与上面用目标价平均算的预期空间是两个口径。")
L.append("催化事件是近 60 天券商的正向事件(深度覆盖、上调盈利预测、业绩超预期),从研报明细表算,最新一条列在表里。"
"定价状态按最近一次事件日的事件前涨幅、事件日跳空、收盘位置与量比归成四情形:"
"价格发现(事件前没涨、事件日放量收高)、趋势延续(事件前已涨、事件日仍放量收高)、"
"高位兑现(事件前大涨、事件日放量冲高回落)、震荡消化(其余)。两者都只展示、不进判决。")
L.append("公司质地是数据基座个股深度评析按代码合成的档(回报与护城河、盈余质量与财务安全、成长与含金量三类),"
"后面是估值标签(贵中便宜,只展示)与十个大师视角的好中差计数。质地进逻辑状态第五路;质地差的候选降为关注交人;"
"评议文字见候选卡整句与报告链接。")
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(_fmt_regime(reg))
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 pick_rows(rows, n: int, theme_cap: int) -> list:
"""从**已按分数排好序**的行里取 n 条,每个传导主题最多 theme_cap 条0=不设限)。
与 collect 里那个同名的内部函数是同一套规则,差别只在取主题的来源:那边回查证据映射,
这边直接读行里的 evidence.theme —— 装配时就把主题写进每一行了,快照里也带着。
两处规则必须一致test_plan_snapshot.py 拿真实快照逐行比对钉住这件事。
"""
out, cnt = [], {}
for r in rows:
theme = ((r.get("evidence") or {}).get("theme")) or "(无传导)"
if theme_cap and cnt.get(theme, 0) >= theme_cap:
continue
cnt[theme] = cnt.get(theme, 0) + 1
out.append(r)
if len(out) >= n:
break
return out
def from_snapshot(date: str | None = None, top: int = 20, obs_top: int = 10,
theme_cap: int = 5) -> dict:
"""从当日 JSON 快照读计划,按请求参数截取。**不做任何装配、不查任何库。**
为什么要这条路2026-09-10台账 057: collect 每次请求都现场装配整池,实测三十三秒,
而选股计划是日频的、用的全是昨收与昨夜数据 —— 一天算一次就够,不该放在请求里现算。
出计划时本来就落了一份全量快照(不裁剪、不设主题限额),注释里写明它是"复盘与对账的
唯一底本",只是接口一直没读它。这条路就是把接口接到那份底本上。
与 collect 的输出逐字一致,只差三处、都是设计使然:
· main / observe 按本次请求的参数从全量截取,名次重编成连续的
· theme_cap 按本次请求写,不用落盘时那个
· 多两个键 plan_source 与 snapshot_generated_at让人一眼看出这份是哪来的、多新
快照不存在、读不动、或数据日对不上,一律抛异常,由调用方回落到 collect。
"""
ds = date or _latest_date("t_factor_akg_score")
if not ds:
raise RuntimeError("t_factor_akg_score 还没有数据——先 build akg_score。")
path = os.path.join(config.PLAN_SNAPSHOT_DIR, f"plan_{ds}.json")
if not os.path.exists(path):
raise FileNotFoundError(f"{ds} 的计划快照不存在: {path}")
with open(path, encoding="utf-8") as f:
snap = json.load(f)
if str(snap.get("date") or "") != str(ds):
raise RuntimeError(f"快照里的数据日 {snap.get('date')} 与请求的 {ds} 对不上: {path}")
full_main, full_obs = snap.get("main"), snap.get("observe")
if not isinstance(full_main, list) or not isinstance(full_obs, list):
raise RuntimeError(f"快照缺全量主榜或观察档: {path}")
data = {k: v for k, v in snap.items()
# 前三个是落盘那次的裁剪结果与参数,与本次请求无关;
# 后两个由接口每次实时读,留在这里会被覆盖,删掉免得看的人以为快照说了算。
if k not in ("main_shown", "observe_shown", "shown_params", "market", "regime")}
data["main"] = [{**r, "rank": i}
for i, r in enumerate(pick_rows(full_main, top, theme_cap), 1)]
data["observe"] = [{**r, "rank": i}
for i, r in enumerate(pick_rows(full_obs, obs_top, theme_cap), 1)]
data["theme_cap"] = theme_cap
data["plan_source"] = "snapshot"
data["snapshot_generated_at"] = snap.get("generated_at")
return data
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)
# ---- 逐票日频逻辑状态表2026-09-07 第三件桥侧前置):只在这一次生成里写,/plan 的实时重算
# 只读不写。写在快照落盘之后:表写失败只打印原因,计划文件与快照照出,不断产。----
try:
logic_state_daily.persist(data["date"], snap["main"] + snap["observe"],
data.get("plan_version"))
except Exception as e: # noqa: BLE001 —— 表写失败不能拖垮出计划
print(f" (逐票逻辑状态表写入失败,计划照出,抗抖动明天少一天历史: {e!r}")
# ---- 个股深度评析的目标名单2026-09-09 方案):主榜与观察档写进 153 库小表,数据基座 00:45 读它。
# 写失败只打印,不断产。----
try:
import review_targets
review_targets.persist(data["date"], snap)
except Exception as e: # noqa: BLE001
print(f" (评析目标名单写入失败,计划照出: {e!r}")
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