diff --git a/app/core/action_engine.py b/app/core/action_engine.py index 5284d2d..f7272b0 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -96,6 +96,23 @@ WHY_WATCH_CONFIRM = "选股系统判为关注(无法判断),交人裁决" WHY_DISPLAY_ONLY = "选股系统判为仅展示" +def display_only_why(row) -> str: + """仅展示的票为什么没建仓, 尽量写出**这一只**的真实原因。 + + 只写「选股系统判为仅展示」是不够的: 仅展示至少有三种来路, 差一个当日涨幅、 + 吸筹没到确认线、被昨夜坏信号一票否决, 三者的处置完全不同 —— 前两种明天可能就 + 翻过来, 第三种是技术面在走坏。三只票并排显示同一句话, 人看不出哪只该继续盯。 + + 判决依据 (basis) 是选股系统在判决时**当场写下**的一句话, 2026-09-04 起随计划下发。 + 它在就直接用它, 不在 (旧版计划没有这个键) 就退回原来那句固定文案 —— 少一个字段 + 不该让这一栏整个空掉。 + """ + basis = (str(row.get("basis") or "").strip() if isinstance(row, dict) else "") + if not basis: + return WHY_DISPLAY_ONLY + ",不出提议" + return f"{WHY_DISPLAY_ONLY},不出提议:{basis}" + + def verdict_confirm_why(verdict): """按判决要不要强制人工确认: 要则回原因, 不要回 None。 @@ -614,7 +631,7 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, # 仅展示的行不占名额、不占金额, 也不进后面任何一道闸; 放在名额判断之前, # 是为了让它的跳过原因永远是「仅展示」而不是碰巧的「名额用完」。 skipped.append({"ts_code": code, "action": A_OPEN, - "why": WHY_DISPLAY_ONLY + ",不出提议"}) + "why": display_only_why(c)}) continue if slots <= 0: skipped.append({"ts_code": code, "action": A_OPEN, diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py index 5ce83b0..e514abf 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -343,7 +343,10 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N continue if route_by_verdict and r.get("verdict") == VERDICT_DISPLAY: dropped["display"] += 1 # 上游判为仅展示: 不进池, 单独列出让人看得见 - display_only.append(c) + # 带上判决依据: 仅展示至少有三种来路 (差一个当日涨幅、吸筹没到确认线、 + # 被昨夜坏信号一票否决), 三者处置完全不同。只记代码的话, 下游只能对所有 + # 仅展示的票写同一句话, 人看不出哪只该继续盯。 + display_only.append({"ts_code": c, "basis": r.get("basis")}) continue if exclude_st: nm = (r.get("name") or "").strip() diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 6630c82..9f7d11e 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -197,9 +197,11 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: return [] # 仅展示的票逐只写跳过原因: disposition_snapshot 复用本函数, 页面「系统在盯的候选」 # 由此显示「选股系统判为仅展示」而不是一个说不清的空白。 - for code in (sel.get("display_only") or []): - out["skipped"].append({"ts_code": code, "action": ae.A_OPEN, - "why": ae.WHY_DISPLAY_ONLY + ",不出提议"}) + for row in (sel.get("display_only") or []): + # 2026-09-04 起这里是带判决依据的行; 早先只是一串代码, 兼容着读。 + row = row if isinstance(row, dict) else {"ts_code": row} + out["skipped"].append({"ts_code": row.get("ts_code"), "action": ae.A_OPEN, + "why": ae.display_only_why(row)}) items = list(sel.get("items") or []) if not items: out["skipped"].append({"action": ae.A_OPEN, diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a97d147..e64522a 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -47,16 +47,17 @@ 无判决回退/判决认不出交人/仅展示不占名额/自动执行 开关四条件与任一缺失仍入队/信号来源区分/密钥名单 含会话密钥/研判键放行与必答题/置信度进硬数字/ - 页面静态守卫 (15 例) + 仅展示的跳过原因写出这一只自己的判决依据/ + 页面静态守卫 (16 例) test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 652 例 + 共 653 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 - 减持与研究理由两键各一例, 现为 652) + 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因一例, 现为 653) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): diff --git a/scripts/test_batch20_units.py b/scripts/test_batch20_units.py index 72af9e6..da20bba 100644 --- a/scripts/test_batch20_units.py +++ b/scripts/test_batch20_units.py @@ -155,6 +155,29 @@ def _(): assert r["candidates"][0]["needs_user_confirm"] is False +@case("判决分流·仅展示的跳过原因写出这一只自己的判决依据 (三种来路要分得开)") +def _(): + # 三只都是仅展示, 但来路完全不同: 一只差当日涨幅、一只吸筹没到确认线、 + # 一只被昨夜坏信号一票否决。写成同一句话的话, 人看不出哪只该继续盯。 + rows = [cand("600000.SH", score=300, verdict="仅展示", basis="门槛未过:started"), + cand("600002.SH", score=290, verdict="仅展示", + basis="三门槛全过,但吸筹评分为「潜在吸筹」,未达确认线,不升格关注"), + cand("600003.SH", score=280, verdict="仅展示", + basis="硬风险否决:决策系统昨夜信号 SELL"), + cand("600004.SH", score=270, verdict="仅展示")] # 旧版计划: 没有判决依据 + sk = {x["ts_code"]: x["why"] for x in _scan(rows, slots=3)["skipped"]} + assert sk["600000.SH"].endswith(":门槛未过:started"), sk["600000.SH"] + assert "潜在吸筹" in sk["600002.SH"], sk["600002.SH"] + assert "SELL" in sk["600003.SH"], sk["600003.SH"] + # 三句话必须互不相同 —— 这正是这条用例要守住的东西 + assert len({sk["600000.SH"], sk["600002.SH"], sk["600003.SH"]}) == 3, sk + # 判决依据缺失时退回原来那句固定文案, 不许让这一栏空掉 + assert sk["600004.SH"] == ae.WHY_DISPLAY_ONLY + ",不出提议", sk["600004.SH"] + # 每一句都仍以固定前缀开头, 页面按前缀分组的逻辑不受影响 + for c in ("600000.SH", "600002.SH", "600003.SH", "600004.SH"): + assert sk[c].startswith(ae.WHY_DISPLAY_ONLY), sk[c] + + @case("判决分流·没有判决 (旧版计划) 时开关开着与关着产出逐字一致, 五键全 None") def _(): rows = [cand("600000.SH", score=300), cand("600001.SH", score=200, rank=2)] diff --git a/scripts/test_batch7_units.py b/scripts/test_batch7_units.py index 8b5e817..7b34394 100644 --- a/scripts/test_batch7_units.py +++ b/scripts/test_batch7_units.py @@ -523,7 +523,10 @@ def _(): assert r["considered"] == r["eligible"] + dr["held"] + dr["black"] + dr["tier"] \ + dr["score"] + dr["sources"] + dr["upside"] + dr["theme"] + dr["dup"] \ + dr["display"], (r, dr) - assert dr["display"] == 1 and r["display_only"] == ["600600.SH"], (dr, r["display_only"]) + # 2026-09-04 起 display_only 是带判决依据的行, 不再是一串代码 —— 下游要靠它写出 + # 这一只票**自己**的跳过原因。 + assert dr["display"] == 1 and [x["ts_code"] for x in r["display_only"]] == ["600600.SH"], \ + (dr, r["display_only"]) assert len(r["items"]) == r["eligible"] - dr["capped"] == 2 # 分流开关关着: 那只仅展示的票照常进池 (分数高, 会排到第一), 计数里 display 是 0 r0 = pf.select_candidates(p, held=["SH600418"], black=["SZ300952"], tiers=["强传导"],