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 }} 前有效
决策系统:{{ p.judge_reason }}(置信度 {{ (p.hard_numbers||{}).judge_conf }})
选股系统:判为「{{ (p.hard_numbers||{}).verdict }}」,{{ propReasons(p).join(';') }}
+
{{ propBasis(p) }}
+
研报说:{{ propLogic(p) }}(另有 {{ propLogicMore(p) }} 条)
{{ propWhy(p) }}
约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }} · 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}
@@ -2365,6 +2367,18 @@ createApp({ const r = (p.hard_numbers || {}).reasons; return Array.isArray(r) ? r.slice(0, 2) : []; } + // 研究理由 (2026-09-03): 判决依据是上游对这条判决的一句话解释; 因果论断是研报里说这家 + // 公司好在哪, 每行自带「——出处:《研报标题》 披露日」。卡片窄, 只摆第一条论断, + // 但那一条必须原样显示到出处与日期为止 —— 一条 2024 年的论断与一条上周的, 人得一眼分得出。 + function propBasis(p) { return (p.hard_numbers || {}).basis || ''; } + function propLogic(p) { + const l = (p.hard_numbers || {}).logic; + return Array.isArray(l) && l.length ? l[0] : ''; + } + function propLogicMore(p) { + const l = (p.hard_numbers || {}).logic; + return Array.isArray(l) && l.length > 1 ? (l.length - 1) : 0; + } function nextStep(r) { if (r.frozen_reason && r.frozen_reason !== 'NONE') return '这只票现在只卖不买。'; if (r.cushion_state === 'SOLID') return '浮盈够厚,条件符合就会自动加仓。'; diff --git a/scripts/run_tests.py b/scripts/run_tests.py index c96e938..a97d147 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -14,7 +14,8 @@ test_batch5_units.py 决策系统信号流解析与消化口径 (8 例) test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 例) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/ - 候选卡五键透传与截断/仅展示在候选阶段剔除 (35 例) + 候选卡五键透传与截断/仅展示在候选阶段剔除/ + 研究理由两键透传与不进送研判名单 (36 例) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例) test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 + @@ -53,8 +54,9 @@ test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 651 例 - (上面这个总数此前写的 649 是笔误: 各批例数加起来一直是 650, 本次 +1 例后是 651) + 共 652 例 + (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 + 减持与研究理由两键各一例, 现为 652) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): diff --git a/scripts/test_batch7_units.py b/scripts/test_batch7_units.py index 905146c..8b5e817 100644 --- a/scripts/test_batch7_units.py +++ b/scripts/test_batch7_units.py @@ -111,9 +111,37 @@ def _(): for k in ("verdict", "reasons", "missing", "risk", "card_rank"): assert k in r and r[k] is None, (k, r.get(k)) assert p["observe"][0][k] is None, (k, p["observe"][0][k]) + # 2026-09-03 起的研究理由两键同理: 旧样例没有, 必须是 None + for k in ("basis", "logic"): + assert k in r and r[k] is None, (k, r.get(k)) assert p["plan_version"] is None and p["generated_at"] is None +@case("解析·研究理由两键: 原样透传 / 论断截到三条 / 缺键为 None / 绝不进送研判的名单") +def _(): + from app.services import judge as jd + d = dict(SAMPLE) + lines = [f"利好:机制{i}——出处:《某研报》 2026-06-23 · id{i}" for i in range(5)] + d["main"] = [dict(SAMPLE["main"][0], basis="三门槛全过、无硬风险、明确吸筹且评分新鲜", + logic=lines)] + r = pf.parse_plan(d)["main"][0] + assert r["basis"].startswith("三门槛全过"), r["basis"] + # 上游给多少收多少, 但有硬上限: 论断行比理由长得多, 提议卡放不下 + assert r["logic"] == lines[:pf.LOGIC_KEEP] and len(r["logic"]) == 3, r["logic"] + # 出处与披露日必须原样留在文字里 —— 一条 2024 年的论断与一条上周的, 人得一眼分得出 + assert "出处" in r["logic"][0] and "2026-06-23" in r["logic"][0], r["logic"][0] + # 空列表与缺键要分得开: 上游说「这票没论断」是空列表, 「上游没给这个字段」才是 None。 + # 这与既有五键 (missing / risk) 一个口径, 空串则与缺键同义。 + d2 = dict(SAMPLE) + d2["main"] = [dict(SAMPLE["main"][0], basis="", logic=[])] + r2 = pf.parse_plan(d2)["main"][0] + assert r2["basis"] is None, r2["basis"] + assert r2["logic"] == [], r2["logic"] + # 边界: 这两个键绝不能进送研判的名单。产业逻辑归数据基座与选股系统, 择时决策系统只判 + # 「此刻这只票买不买得下手」。以后有人顺手加进去, 这一条会拦住。 + assert "basis" not in jd.OPEN_JUDGE_KEYS and "logic" not in jd.OPEN_JUDGE_KEYS, jd.OPEN_JUDGE_KEYS + + @case("解析·themes 映射: 主榜优先于观察档, 同码不被观察档覆盖") def _(): d = dict(SAMPLE)