2026-07-30 14:09:02 +08:00
|
|
|
|
"""每日选股计划(R4):数据装配 / Markdown 渲染 / 产出,三段分离。
|
|
|
|
|
|
|
|
|
|
|
|
collect() -> dict 结构化计划——api.py 直接当 JSON 返回
|
|
|
|
|
|
render_md() -> str 从 dict 渲染 Markdown
|
|
|
|
|
|
generate() CLI 与 cron 的入口:collect + render + 落盘 + 打印
|
2026-07-30 12:44:56 +08:00
|
|
|
|
|
|
|
|
|
|
数据全部来自已落库的表,不重算:
|
|
|
|
|
|
平台因子表 t_factor_akg_score / _gate / _upside / _heat —— 当日截面
|
2026-07-30 14:09:02 +08:00
|
|
|
|
基座只读视图 v_factor_transmission —— 传导证据
|
2026-07-30 12:44:56 +08:00
|
|
|
|
基座 industry_pools —— 股票名称
|
2026-07-30 14:09:02 +08:00
|
|
|
|
升降档一节对比前一交易日的档位表——数据到达本身是信号(首次覆盖 /
|
|
|
|
|
|
新进传导链即升档)。
|
2026-09-03 11:44:16 +08:00
|
|
|
|
|
|
|
|
|
|
2026-09-03 起(《主观量化系统方案_2026-09-03》第 3.3 节):候选卡每票多带数据基座的因果论断
|
|
|
|
|
|
证据线(只展示不进判决,sources.logic_claims);generate 出计划时把市场四项(两市成交额、广度、
|
|
|
|
|
|
融资、恐贪,sources.market_context)写进快照的 market 段,与 08:45 追加的 regime 段并列,
|
|
|
|
|
|
接口 /plan 只从快照读这两段。
|
2026-07-30 12:44:56 +08:00
|
|
|
|
"""
|
2026-09-03 11:44:16 +08:00
|
|
|
|
from __future__ import annotations # 注解不在定义时求值:开发机的 Python 3.9 也能导入本模块跑离线单测
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
import datetime as dt
|
2026-07-30 12:44:56 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
import card
|
2026-07-30 12:44:56 +08:00
|
|
|
|
import common
|
2026-07-31 09:39:24 +08:00
|
|
|
|
import config
|
2026-09-04 11:46:01 +08:00
|
|
|
|
import judgement
|
|
|
|
|
|
import logic_state
|
2026-09-07 14:21:42 +08:00
|
|
|
|
import logic_state_daily
|
2026-07-30 12:44:56 +08:00
|
|
|
|
import db
|
2026-09-02 16:39:52 +08:00
|
|
|
|
import sources
|
|
|
|
|
|
import version
|
2026-07-30 12:44:56 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 分数编码(与 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(
|
2026-09-02 16:53:13 +08:00
|
|
|
|
"SELECT ts_code, target, n_sources, moved_ratio, mkt_trade_date, "
|
|
|
|
|
|
"members_total, moved FROM v_factor_transmission WHERE scan_date = %s", (ds,))
|
2026-07-30 12:44:56 +08:00
|
|
|
|
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")
|
2026-09-02 16:53:13 +08:00
|
|
|
|
# 元组前三项是既有口径(多处按下标取),第四、五项是环节的成员数与已动数(关注环节表用)
|
|
|
|
|
|
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))
|
2026-07-30 12:44:56 +08:00
|
|
|
|
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), "?")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
def _val(series: pd.Series, k: str):
|
|
|
|
|
|
v = series.get(k)
|
|
|
|
|
|
return None if v is None or pd.isna(v) else float(v)
|
2026-07-30 12:55:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 15:00:54 +08:00
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 11:46:01 +08:00
|
|
|
|
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"),
|
2026-09-07 14:21:42 +08:00
|
|
|
|
# 落定态在 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"),
|
2026-09-04 11:46:01 +08:00
|
|
|
|
"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,
|
2026-09-09 12:05:03 +08:00
|
|
|
|
broker: dict, ds: str, full_logic=None, seg_hist=None, company=None) -> dict:
|
2026-09-04 11:46:01 +08:00
|
|
|
|
"""一只票的逻辑状态四态。四路各自归一,再按合成规则合成。
|
|
|
|
|
|
|
|
|
|
|
|
乙路的取法:一只票可能挂在多个被指向的环节上,取第一个有行业观点的那个。
|
|
|
|
|
|
取不到就是缺失,卡上会写明缺的是哪一路——缺失既不算负面也不算正面证据,
|
|
|
|
|
|
但必须让人看得见系统缺的是什么,不能让人以为系统判过了。
|
|
|
|
|
|
"""
|
2026-09-07 11:42:40 +08:00
|
|
|
|
# 甲路吃全量论断(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)
|
2026-09-04 11:46:01 +08:00
|
|
|
|
row = next((seg_view[t] for t in seg_of.get(k, []) if t in seg_view), None)
|
2026-09-07 11:42:40 +08:00
|
|
|
|
# 乙路带上这个簇近几个计划日的历史,让"偏多到偏空"的硬触发能维持住(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)
|
2026-09-04 11:46:01 +08:00
|
|
|
|
c = broker.get(k) or logic_state.signal(
|
|
|
|
|
|
logic_state.PATH_BROKER, logic_state.SIG_NONE,
|
|
|
|
|
|
why="两个等长窗口里算不出可比的每股收益预测")
|
2026-09-09 12:05:03 +08:00
|
|
|
|
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)
|
2026-09-04 11:46:01 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 14:21:42 +08:00
|
|
|
|
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 里写,这里只读。
|
|
|
|
|
|
"""
|
2026-09-07 15:48:27 +08:00
|
|
|
|
# 券商研报原始行取一次,券商行动(丙路)与安全边际三情景(第四件)共用——两路看同一批研报。
|
|
|
|
|
|
broker_rows = sources.broker_reports(codes, ds)
|
2026-09-07 14:21:42 +08:00
|
|
|
|
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),
|
2026-09-07 15:48:27 +08:00
|
|
|
|
"broker_rows": broker_rows,
|
|
|
|
|
|
"broker": sources.broker_actions(codes, ds, rows=broker_rows),
|
2026-09-07 14:21:42 +08:00
|
|
|
|
"seg_of": _segments_of(ds),
|
|
|
|
|
|
"hist": logic_state_daily.history(ds, codes=None if len(codes) > 50 else codes),
|
2026-09-09 12:05:03 +08:00
|
|
|
|
# 公司质地(2026-09-09 接入方案):基座个股深度评析索引表的摘要,第五路与卡上一行共用。读不到为空字典。
|
|
|
|
|
|
"company": sources.company_reviews(codes, ds),
|
2026-09-07 14:21:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-09 12:05:03 +08:00
|
|
|
|
full_logic=inp["logic_full"].get(k) or [], seg_hist=inp["seg_hist"], company=inp["company"])
|
2026-09-07 14:21:42 +08:00
|
|
|
|
out[k] = logic_state_daily.settle_one(k, raw, inp["hist"].get(k))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 16:35:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
def _assemble_cards(ds: str, codes: list, ev: dict, upside: pd.Series,
|
2026-09-02 17:14:54 +08:00
|
|
|
|
mkt_days: set, risk: set | None = None) -> tuple[dict, list]:
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"""候选卡装配(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-07 14:21:42 +08:00
|
|
|
|
# 逻辑状态四态的取数(三路输入加逐票日频表的近日行;取数口径与理由见 _logic_inputs)。
|
2026-09-04 11:46:01 +08:00
|
|
|
|
# 这三路都是"研究证据还在不在"的跟踪,与候选卡的三门槛判决是正交的两维:
|
|
|
|
|
|
# 判决回答今天要不要买,四态回答支撑它的研究证据还在不在。收敛规则在
|
|
|
|
|
|
# logic_state.apply_to_card,是单调的——强化只能提前卡内序、永远不升判决。
|
2026-09-07 14:21:42 +08:00
|
|
|
|
inp = _logic_inputs(codes, ds)
|
|
|
|
|
|
logic_full, seg_view, seg_hist = inp["logic_full"], inp["seg_view"], inp["seg_hist"]
|
2026-09-09 12:05:03 +08:00
|
|
|
|
company = inp.get("company") or {}
|
2026-09-07 14:21:42 +08:00
|
|
|
|
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 15:48:27 +08:00
|
|
|
|
# 安全边际三情景(2026-09-07 第四件):按同一预测期的每股收益与市盈率预测算悲观、中性、乐观
|
|
|
|
|
|
# 三个估值与现价的差,只展示不进判决。现价与预期空间同源(前复权行情表);两处任一读不到,
|
|
|
|
|
|
# 卡上那一行写"算不出"并说明原因,不拦票、不断产。
|
|
|
|
|
|
prices = sources.close_prices(ds)
|
|
|
|
|
|
valuation = sources.valuation_scenarios(codes, ds, prices, rows=inp["broker_rows"])
|
2026-09-08 11:40:51 +08:00
|
|
|
|
# 催化事件与定价状态(2026-09-08《量价研判链吸收方案》3.4):四类券商正向事件从研报明细表算,
|
|
|
|
|
|
# 事件日字段从前复权行情表算,归成四情形。只展示加复盘分组,不进判决;任一读不到整体缺席不断产。
|
|
|
|
|
|
events = sources.analyst_events(codes, ds)
|
|
|
|
|
|
ev_fields = sources.event_day_fields(codes, ds, events)
|
2026-09-08 14:07:05 +08:00
|
|
|
|
# 相关快讯(2026-09-08 台账 050):财联社电报近 3 天点名这只票的条目,只当上下文送人看与送研判,
|
|
|
|
|
|
# 不判正负、不进判决、不进定价状态。表读不到整体缺席不断产。
|
|
|
|
|
|
news = sources.news_flashes(codes, ds)
|
2026-09-08 15:21:10 +08:00
|
|
|
|
# 行业催化(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))
|
2026-09-02 17:14:54 +08:00
|
|
|
|
if risk is None: # collect 会传入读过一次的名单;单独调用时自己读
|
|
|
|
|
|
try:
|
|
|
|
|
|
risk = factors._risk_set() or set() # noqa: SLF001 —— 同仓自用
|
|
|
|
|
|
except Exception: # noqa: BLE001 —— 风险名单拿不到时不当 ST 处理,与 gate 的宽容一致
|
|
|
|
|
|
risk = set()
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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"),
|
2026-09-03 11:44:16 +08:00
|
|
|
|
"stale_snapshot": stale, "logic": logic.get(k) or []}
|
2026-09-07 14:21:42 +08:00
|
|
|
|
raw_state = _logic_state_of(k, evd, seg_of, seg_view, broker, ds,
|
2026-09-09 12:05:03 +08:00
|
|
|
|
full_logic=logic_full.get(k) or [], seg_hist=seg_hist, company=company)
|
2026-09-07 14:21:42 +08:00
|
|
|
|
# 抗抖动(2026-09-07 第三件桥侧前置):配上这只票在逐票日频表里的近日行,落定今天的状态。
|
|
|
|
|
|
# 进入逻辑存疑即刻成立,退出要连续几天不再存疑,其余迁移要连续几天同向;表里没有
|
|
|
|
|
|
# 这只票时落定态就是原始态。落定态进 state,原始态另存 raw_state,两个都发给下游。
|
|
|
|
|
|
state = logic_state_daily.settle_one(k, raw_state, hist.get(k))
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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,
|
2026-09-04 10:49:30 +08:00
|
|
|
|
logic_stale_days=config.LOGIC_STALE_DAYS,
|
2026-09-09 12:05:03 +08:00
|
|
|
|
require_started=config.CARD_REQUIRE_STARTED,
|
|
|
|
|
|
company_review=company.get(k), quality_gate=config.CARD_COMPANY_QUALITY_GATE)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
cards[k] = {
|
2026-09-04 11:46:01 +08:00
|
|
|
|
**j, "logic_state": state,
|
2026-09-07 15:48:27 +08:00
|
|
|
|
"valuation": valuation.get(k), "valuation_text": card.valuation_view(valuation.get(k)),
|
2026-09-08 11:40:51 +08:00
|
|
|
|
"events": events.get(k), "events_text": card.events_view(events.get(k)),
|
|
|
|
|
|
"pricing_state": card.pricing_state(ev_fields.get(k)),
|
2026-09-08 14:07:05 +08:00
|
|
|
|
"news": news.get(k), "news_text": card.news_view(news.get(k)),
|
2026-09-08 15:21:10 +08:00
|
|
|
|
"industry_catalyst": ind_cat.get(k), "industry_catalyst_text": card.industry_catalyst_view(ind_cat.get(k)),
|
2026-09-09 12:05:03 +08:00
|
|
|
|
"company_review": company.get(k), "company_review_text": card.company_review_view(company.get(k)),
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"theme": theme, "n_sources": n_sources, "chain_fit": evd["chain_fit"],
|
|
|
|
|
|
"started_source": "moved_view" if mv else None,
|
2026-09-03 11:44:16 +08:00
|
|
|
|
"logic_claims": evd["logic"],
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"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(): # 只有未动名单的环节也是"被指向",列入但无已启动
|
2026-09-02 16:53:13 +08:00
|
|
|
|
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]
|
2026-09-02 16:39:52 +08:00
|
|
|
|
for s in segs.values():
|
|
|
|
|
|
best, best_pct = None, None
|
|
|
|
|
|
for k in s["started"]:
|
2026-09-02 16:53:13 +08:00
|
|
|
|
# 领涨者按行情视图找,不限于档位表里的票:被指向且已启动但不在主榜与观察档的
|
|
|
|
|
|
# (无覆盖且不在赛道、或被风险闸挡)也要显示,这是"定位对不对"的读数
|
|
|
|
|
|
p = (daily.get(k) or {}).get("pct0")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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"])
|
2026-09-02 16:53:13 +08:00
|
|
|
|
s["started_in_tiers"] = sum(1 for k in s["started"] if k in cards)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
ordered = sorted(segs.values(), key=lambda s: (-s["candidates"], -(s["n_sources"] or 0),
|
|
|
|
|
|
-(s["chain_fit"] or 0), s["segment"]))
|
|
|
|
|
|
return cards, ordered
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
def collect(date: str | None = None, top: int = 20, obs_top: int = 10,
|
|
|
|
|
|
theme_cap: int = 5) -> dict:
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"""装配一天的计划为结构化字典。数据缺失抛 RuntimeError(api 侧转 404)。
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 起附带候选卡:main / observe 的装配、排序、裁剪一字不动(下游 PMS 只读
|
|
|
|
|
|
这两段的既有字段),每行只是多联入判决类字段;顶层新增 generated_at、plan_version、
|
|
|
|
|
|
card_counts、candidates(候选单全量,不受裁剪)、watch、segments_pointed。
|
|
|
|
|
|
`_full` 是全量主榜与观察档行,只给 generate 落快照用,api 返回前会去掉。"""
|
2026-07-30 12:44:56 +08:00
|
|
|
|
ds = date or _latest_date("t_factor_akg_score")
|
|
|
|
|
|
if not ds:
|
2026-07-30 14:09:02 +08:00
|
|
|
|
raise RuntimeError("t_factor_akg_score 还没有数据——先 build akg_score。")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
score = _factor("t_factor_akg_score", ds)
|
|
|
|
|
|
gate = _factor("t_factor_akg_gate", ds)
|
|
|
|
|
|
if score.empty or gate.empty:
|
2026-07-30 14:09:02 +08:00
|
|
|
|
raise RuntimeError(f"{ds} 缺 akg_score / akg_gate——先 build 该日再出计划。")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
upside = _factor("t_factor_akg_upside", ds)
|
2026-07-30 12:55:24 +08:00
|
|
|
|
if upside.empty:
|
2026-07-30 14:09:02 +08:00
|
|
|
|
# 当日 upside 表为空时现算兜底(as-of 口径不变:consensus<=当日、当日收盘价)
|
2026-07-30 12:55:24 +08:00
|
|
|
|
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)
|
2026-07-30 12:44:56 +08:00
|
|
|
|
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)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
generated_at = dt.datetime.now().isoformat(timespec="seconds")
|
2026-09-02 17:14:54 +08:00
|
|
|
|
try: # 风险名单只读一次:候选卡与升降档原因共用
|
|
|
|
|
|
import factors
|
|
|
|
|
|
risk = factors._risk_set() or set() # noqa: SLF001 —— 同仓自用
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
risk = set()
|
2026-09-02 16:39:52 +08:00
|
|
|
|
cards, segments_pointed = _assemble_cards(
|
2026-09-02 17:14:54 +08:00
|
|
|
|
ds, list(main.index) + list(obs.index), ev, upside, mkt_days, risk=risk)
|
2026-07-30 12:44:56 +08:00
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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"}
|
2026-07-30 14:09:02 +08:00
|
|
|
|
r = {"rank": rank, "code": k, "name": names.get(k),
|
|
|
|
|
|
"score": round(float(s), 2),
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"evidence": evidence,
|
2026-07-30 14:09:02 +08:00
|
|
|
|
"heat": _val(heat, k), "upside": _val(upside, k)}
|
|
|
|
|
|
if with_tier:
|
|
|
|
|
|
r["tier"] = _tier_label(s)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
if c:
|
2026-09-03 11:44:16 +08:00
|
|
|
|
# 2026-09-03 新增只联入:basis(判决依据一句话)、logic(因果论断带出处的文字行)、
|
|
|
|
|
|
# card.chain_fit(链符,入池上下文用)、card.logic_claims(论断原值)、card.failed_gates。
|
2026-09-02 16:39:52 +08:00
|
|
|
|
r.update(verdict=c["verdict"], reasons=c["reasons"], missing=c["missing"],
|
|
|
|
|
|
risk=c["risk"], card_rank=c["card_rank"],
|
2026-09-03 11:44:16 +08:00
|
|
|
|
basis=c.get("basis"), logic=c.get("logic") or [],
|
2026-09-04 11:46:01 +08:00
|
|
|
|
# 2026-09-04 新增:逻辑状态四态。只发状态、子因、每路的来龙去脉与
|
|
|
|
|
|
# 截止日,不发权重也不发判决改动——四态怎么作用于建仓通道是 PMS
|
|
|
|
|
|
# 那边的事,这里只提供状态与出处。
|
|
|
|
|
|
logic_state=_state_out(c.get("logic_state")),
|
2026-09-07 15:48:27 +08:00
|
|
|
|
# 安全边际三情景(2026-09-07 第四件):原值给程序,整句给人;只展示不进判决。
|
|
|
|
|
|
valuation=c.get("valuation"), valuation_text=c.get("valuation_text"),
|
2026-09-08 11:40:51 +08:00
|
|
|
|
# 催化事件与定价状态(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 14:07:05 +08:00
|
|
|
|
# 相关快讯(2026-09-08 台账 050):原值给程序,整句给人;只是上下文,不进判决。
|
|
|
|
|
|
news=c.get("news"), news_text=c.get("news_text"),
|
2026-09-08 15:21:10 +08:00
|
|
|
|
# 行业催化(2026-09-08 台账 051):环节级,原值给程序,整句给人;只展示不进判决。
|
|
|
|
|
|
industry_catalyst=c.get("industry_catalyst"),
|
|
|
|
|
|
industry_catalyst_text=c.get("industry_catalyst_text"),
|
2026-09-09 12:05:03 +08:00
|
|
|
|
# 公司深度(2026-09-09 接入方案):质地档与评议摘要,原值给程序,整句给人;
|
|
|
|
|
|
# 质地进逻辑状态第五路与判决收敛(只降不升),评议文字只给人看与送研判。
|
|
|
|
|
|
company_review=c.get("company_review"), company_review_text=c.get("company_review_text"),
|
|
|
|
|
|
company_gate=c.get("company_gate"),
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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"),
|
2026-09-03 11:44:16 +08:00
|
|
|
|
"confirm": c.get("confirm"), "failed_gates": c.get("failed_gates"),
|
|
|
|
|
|
"chain_fit": c.get("chain_fit"),
|
|
|
|
|
|
"logic_claims": c.get("logic_claims") or []})
|
2026-07-30 14:09:02 +08:00
|
|
|
|
return r
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-31 09:20:44 +08:00
|
|
|
|
# ---- 升降原因(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)
|
2026-07-31 09:39:24 +08:00
|
|
|
|
# 赛道闸与风险闸的成员集合(07-31 修:赛道闸开启后,"不在赛道"曾被
|
|
|
|
|
|
# 误标成"风险闸/档位调整"、"赛道锚生效"曾被误标成"新进传导链")。
|
|
|
|
|
|
# 两个集合都尊重各自开关:闸没开时对应原因自然不会出现。
|
2026-09-02 17:14:54 +08:00
|
|
|
|
tset = None
|
2026-07-31 09:39:24 +08:00
|
|
|
|
try:
|
|
|
|
|
|
import factors
|
|
|
|
|
|
tset = factors._track_set() # noqa: SLF001 —— 同仓自用
|
|
|
|
|
|
except Exception: # noqa: BLE001 —— 拿不到就退化为通用文案
|
|
|
|
|
|
pass
|
2026-07-31 09:20:44 +08:00
|
|
|
|
|
|
|
|
|
|
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: # 升入观察档
|
2026-07-31 09:39:24 +08:00
|
|
|
|
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 "档位调整"
|
2026-07-31 09:20:44 +08:00
|
|
|
|
if pg == 2.0: # 从主榜降出
|
2026-07-31 09:39:24 +08:00
|
|
|
|
if k in risk:
|
|
|
|
|
|
return "风险闸"
|
2026-07-31 09:20:44 +08:00
|
|
|
|
cu = _val(upside, k)
|
|
|
|
|
|
if cu is None:
|
|
|
|
|
|
return "覆盖脱落"
|
2026-07-31 09:39:24 +08:00
|
|
|
|
if cu < 0:
|
|
|
|
|
|
return "估值转负"
|
|
|
|
|
|
if tset is not None and k not in tset:
|
|
|
|
|
|
return "赛道闸外" # 有覆盖也不贵,但不在十赛道内
|
|
|
|
|
|
return "档位调整"
|
2026-07-31 09:20:44 +08:00
|
|
|
|
if pg == 1.0: # 从观察档降出
|
2026-07-31 09:39:24 +08:00
|
|
|
|
if k in risk:
|
|
|
|
|
|
return "风险闸"
|
|
|
|
|
|
if k not in ev and (tset is None or k not in tset):
|
|
|
|
|
|
return "离开传导链"
|
|
|
|
|
|
return "档位调整"
|
2026-07-31 09:20:44 +08:00
|
|
|
|
return "出入面板"
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
def _mv(d: pd.DataFrame):
|
|
|
|
|
|
return [{"code": k, "name": names.get(k),
|
2026-07-31 09:20:44 +08:00
|
|
|
|
"from": lab.get(r["prev"], "?"), "to": lab.get(r["cur"], "?"),
|
|
|
|
|
|
"reason": _why(k, r["prev"], r["cur"])}
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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))}
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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)}
|
2026-07-30 14:09:02 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"date": ds,
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"generated_at": generated_at,
|
|
|
|
|
|
"plan_version": version.git_short_rev(),
|
2026-07-30 14:09:02 +08:00
|
|
|
|
"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,
|
2026-07-31 09:39:24 +08:00
|
|
|
|
"gate_on": bool(config.ENABLE_TRACK_GATE),
|
2026-07-30 14:09:02 +08:00
|
|
|
|
"encoding": "主榜分=200+传导档位×20+组内分(还没热、还便宜);"
|
|
|
|
|
|
"观察档分=100+0.6z(传导)+0.4z(−热度)",
|
2026-09-02 16:39:52 +08:00
|
|
|
|
# ---- 候选卡(2026-09-02):分数与档位之外的另一份产物,不受 top / theme_cap 裁剪 ----
|
|
|
|
|
|
"card_params": {"start_pct": config.CARD_START_PCT,
|
2026-09-04 10:49:30 +08:00
|
|
|
|
"require_started": config.CARD_REQUIRE_STARTED,
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"accum_max_age": config.CARD_ACCUM_MAX_AGE,
|
|
|
|
|
|
"neg_tol": config.UPSIDE_NEG_TOLERANCE,
|
2026-09-03 11:44:16 +08:00
|
|
|
|
"logic_per_stock": config.LOGIC_CLAIMS_PER_STOCK,
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"rules": "候选=环节被指向∧当日涨幅达标∧券商覆盖且非ST∧明确吸筹∧无硬风险;"
|
2026-09-03 11:44:16 +08:00
|
|
|
|
"关注=三门槛全过∧无硬风险∧确认线缺失或陈旧(系统无法判断,交人裁决);"
|
|
|
|
|
|
"其余仅展示(只差覆盖、潜在吸筹等非明确状态都不升格,台账 013);"
|
|
|
|
|
|
"因果论断只展示不进判决"},
|
2026-09-02 16:39:52 +08:00
|
|
|
|
"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)},
|
2026-07-30 14:09:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 "—"
|
2026-09-02 16:39:52 +08:00
|
|
|
|
if e.get("moved_ratio") is None: # 已动成员视图补的证据行,没有已动比例
|
|
|
|
|
|
return f"{e['theme']}({e.get('n_sources') or '—'} 源,本票已启动)"
|
2026-07-30 14:09:02 +08:00
|
|
|
|
return f"{e['theme']}({e['n_sources']} 源,已动 {e['moved_ratio']:.0%})"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 11:44:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 11:46:46 +08:00
|
|
|
|
def _fmt_yi(v) -> str:
|
|
|
|
|
|
"""已经是"亿元"的数直接显示。两市成交额由取数层换算好(原表单位是千元),不在这里换算。"""
|
|
|
|
|
|
return "—" if v is None else f"{v:,.0f} 亿"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 11:44:16 +08:00
|
|
|
|
def _fmt_amount(v) -> str:
|
2026-09-03 11:46:46 +08:00
|
|
|
|
"""以元为单位的金额换算成亿显示。融资余额用它(那张表的单位是元)。"""
|
2026-09-03 11:44:16 +08:00
|
|
|
|
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")
|
2026-09-03 11:46:46 +08:00
|
|
|
|
parts.append(f"两市成交额 {_fmt_yi(t.get('amount_yi'))}"
|
2026-09-03 11:44:16 +08:00
|
|
|
|
+ (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) + "(只展示与复盘分组,不作交易前置)。"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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"]]
|
2026-07-30 12:44:56 +08:00
|
|
|
|
if stale:
|
2026-07-30 14:09:02 +08:00
|
|
|
|
L.append(f"注:本日传导用的行情快照 = {'、'.join(stale)}"
|
|
|
|
|
|
f"(与计划日不同——历史降级日口径)。")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
|
2026-09-02 16:39:52 +08:00
|
|
|
|
# ---- 候选单与关注环节(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:
|
2026-09-07 15:48:27 +08:00
|
|
|
|
L.append("| # | 代码 | 名称 | 环节 | 源数 | 当日涨幅 | 吸筹 | 预期空间 | 安全边际(悲观下行/中性上行,赔率) "
|
2026-09-09 12:05:03 +08:00
|
|
|
|
"| 公司质地 | 催化事件 | 定价状态 | 理由 | 因果论断(出处) |")
|
|
|
|
|
|
L.append("|---|------|------|------|------|----------|------|----------|----------|----------|----------|----------|------|------------------|")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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'))} "
|
2026-09-07 15:48:27 +08:00
|
|
|
|
f"| {_fmt_accum(ac)} | {_fmt_pct(r.get('upside'))} | {card.valuation_short(r.get('valuation'))} "
|
2026-09-09 12:05:03 +08:00
|
|
|
|
f"| {card.company_review_short(r.get('company_review'))} "
|
2026-09-08 11:40:51 +08:00
|
|
|
|
f"| {card.events_short(r.get('events'))} | {card.pricing_short(r.get('pricing_state'))} "
|
2026-09-03 11:44:16 +08:00
|
|
|
|
f"| {';'.join(r.get('reasons') or [])} | {_fmt_logic(r.get('logic'))} |")
|
2026-09-07 15:48:27 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
L.append("安全边际按同一预测期的每股收益与市盈率预测算:悲观是最低每股收益乘最低市盈率,中性是两项中位数,"
|
|
|
|
|
|
"乐观是两项最高;赔率是中性上行对悲观下行,不到一比一就是这个位置的赔率不吸引人。"
|
|
|
|
|
|
"它只展示、不进判决,与上面用目标价平均算的预期空间是两个口径。")
|
2026-09-08 11:40:51 +08:00
|
|
|
|
L.append("催化事件是近 60 天券商的正向事件(深度覆盖、上调盈利预测、业绩超预期),从研报明细表算,最新一条列在表里。"
|
|
|
|
|
|
"定价状态按最近一次事件日的事件前涨幅、事件日跳空、收盘位置与量比归成四情形:"
|
|
|
|
|
|
"价格发现(事件前没涨、事件日放量收高)、趋势延续(事件前已涨、事件日仍放量收高)、"
|
|
|
|
|
|
"高位兑现(事件前大涨、事件日放量冲高回落)、震荡消化(其余)。两者都只展示、不进判决。")
|
2026-09-09 12:05:03 +08:00
|
|
|
|
L.append("公司质地是数据基座个股深度评析按代码合成的档(回报与护城河、盈余质量与财务安全、成长与含金量三类),"
|
|
|
|
|
|
"后面是估值标签(贵中便宜,只展示)与十个大师视角的好中差计数。质地进逻辑状态第五路;质地差的候选降为关注交人;"
|
|
|
|
|
|
"评议文字见候选卡整句与报告链接。")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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 []
|
2026-09-03 11:44:16 +08:00
|
|
|
|
L.append(f"## 关注单(三门槛全过、无硬风险,但吸筹确认线缺失或陈旧——系统无法判断,交人裁决;"
|
|
|
|
|
|
f"共 {cc.get('关注', len(watch))} 只,列前 20)")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
if watch:
|
2026-09-03 11:44:16 +08:00
|
|
|
|
L.append("| # | 代码 | 名称 | 环节 | 当日涨幅 | 吸筹 | 缺什么 | 因果论断(出处) |")
|
|
|
|
|
|
L.append("|---|------|------|------|----------|------|--------|------------------|")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
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 {})} "
|
2026-09-03 11:44:16 +08:00
|
|
|
|
f"| {';'.join(r.get('missing') or [])} | {_fmt_logic(r.get('logic'))} |")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
reg = d.get("regime")
|
|
|
|
|
|
if reg:
|
2026-09-04 15:00:54 +08:00
|
|
|
|
L.append(_fmt_regime(reg))
|
2026-09-02 16:39:52 +08:00
|
|
|
|
L.append("")
|
2026-09-03 11:44:16 +08:00
|
|
|
|
mk = d.get("market")
|
|
|
|
|
|
if mk:
|
|
|
|
|
|
L.append(_fmt_market(mk))
|
|
|
|
|
|
L.append("")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
cap_txt = f",每主题限额 {d['theme_cap']}" if d["theme_cap"] else ""
|
2026-07-31 09:39:24 +08:00
|
|
|
|
gate_txt = "、在十五五赛道内" if d.get("gate_on") else ""
|
|
|
|
|
|
L.append(f"## 主榜 Top {len(d['main'])}"
|
|
|
|
|
|
f"(有券商预期、目标价不低于现价{gate_txt}{cap_txt})")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
L.append("| # | 代码 | 名称 | 总分 | 档位 | 传导证据 | 热度 | 预期空间 |")
|
|
|
|
|
|
L.append("|---|------|------|------|------|----------|------|----------|")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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'])} |")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
L.append(f"## 观察档 Top {len(d['observe'])}"
|
2026-07-31 09:39:24 +08:00
|
|
|
|
f"(无券商预期、但{'在赛道或传导链上' if d.get('gate_on') else '在传导链上'}"
|
|
|
|
|
|
f"——没有估值锚,置信度低{cap_txt})")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
L.append("| # | 代码 | 名称 | 分 | 传导证据 | 热度 |")
|
|
|
|
|
|
L.append("|---|------|------|----|----------|------|")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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'])} |")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
|
|
|
|
|
|
L.append("## 今日升降档")
|
|
|
|
|
|
L.append("")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
ch = d["changes"]
|
|
|
|
|
|
if not ch:
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("(没有更早的档位表可比,升降档从下一个交易日开始。)")
|
|
|
|
|
|
else:
|
2026-07-30 14:09:02 +08:00
|
|
|
|
L.append(f"对比 {ch['base_date']}:升档 {ch['upgrades_total']} 只,"
|
|
|
|
|
|
f"降档 {ch['downgrades_total']} 只。"
|
2026-07-30 12:44:56 +08:00
|
|
|
|
f"升档=拿到新锚(首次覆盖 / 新进传导链),本身就是值得看的信号。")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
if ch["upgrades"]:
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
L.append("**升档**:")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
L += [f"- {m['code']} {m['name'] or ''}:{m['from']} → {m['to']}"
|
2026-07-31 09:20:44 +08:00
|
|
|
|
f"({m.get('reason', '—')})"
|
2026-07-30 14:09:02 +08:00
|
|
|
|
for m in ch["upgrades"]]
|
|
|
|
|
|
if ch["upgrades_total"] > len(ch["upgrades"]):
|
|
|
|
|
|
L.append(f"- ……共 {ch['upgrades_total']} 只,其余见档位表")
|
|
|
|
|
|
if ch["downgrades"]:
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
L.append("**降档**:")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
L += [f"- {m['code']} {m['name'] or ''}:{m['from']} → {m['to']}"
|
2026-07-31 09:20:44 +08:00
|
|
|
|
f"({m.get('reason', '—')})"
|
2026-07-30 14:09:02 +08:00
|
|
|
|
for m in ch["downgrades"]]
|
|
|
|
|
|
if ch["downgrades_total"] > len(ch["downgrades"]):
|
|
|
|
|
|
L.append(f"- ……共 {ch['downgrades_total']} 只,其余见档位表")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
L.append("")
|
|
|
|
|
|
L.append("---")
|
2026-07-30 14:09:02 +08:00
|
|
|
|
L.append(f"口径:{d['encoding']}。")
|
|
|
|
|
|
return "\n".join(L)
|
|
|
|
|
|
|
2026-07-30 12:44:56 +08:00
|
|
|
|
|
2026-09-10 09:30:44 +08:00
|
|
|
|
class StaleSnapshot(RuntimeError):
|
|
|
|
|
|
"""快照还在,但太旧、不能拿来当今天的计划用。调用方接住它回落到实时装配。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def snapshot_fresh_enough(snap: dict) -> tuple:
|
|
|
|
|
|
"""这份快照够不够新,能不能当今天的计划直接下发。返回 (能不能, 一句原因)。
|
|
|
|
|
|
|
|
|
|
|
|
为什么需要这道守卫(2026-09-10,台账 057): 出计划这一步现在是**前一晚**跑的
|
|
|
|
|
|
(实测 09-09 23:10 出的 09-09 那份),而数据基座凌晨才更新公司深度评析、催化事件
|
|
|
|
|
|
这些证据线。所以快照天生比实时装配旧半天 —— 实测两条路比对,代码序列一模一样,
|
|
|
|
|
|
但候选卡名次、判决、依据这些字段有实质差异。直接拿旧快照下发,等于让下游用昨晚的判决。
|
|
|
|
|
|
|
|
|
|
|
|
两条判据,都不满足就退回实时装配(也就是今天的行为,不会变坏):
|
|
|
|
|
|
· 代码版本要和现在跑的一致 —— 版本变了说明判决逻辑可能变了,昨天算的不算数
|
|
|
|
|
|
· 生成时刻要晚于今天的界(默认早上六点)—— 保证它是在数据基座凌晨那批任务之后出的
|
|
|
|
|
|
|
|
|
|
|
|
**这道守卫要能松开,靠的是把出计划挪到数据基座之后**(比如早上七点十分,代码注释里
|
|
|
|
|
|
本来就是这么设计的),不是把界调低。界调低只会让旧快照蒙混过关。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import version
|
|
|
|
|
|
min_hour = int(os.environ.get("PLAN_SNAPSHOT_MIN_HOUR", "6"))
|
|
|
|
|
|
gen = str(snap.get("generated_at") or "")
|
|
|
|
|
|
ver = str(snap.get("plan_version") or "")
|
|
|
|
|
|
now_ver = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
now_ver = str(version.git_short_rev() or "")
|
|
|
|
|
|
except Exception: # noqa: BLE001 —— 取不到版本就不拿这条卡人
|
|
|
|
|
|
now_ver = ""
|
|
|
|
|
|
if now_ver and ver and ver != now_ver:
|
|
|
|
|
|
return False, (f"快照是用代码版本 {ver} 出的,现在跑的是 {now_ver};"
|
|
|
|
|
|
f"判决逻辑可能已经变了,本次按实时装配")
|
|
|
|
|
|
if not gen:
|
|
|
|
|
|
return False, "快照没有生成时刻,判不了新旧"
|
|
|
|
|
|
try:
|
|
|
|
|
|
g = dt.datetime.fromisoformat(gen)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return False, f"快照的生成时刻认不出: {gen}"
|
|
|
|
|
|
today_line = dt.datetime.now().replace(hour=min_hour, minute=0, second=0, microsecond=0)
|
|
|
|
|
|
if g < today_line:
|
|
|
|
|
|
return False, (f"快照出于 {gen},早于今天 {min_hour:02d}:00;"
|
|
|
|
|
|
f"数据基座凌晨那批证据线更新它没赶上,本次按实时装配")
|
|
|
|
|
|
return True, ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:41 +08:00
|
|
|
|
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}")
|
2026-09-10 09:31:19 +08:00
|
|
|
|
if not date: # 只有「要今天该用的那份」才谈新鲜度
|
|
|
|
|
|
ok, why = snapshot_fresh_enough(snap)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
raise StaleSnapshot(why)
|
2026-09-10 09:23:41 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:09:02 +08:00
|
|
|
|
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 11:44:16 +08:00
|
|
|
|
# 环境段的市场四项(2026-09-03):出计划时读一次落进快照,接口 /plan 只从快照读,盘中不再取数;
|
|
|
|
|
|
# 每项读失败为空并把原因记在 market.errors,不阻断。区制段仍由 08:45 的追加步骤写入。
|
|
|
|
|
|
data["market"] = sources.market_context(data["date"])
|
2026-07-30 14:09:02 +08:00
|
|
|
|
text = render_md(data)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
os.makedirs(config.PLAN_SNAPSHOT_DIR, exist_ok=True)
|
|
|
|
|
|
out = os.path.join(config.PLAN_SNAPSHOT_DIR, f"plan_{data['date']}.md")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
with open(out, "w", encoding="utf-8") as f:
|
|
|
|
|
|
f.write(text + "\n")
|
2026-09-02 16:39:52 +08:00
|
|
|
|
# ---- 当日 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 14:21:42 +08:00
|
|
|
|
# ---- 逐票日频逻辑状态表(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 10:29:56 +08:00
|
|
|
|
# ---- 个股深度评析的目标名单(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})")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
print(text)
|
2026-09-02 16:39:52 +08:00
|
|
|
|
print(f"\n已写入 {out} 与快照 {jpath}"
|
|
|
|
|
|
f"(主榜 {len(snap['main'])} 行、观察档 {len(snap['observe'])} 行、"
|
|
|
|
|
|
f"候选 {len(data.get('candidates') or [])} 只,版本 {data.get('plan_version')})")
|
2026-07-30 12:44:56 +08:00
|
|
|
|
return out
|