PMS 补收研究理由:判决依据与因果论断到达人眼前

选股系统每行已带判决依据与因果论断,但计划解析只收五个键,研究结论走到 PMS 门口就断了。现在增收这两个键:判决依据是一句话解释,因果论断带出处与披露日、截三条(与上游封顶同数)。两者只进评审账本与提议卡给人看,不参与任何判定;绝不进送研判的键清单,产业逻辑不归择时决策系统判,单测钉住这条边界。提议卡新增一行显示研报说了什么,并标出还有几条。
空列表与缺键分得开:上游说这票没论断是空列表,上游没给这个字段才是 None,与既有五键一个口径。
测试:全套 ALL SUITES PASS,例数 652。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-03 23:50:44 +08:00
parent 4589539a0c
commit fd74dd197f
5 changed files with 87 additions and 4 deletions

View File

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

View File

@ -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,

View File

@ -1027,6 +1027,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="spacer"></span><span class="muted" style="font-size:11px">{{ p.expire_at }} 前有效</span></div>
<div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ p.judge_reason }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(置信度 {{ (p.hard_numbers||{}).judge_conf }}</span></div>
<div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>判为「{{ (p.hard_numbers||{}).verdict }}」<span v-if="propReasons(p).length">{{ propReasons(p).join('') }}</span></div>
<div class="st muted" v-if="propBasis(p)">{{ propBasis(p) }}</div>
<div class="st" v-if="propLogic(p)"><span class="muted">研报说:</span>{{ propLogic(p) }}<span class="muted" v-if="propLogicMore(p)">(另有 {{ propLogicMore(p) }} 条)</span></div>
<div class="st muted">{{ propWhy(p) }}</div>
<div class="st">约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }}
· 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}</div>
@ -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 '浮盈够厚,条件符合就会自动加仓。';

View File

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

View File

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