"今天没建仓的"那一栏只写原因,右侧不再打印计数字典

两处都是用户指出来的。

第一处,那一栏原先写「选股系统判为仅展示,不出提议:门槛未过:started」。
「仅展示」「不出提议」是内部说法,started 是门槛在代码里的变量名,绕了两层还是
没说清为什么不买。现在直接用判决依据那一句完整中文,例如「当日涨幅 -3.2%,没到
3% 的启动线」。旧版计划没有这个字段时退回一句大白话,不留空。

第二处,右侧显示过「落选明细 {'held': 0, 'black': 0, ... 'display': 83}」——
把计数字典原样拼进了字符串,键全是英文,值为零的也全列出来,读的人要在十一个
数字里自己找哪个不是零。现在只念不为零的那几项,按筛选实际发生的顺序写成一句话:
「今天考察了 83 只票,一只都没留下:选股系统判定今天不买 83 只。」

测试:改两条钉旧文案的断言,新增一条钉住计数字典不许再原样打印,另在跳过原因那条
用例里加了对代码名与内部说法的逐词检查。例数从 653 到 654。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-04 10:28:35 +08:00
parent 15b6eea906
commit d85fe6731b
4 changed files with 77 additions and 22 deletions

View File

@ -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):

View File

@ -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: 预估占用金额}。

View File

@ -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 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):

View File

@ -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")