diff --git a/app/core/action_engine.py b/app/core/action_engine.py
index 92d50d0..553329d 100644
--- a/app/core/action_engine.py
+++ b/app/core/action_engine.py
@@ -689,6 +689,10 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
"logic_state": c.get("logic_state"),
# 安全边际三情景的整句 (2026-09-07 第四件): 只给人看, 不送研判。
"valuation_text": c.get("valuation_text"),
+ # 催化事件与定价状态 (2026-09-08 量价研判链 3.5): 原值进账本与提议卡; 两句整句另送研判
+ # (它们是事件与量价, 不是产业逻辑, 见 judge.OPEN_JUDGE_KEYS)。不参与任何判定。
+ "events": c.get("events"), "events_text": c.get("events_text"),
+ "pricing_state": c.get("pricing_state"), "pricing_text": c.get("pricing_text"),
# ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉,
# 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。
"target_pct": target_pct, "target_amount": round(full_amt, 2),
diff --git a/app/services/judge.py b/app/services/judge.py
index 2b09aa9..b3f32af 100644
--- a/app/services/judge.py
+++ b/app/services/judge.py
@@ -43,7 +43,12 @@ OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat",
# 上游候选卡的判决与理由 (2026-09-02 起随计划下发)。决策系统那侧是逐键
# 渲染进提示词的, 所以这五项放行之后不用改它的提示词就能看见; 配合下面
# must_answer 里那条「每条理由到今天是否仍成立」, 研判就有了可逐条核对的靶子。
- "verdict", "reasons", "missing", "risk", "card_rank")
+ "verdict", "reasons", "missing", "risk", "card_rank",
+ # 催化事件与定价状态的整句 (2026-09-08《量价研判链吸收方案》3.5): 它们是事件与量价,
+ # 正是研报里决策智能体的"事件上下文", 不是产业逻辑 —— 与判决依据、论断、逻辑状态、
+ # 安全边际那几项不同, 这两句要送。送整句不送字典: 研判提示词把硬数字逐行印成
+ # "键: 值", 字典会印成一串代码, 整句模型才读得顺。
+ "events_text", "pricing_text")
# 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答
# 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。
diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py
index 0256cbc..849eb1b 100644
--- a/app/services/plan_feed.py
+++ b/app/services/plan_feed.py
@@ -167,6 +167,39 @@ def _logic_state_or_none(v):
"reasons": _list_or_none(v.get("reasons"), limit=LOGIC_STATE_REASONS_KEEP) or []}
+def _events_or_none(v):
+ """催化事件归一 (2026-09-08): 上游给 {latest, count, events:[{date, types, orgs, title, n_reports, compound}]}。
+ 只收约定的键, 事件最多五条; 不是字典或没有事件一律 None。"""
+ if not isinstance(v, dict) or not isinstance(v.get("events"), list) or not v["events"]:
+ return None
+ evs = []
+ for e in v["events"][:5]:
+ if not isinstance(e, dict) or not _text_or_none(e.get("date")):
+ continue
+ evs.append({"date": _text_or_none(e.get("date")), "types": _list_or_none(e.get("types")) or [],
+ "orgs": _list_or_none(e.get("orgs")) or [], "title": _text_or_none(e.get("title")),
+ "n_reports": _int_or_none(e.get("n_reports")), "compound": bool(e.get("compound"))})
+ if not evs:
+ return None
+ return {"latest": _text_or_none(v.get("latest")) or evs[0]["date"],
+ "count": _int_or_none(v.get("count")) or len(evs), "events": evs}
+
+
+_PRICING_NUM_KEYS = ("pre5", "pre20", "gap", "intraday", "close_pos", "vol_ratio", "day_pct")
+
+
+def _pricing_or_none(v):
+ """定价状态归一 (2026-09-08): state 是四情形之一或 None (算不出时 why 写原因), 六个数与涨停标记原样收。"""
+ if not isinstance(v, dict) or not _text_or_none(v.get("event_date")):
+ return None
+ out = {"state": _text_or_none(v.get("state")), "why": _text_or_none(v.get("why")),
+ "event_date": _text_or_none(v.get("event_date")), "has_event": bool(v.get("has_event")),
+ "limit_up": (None if v.get("limit_up") is None else bool(v.get("limit_up")))}
+ for k in _PRICING_NUM_KEYS:
+ out[k] = _float_or_none(v.get(k))
+ return out
+
+
def _list_or_none(v, limit: int = 0):
"""字符串列表归一: 列表逐项去空白、丢空项, 单个字符串当一项; 缺失或类型不对一律 None。
@@ -225,6 +258,12 @@ def _rows(raw, bucket: str) -> list:
# 安全边际三情景的整句 (2026-09-07 第四件): 上游按盈利预测算的悲观/中性/乐观估值与赔率,
# 只给人看 (提议卡与账本), 不参与任何判定, 也不送研判。
"valuation_text": _text_or_none(it.get("valuation_text")),
+ # 催化事件与定价状态 (2026-09-08《量价研判链吸收方案》3.5): 原值进硬数字给人看与复盘,
+ # 两句整句送研判 (judge.OPEN_JUDGE_KEYS 收 events_text / pricing_text)。都不进任何判定。
+ "events": _events_or_none(it.get("events")),
+ "events_text": _text_or_none(it.get("events_text")),
+ "pricing_state": _pricing_or_none(it.get("pricing_state")),
+ "pricing_text": _text_or_none(it.get("pricing_text")),
})
return out
@@ -417,7 +456,9 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N
# 逻辑状态 (2026-09-07 第三件): 动作引擎放进硬数字; 判决候选而逻辑存疑的在 scan_open
# 里强制交人。它不改判决、不改排序 —— 收敛规则是单调的 (选股系统 logic_state.apply_to_card)。
"logic_state": r.get("logic_state"),
- "valuation_text": r.get("valuation_text")}
+ "valuation_text": r.get("valuation_text"),
+ "events": r.get("events"), "events_text": r.get("events_text"),
+ "pricing_state": r.get("pricing_state"), "pricing_text": r.get("pricing_text")}
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 695ff78..b524c8e 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -1029,6 +1029,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
选股系统:{{ propVerdictLine(p) }}
{{ propBasis(p) }}
研报说:{{ propLogic(p) }}(另有 {{ propLogicMore(p) }} 条)
+
+ {{ propText(p, 'valuation_text') }}
+ {{ propText(p, 'events_text') }}
+ {{ propText(p, 'pricing_text') }}
{{ propWhy(p) }}
约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }}
· 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}
@@ -2574,6 +2579,11 @@ createApp({
// 公司好在哪, 每行自带「——出处:《研报标题》 披露日」。卡片窄, 只摆第一条论断,
// 但那一条必须原样显示到出处与日期为止 —— 一条 2024 年的论断与一条上周的, 人得一眼分得出。
function propBasis(p) { return (p.hard_numbers || {}).basis || ''; }
+ // 硬数字里由上游写好的整句 (安全边际 / 催化事件 / 定价状态): 原样显示, 不是字符串就不显示。
+ function propText(p, key) {
+ const v = (p.hard_numbers || {})[key];
+ return (typeof v === 'string' && v.trim()) ? v : '';
+ }
function propLogic(p) {
const l = (p.hard_numbers || {}).logic;
return Array.isArray(l) && l.length ? l[0] : '';
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 3d43271..ed79cdd 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -59,16 +59,16 @@
test_batch22_units.py 逻辑状态四态接入 (2026-09-07 第三件): 解析层收逻辑状态/硬数字带它而研判
白名单不收/判决候选而逻辑存疑强制交人/持仓存疑停增持侧/研究走弱
减持默认关且开了必交人/同轮减持优先级/早上取回与映射新鲜度/策略
- 买入腿按来源暂停恢复/持仓视图两栏三情形/假仓库签名/安全边际整句透传/参考目标价 (17 例)
+ 买入腿按来源暂停恢复/持仓视图两栏三情形/假仓库签名/安全边际整句透传/参考目标价/催化事件与定价状态透传 (18 例)
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 例)
- 共 678 例
+ 共 679 例
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
- 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 现为 678)
+ 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 现为 679)
任一子集失败即整体失败 (退出码 1)。
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
diff --git a/scripts/test_batch22_units.py b/scripts/test_batch22_units.py
index 90a2e25..54e2a14 100644
--- a/scripts/test_batch22_units.py
+++ b/scripts/test_batch22_units.py
@@ -438,6 +438,41 @@ def _():
assert nv["state"] == "逻辑成立" and nv["settle_note"] == "维持" and "研报论断" in nv["text"], nv
+@case("催化事件与定价状态·解析层归一, 候选与硬数字带原值与整句, 研判白名单只收两句整句 (量价研判链 3.5)")
+def _():
+ ev = {"latest": "2026-09-01", "count": 2, "events": [
+ {"date": "2026-09-01", "types": ["上调盈利预测", "业绩超预期"], "orgs": ["甲", "丁"], "title": "x", "n_reports": 2, "compound": True},
+ {"date": "2026-08-20", "types": ["深度覆盖"], "orgs": ["乙"], "title": "y", "n_reports": 1, "compound": False},
+ "不是字典"]}
+ ps = {"state": "价格发现", "why": "事件前没涨", "event_date": "2026-09-01", "has_event": True, "pre5": 0.01,
+ "pre20": 0.02, "gap": 0.0, "intraday": 0.01, "close_pos": 0.8, "vol_ratio": 2.0, "day_pct": 0.03,
+ "limit_up": False, "history_days": 40}
+ row = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "events": ev, "events_text": "催化事件:…",
+ "pricing_state": ps, "pricing_text": "定价状态(事件日 2026-09-01):价格发现。…"}], "main")[0]
+ assert row["events"]["count"] == 2 and len(row["events"]["events"]) == 2, row["events"]
+ assert row["events"]["events"][0]["compound"] and row["events"]["events"][0]["orgs"] == ["甲", "丁"]
+ assert row["pricing_state"]["state"] == "价格发现" and row["pricing_state"]["vol_ratio"] == 2.0
+ assert "history_days" not in row["pricing_state"] and row["pricing_state"]["limit_up"] is False
+ assert row["events_text"].startswith("催化事件") and row["pricing_text"].startswith("定价状态")
+ empty = pf._rows([{"code": "600000.SH", "rank": 1, "score": 210, "events": None, "pricing_state": {"state": None}}], "main")[0]
+ assert empty["events"] is None and empty["pricing_state"] is None and empty["pricing_text"] is None
+ plan = {"date": "2026-09-04", "main": [row], "observe": []}
+ item = pf.select_candidates(plan, top_n=10)["items"][0]
+ assert item["events"]["latest"] == "2026-09-01" and item["pricing_state"]["state"] == "价格发现", item
+ c = {**item, "price": 10.0}
+ params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25)}
+ with _Patch() as p:
+ p(ae, "check_all_caps", lambda **kw: [])
+ p(ae, "_new_name_ctx", lambda caps, c: caps)
+ cand, why = ae.eval_open(c, params, {"names_count": 0, "max_names": 10}, 500_000)
+ hn = cand["hard_numbers"]
+ assert hn["events"]["count"] == 2 and hn["pricing_state"]["state"] == "价格发现", why
+ sent = jd._judge_hard_numbers("OPEN", hn)
+ assert sent["events_text"] == row["events_text"] and sent["pricing_text"] == row["pricing_text"], sent
+ assert "events" not in sent and "pricing_state" not in sent and "valuation_text" not in sent, sent
+ assert "events_text" in jd.OPEN_JUDGE_KEYS and "pricing_text" in jd.OPEN_JUDGE_KEYS
+
+
@case("参考目标价·状态应答里的估值子字典归一, 持仓行只在算得出时给 ref_target, 算不出或缺就不给")
def _():
val = {"neut": 56.05, "pess": 42.64, "opt": 84.96, "price": 50.17, "quarter": "2026Q4", "firms": 8,