From d85fe6731b60c58444c8378ec9e4ee9b23c28226 Mon Sep 17 00:00:00 2001 From: zlt Date: Fri, 4 Sep 2026 10:28:35 +0800 Subject: [PATCH] =?UTF-8?q?"=E4=BB=8A=E5=A4=A9=E6=B2=A1=E5=BB=BA=E4=BB=93?= =?UTF-8?q?=E7=9A=84"=E9=82=A3=E4=B8=80=E6=A0=8F=E5=8F=AA=E5=86=99?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=EF=BC=8C=E5=8F=B3=E4=BE=A7=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E8=AE=A1=E6=95=B0=E5=AD=97=E5=85=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处都是用户指出来的。 第一处,那一栏原先写「选股系统判为仅展示,不出提议:门槛未过:started」。 「仅展示」「不出提议」是内部说法,started 是门槛在代码里的变量名,绕了两层还是 没说清为什么不买。现在直接用判决依据那一句完整中文,例如「当日涨幅 -3.2%,没到 3% 的启动线」。旧版计划没有这个字段时退回一句大白话,不留空。 第二处,右侧显示过「落选明细 {'held': 0, 'black': 0, ... 'display': 83}」—— 把计数字典原样拼进了字符串,键全是英文,值为零的也全列出来,读的人要在十一个 数字里自己找哪个不是零。现在只念不为零的那几项,按筛选实际发生的顺序写成一句话: 「今天考察了 83 只票,一只都没留下:选股系统判定今天不买 83 只。」 测试:改两条钉旧文案的断言,新增一条钉住计数字典不许再原样打印,另在跳过原因那条 用例里加了对代码名与内部说法的逐词检查。例数从 653 到 654。 Co-Authored-By: Claude Opus 5 --- app/core/action_engine.py | 18 +++++++------- app/services/proposal_service.py | 40 +++++++++++++++++++++++++++++--- scripts/run_tests.py | 7 +++--- scripts/test_batch20_units.py | 34 ++++++++++++++++++++++----- 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/app/core/action_engine.py b/app/core/action_engine.py index f7272b0..72cc19f 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -97,20 +97,18 @@ WHY_DISPLAY_ONLY = "选股系统判为仅展示" def display_only_why(row) -> str: - """仅展示的票为什么没建仓, 尽量写出**这一只**的真实原因。 + """这一栏回答的是「为什么今天没买这只票」。所以它只写原因, 不写系统内部的判决名。 - 只写「选股系统判为仅展示」是不够的: 仅展示至少有三种来路, 差一个当日涨幅、 - 吸筹没到确认线、被昨夜坏信号一票否决, 三者的处置完全不同 —— 前两种明天可能就 - 翻过来, 第三种是技术面在走坏。三只票并排显示同一句话, 人看不出哪只该继续盯。 + 2026-09-04 改过一次。改之前写的是「选股系统判为仅展示, 不出提议: 门槛未过: started」, + 三处毛病: 「仅展示」「不出提议」是内部说法, 读的人不知道是什么意思; started 是门槛在 + 代码里的变量名, 不该出现在页面上; 整句话绕了两层还是没说清到底为什么不买。 - 判决依据 (basis) 是选股系统在判决时**当场写下**的一句话, 2026-09-04 起随计划下发。 - 它在就直接用它, 不在 (旧版计划没有这个键) 就退回原来那句固定文案 —— 少一个字段 - 不该让这一栏整个空掉。 + 现在直接用判决依据 —— 那是选股系统判决时当场写下的一句完整中文, 例如 + 「当日涨幅 -3.2%, 没到 3% 的启动线」或「择时决策系统昨夜给出卖出信号」。 + 旧版计划没有这个字段, 那时退回一句大白话, 不留空。 """ 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}" + return basis or "选股系统今天不建议买入" def verdict_confirm_why(verdict): diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 9f7d11e..4451edf 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -204,9 +204,7 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: "why": ae.display_only_why(row)}) items = list(sel.get("items") or []) if not items: - out["skipped"].append({"action": ae.A_OPEN, - "why": f"候选池过滤后为空 (考察 {sel.get('considered')} 只, " - f"落选明细 {sel.get('dropped')})"}) + out["skipped"].append({"action": ae.A_OPEN, "why": _no_candidate_why(sel)}) return [] codes = [x["ts_code"] for x in items] @@ -272,6 +270,42 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: return res["candidates"] +# 候选一只都不剩时, 这一栏要说清是被哪几道筛掉的。键名是代码里的, 翻成人话再显示。 +# 顺序就是筛选实际发生的顺序, 照着念下来正好是一条票走过的路。 +_DROP_CN = [ + ("held", "已经持有"), + ("black", "在黑名单里"), + ("display", "选股系统判定今天不买"), + ("st", "是 ST 类"), + ("tier", "档位不够"), + ("score", "分数不够"), + ("sources", "传导源数不够"), + ("upside", "券商预期空间不够"), + ("theme", "同一主题已经选够"), + ("dup", "和已选的重复"), + ("capped", "超出今天的名额"), +] + + +def _no_candidate_why(sel) -> str: + """今天一只候选都没有时的那句说明。 + + 2026-09-04 改过一次。改之前是把落选计数那个字典直接拼进字符串, 页面上显示成 + 「落选明细 {'held': 0, 'black': 0, ... 'display': 83}」—— 键全是英文, 值为零的 + 也全列出来, 读的人要在十一个数字里自己找出哪个不是零。 + + 现在只念不为零的那几项, 按筛选实际发生的顺序, 用中文写成一句话。 + """ + n = sel.get("considered") + dropped = sel.get("dropped") or {} + parts = [f"{cn} {dropped[k]} 只" for k, cn in _DROP_CN + if isinstance(dropped.get(k), int) and dropped[k] > 0] + head = f"今天考察了 {n} 只票,一只都没留下" if n else "今天没有票可考察" + if not parts: + return head + "。" + return head + ":" + ",".join(parts) + "。" + + def _inflight_open_commitments(view) -> dict: """在途的新建仓承诺: {names: {代码}, amount: 预估占用金额}。 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index e64522a..c715f3e 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -48,16 +48,17 @@ 开关四条件与任一缺失仍入队/信号来源区分/密钥名单 含会话密钥/研判键放行与必答题/置信度进硬数字/ 仅展示的跳过原因写出这一只自己的判决依据/ - 页面静态守卫 (16 例) + 候选一只不剩时的说明不打印计数字典/ + 页面静态守卫 (17 例) test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 653 例 + 共 654 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 - 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因一例, 现为 653) + 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 现为 654) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): diff --git a/scripts/test_batch20_units.py b/scripts/test_batch20_units.py index 3dd4329..3f04810 100644 --- a/scripts/test_batch20_units.py +++ b/scripts/test_batch20_units.py @@ -141,7 +141,7 @@ def _(): assert any(s["ts_code"] == "600001.SH" and "名额" in s["why"] for s in r["skipped"]), r["skipped"] -@case("判决分流·仅展示: 不产出、不占名额、不进任何闸, 跳过原因写明「选股系统判为仅展示」") +@case("判决分流·仅展示: 不产出、不占名额、不进任何闸, 跳过原因说清为什么不买") def _(): rows = [cand("600000.SH", score=300, verdict="仅展示", reasons=["只差覆盖"], missing=["券商覆盖"], risk=[]), @@ -149,8 +149,10 @@ def _(): r = _scan(rows, slots=1, names_count=19) assert [c["ts_code"] for c in r["candidates"]] == ["600001.SH"], r["candidates"] sk = {s["ts_code"]: s["why"] for s in r["skipped"]} - assert "仅展示" in sk["600000.SH"] and "名额" not in sk["600000.SH"], sk - assert sk["600000.SH"].startswith(ae.WHY_DISPLAY_ONLY), sk + # 这一栏回答的是「为什么今天没买」, 所以它只写原因, 不写判决名, 也不许写成 + # 「名额用完」—— 仅展示的行排在名额判断之前, 跳过原因不能被名额那一刀盖掉。 + assert "名额" not in sk["600000.SH"], sk + assert sk["600000.SH"] == "选股系统今天不建议买入", sk # 名额没被仅展示占掉: 只有一个名额, 候选那只拿到了 assert r["candidates"][0]["needs_user_confirm"] is False @@ -172,10 +174,30 @@ def _(): # 三句话必须互不相同 —— 这正是这条用例要守住的东西 assert len({sk["600000.SH"], sk["600002.SH"], sk["600003.SH"]}) == 3, sk # 判决依据缺失时退回原来那句固定文案, 不许让这一栏空掉 - assert sk["600004.SH"] == ae.WHY_DISPLAY_ONLY + ",不出提议", sk["600004.SH"] - # 每一句都仍以固定前缀开头, 页面按前缀分组的逻辑不受影响 + assert sk["600004.SH"] == "选股系统今天不建议买入", sk["600004.SH"] + # 四句话里一个代码名、一句内部说法都不许有 —— 这一栏是给人读的 for c in ("600000.SH", "600002.SH", "600003.SH", "600004.SH"): - assert sk[c].startswith(ae.WHY_DISPLAY_ONLY), sk[c] + for w in ("started", "pointed", "covered", "SELL", "AVOID", + "仅展示", "不出提议", "判决"): + assert w not in sk[c], (c, w, sk[c]) + + +@case("候选一只不剩时的说明: 只念不为零的那几项, 不把计数字典原样打印出来") +def _(): + from app.services import proposal_service as psvc + z = {"held": 0, "black": 0, "tier": 0, "score": 0, "sources": 0, "upside": 0, + "st": 0, "theme": 0, "dup": 0, "capped": 0, "display": 83} + w = psvc._no_candidate_why({"considered": 83, "dropped": z}) + assert w == "今天考察了 83 只票,一只都没留下:选股系统判定今天不买 83 只。", w + # 页面上出现过 "落选明细 {'held': 0, ...}" 这种东西, 这条就是钉住不许再出现 + for bad in ("{", "}", "'", "held", "display", "capped"): + assert bad not in w, (bad, w) + # 多项不为零时按筛选实际发生的顺序念 + w2 = psvc._no_candidate_why({"considered": 50, + "dropped": {"held": 3, "display": 40, "capped": 7}}) + assert w2.index("已经持有") < w2.index("不买") < w2.index("名额"), w2 + # 一只都没考察过时不硬拼一个空清单 + assert psvc._no_candidate_why({"considered": 0, "dropped": {}}) == "今天没有票可考察。" @case("判决分流·没有判决 (旧版计划) 时开关开着与关着产出逐字一致, 五键全 None")