From 7d670b364cebd295fdf096ae3a6b7abe6cef01d3 Mon Sep 17 00:00:00 2001 From: zlt Date: Tue, 8 Sep 2026 11:44:33 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8F=E4=BB=B7=E7=A0=94=E5=88=A4=E9=93=BE?= =?UTF-8?q?=203.5=EF=BC=9A=E5=82=AC=E5=8C=96=E4=BA=8B=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E7=8A=B6=E6=80=81=E9=9A=8F=E5=80=99=E9=80=89?= =?UTF-8?q?=E8=BF=9B=E7=A1=AC=E6=95=B0=E5=AD=97=E4=B8=8E=E6=8F=90=E8=AE=AE?= =?UTF-8?q?=E5=8D=A1=EF=BC=8C=E4=B8=A4=E5=8F=A5=E6=95=B4=E5=8F=A5=E9=80=81?= =?UTF-8?q?=E7=A0=94=E5=88=A4=EF=BC=9B=E6=8F=90=E8=AE=AE=E5=8D=A1=E8=A1=A5?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=AE=89=E5=85=A8=E8=BE=B9=E9=99=85=E6=95=B4?= =?UTF-8?q?=E5=8F=A5=EF=BC=88=E7=AC=AC=E4=BA=8C=E5=8D=81=E4=BA=8C=E6=89=B9?= =?UTF-8?q?=E5=8A=A0=E4=B8=80=E4=BE=8B=EF=BC=8C=E5=85=A8=E5=A5=97=20679?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/action_engine.py | 4 ++++ app/services/judge.py | 7 +++++- app/services/plan_feed.py | 43 ++++++++++++++++++++++++++++++++++- app/web/static/index.html | 10 ++++++++ scripts/run_tests.py | 6 ++--- scripts/test_batch22_units.py | 35 ++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 5 deletions(-) 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,