diff --git a/app/core/action_engine.py b/app/core/action_engine.py index a04aae0..5284d2d 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -522,6 +522,10 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): # 到今天还成不成立」; 进评审账本与提议卡, 让人看得见系统为什么挑了它。 "verdict": c.get("verdict"), "reasons": c.get("reasons"), "missing": c.get("missing"), "risk": c.get("risk"), "card_rank": c.get("card_rank"), + # 研究理由两键 (2026-09-03): 判决依据是上游对自己这条判决的一句话解释, 因果论断是 + # 研报里说这家公司好在哪、出处是哪份研报哪一天。两者只进评审账本与提议卡给人看, + # 不参与任何判定, 也不送研判 —— 产业逻辑归数据基座与选股系统, 不归择时决策系统。 + "basis": c.get("basis"), "logic": c.get("logic"), # ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉, # 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。 "target_pct": target_pct, "target_amount": round(full_amt, 2), diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py index cdc626b..5ce83b0 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -53,6 +53,26 @@ top_n 截断**之前**剔掉 —— 那是为了不让它们白占候选池的 「关注」强制人工确认与「候选」自动流程的分流不在本模块, 见 action_engine.scan_open 与 proposal_service._route_one。旧版计划没有这些键时, 一切行为与 2026-09-02 之前一字不差。 +**研究理由 (2026-09-03 起上游随每行下发, 同样向下兼容)**: 又多了两个键 —— +`basis` (判决依据, 一句话说明这一行为什么落在这一档, 例如「三门槛全过、无硬风险、明确吸筹 +且评分新鲜」) 与 `logic` (因果论断, 字符串列表, 每行是研报里那句话加上「——出处:《文档 +标题》 披露日 · 论断编号」)。加这两个键要解决的是一件很具体的事: 在此之前 PMS 收下的 +`reasons` 只有四条可交易口径的理由 (门槛过了几道、覆盖几家、当日涨了多少), 人在提议卡上 +看不到研报究竟说了什么, 研究结论走到 PMS 门口就断了。 + +两个键的性质与那五个不同, 所以口径也不同: + + * **它们只给人看, 不参与任何判定。** 判决分流、自动执行开关、规则闸、名额与金额, 一处 + 都不读它们。`basis` 是上游对自己判决的解释, `logic` 是产业逻辑本身。 + * **绝不送研判。** `judge.OPEN_JUDGE_KEYS` 不收这两个键 (那份名单里有一条守卫单测钉着)。 + 送过去等于请择时决策系统去读产业逻辑, 而它的活是判「此刻这只票买不买得下手」—— + 四层分工里产业逻辑归数据基座与选股系统, 不归它。 + * **`logic` 的条数在这里截断** (`LOGIC_KEEP`), 与 `reasons` 走 `REASONS_KEEP` 同一个 + 做法; `basis` 是一句话, 不截。 + * **论断的日期就写在文字里。** 上游把出处与披露日拼进了每一行, 所以页面照原样显示就能 + 看出这条论断是哪年哪月的。上游那边正在给论断加陈旧度标注, 字段到了之前, 日期这件事 + 先靠这行文字兑现 —— 一条 2024 年的论断与一条上周的论断, 人必须一眼分得出。 + 模块级只依赖 stdlib + `app.core.command_spec` / `app.core.action_engine` (都是纯逻辑), 其余 (requests / param_store / pms_repo / plan_diff / tradedays) 一律函数内懒加载 —— 让解析与筛选这两段纯逻辑可以零依赖单测。 @@ -116,6 +136,15 @@ def _text_or_none(v): # 进研判请求体、显示在提议卡上 —— 四个去处都只需要前几条最要紧的。 REASONS_KEEP = 4 +# 因果论断最多带几条进 PMS。取 3 有三条理由: +# 一是上游 `card.logic_lines` 自己就按 3 条封顶, 取同一个数, 今天上游给了几条 PMS 就收 +# 几条 —— 截断不会静默丢掉已经给出来的研究结论 (那是这两个键存在的全部意义); +# 二是它仍然是一道硬上限: 上游哪天把自己那个 3 调大, 提议卡、评审账本、提议确认表不会 +# 跟着一起被灌满, 这一层不用改码就挡得住; +# 三是提议卡上放不下太多行 —— 论断行比理由长得多 (一句机制加一段出处), 卡片默认只显示 +# 第一条, 其余折叠, 3 条正好是「折开也还看得完」的量。 +LOGIC_KEEP = 3 + def _list_or_none(v, limit: int = 0): """字符串列表归一: 列表逐项去空白、丢空项, 单个字符串当一项; 缺失或类型不对一律 None。 @@ -167,6 +196,9 @@ def _rows(raw, bucket: str) -> list: "missing": _list_or_none(it.get("missing")), "risk": _list_or_none(it.get("risk")), "card_rank": _int_or_none(it.get("card_rank")), + # 研究理由 (2026-09-03): 只给人看, 不参与任何判定, 也不送研判。见模块说明。 + "basis": _text_or_none(it.get("basis")), + "logic": _list_or_none(it.get("logic"), limit=LOGIC_KEEP), }) return out @@ -349,7 +381,10 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N # 候选卡五键原样透传 (缺就是 None), 动作引擎把它们放进硬数字 "verdict": r.get("verdict"), "reasons": r.get("reasons"), "missing": r.get("missing"), "risk": r.get("risk"), - "card_rank": r.get("card_rank")} + "card_rank": r.get("card_rank"), + # 研究理由两键: 跟着候选一路带到提议卡与评审账本, 让人看得到研报说了什么。 + # 它们不进送研判的那份名单 (judge.OPEN_JUDGE_KEYS), 产业逻辑不归择时决策系统判。 + "basis": r.get("basis"), "logic": r.get("logic")} for r in passed[:n]] return {"date": plan.get("date"), "considered": len(pool), "eligible": len(passed), "items": items, "dropped": dropped, "st_unknown": st_unknown, diff --git a/app/web/static/index.html b/app/web/static/index.html index af96e3d..94efcfa 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1027,6 +1027,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} {{ p.expire_at }} 前有效