diff --git a/README.md b/README.md index b485867..04d1a1f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ scripts/ test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例 test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 - test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例 + test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例 test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 40 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) @@ -302,7 +302,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 ## 已实现 / 待开发 -**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 391 例**。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 393 例**。 ### 静默失败专项(2026-07-31,八条已修) diff --git a/app/core/planner.py b/app/core/planner.py index acf5fe2..7a1694f 100644 --- a/app/core/planner.py +++ b/app/core/planner.py @@ -22,6 +22,8 @@ """ from __future__ import annotations +import re + from app.core.sizer import LOT, lot_qty, split_batches, check_caps from app.core.cushion import sell_allocation # noqa: F401 (供调用方做核销预览) @@ -631,6 +633,26 @@ def _open_batch_items(ts_code, sp, price, reason_prefix="建仓"): # ================================================================ # 四、拒绝原因聚合 # ================================================================ +_REJ_CODE = re.compile(r"^([A-Z][A-Z0-9_]{2,}):") +_REJ_NUM = re.compile(r"\d+(?:[,\d]*)(?:\.\d+)?%?") + + +def _reason_key(text: str) -> str: + """把一条拒绝原因归一成"同类原因同一个键"。 + + 有判据码 (`SECTOR_RATIO: …`) 就用码; 没有码的把**数字抹成 …** 再当键 —— 价格、金额、 + 只数每只票都不一样, 不抹掉就永远聚不到一起。括号里的尾注 (「(已尝试: CUSTOM → …)」) + 也去掉, 它是排查细节不是分类依据, 原始 rejects 清单里一个字没少。 + """ + s = str(text or "").strip() + m = _REJ_CODE.match(s) + if m: + return m.group(1) + s = s.split("(")[0].split("(")[0].strip() + s = _REJ_NUM.sub("…", s) + return s[:40].strip() or "(无原因)" + + def summarize_rejects(rejects) -> str: """把 rejects 清单聚合成一行人话。 @@ -640,7 +662,13 @@ def summarize_rejects(rejects) -> str: 端到面上。**"拒了 10 只"和"没有候选"是两件完全不同的事, 长得却一样。** 输出按拒绝码归类计数, 多的排前面: - "SECTOR_RATIO 10 只 (600000.SH…); STOCK_CAP 2 只 (600519.SH…)" + "SECTOR_RATIO 10 只 (600000.SH…); 目标金额 … 元按现价 … 买不足一手 8 只 (600764.SH…)" + + **没有判据码的原因必须先把数字抹掉再归类** (2026-08-03 实机修): 原来对无码原因是 + `text[:20]` 硬截 20 个字, 结果两件事同时坏掉 —— 一是价格在截断范围内, 8 只票 8 个价, + 于是聚成 8 组、一组一只, 聚合等于没做, 输出比原始清单还长; 二是截断落在词中间, 印出 + "…按现价 18.68 买" / "…按现价 6.47 买不" 这种半句话。聚合本来是为了让人一眼看懂 + "为什么这么少", 结果自己成了噪音源。 """ if not rejects: return "" @@ -648,10 +676,7 @@ def summarize_rejects(rejects) -> str: for r in rejects or []: code = r.get("ts_code") for reason in (r.get("reasons") or []): - # 判据码是冒号前那一段 (SECTOR_RATIO / STOCK_CAP / MAX_NAMES …); - # 一手不可行那类没有码, 整句截断当分类名 - text = str(reason) - key = text.split(":", 1)[0].strip() if ":" in text[:24] else text[:20] + key = _reason_key(str(reason)) slot = by.setdefault(key, {"n": 0, "codes": []}) slot["n"] += 1 if code and code not in slot["codes"]: diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 9084a79..5bcc2b5 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -13,10 +13,10 @@ test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例) - test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 例) + test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例) test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (40 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 391 例 + 共 393 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch9_units.py b/scripts/test_batch9_units.py index ebc8728..660fd48 100644 --- a/scripts/test_batch9_units.py +++ b/scripts/test_batch9_units.py @@ -426,6 +426,35 @@ def t_g4(): assert "1 只" in s and "股A" in s +@case("[G5] 无码原因**先抹掉数字再归类** —— 价格各不相同也要聚成一组") +def t_g5(): + # 2026-08-03 实机: 8 只票同一个原因、8 个不同价格, 原来按 text[:20] 硬截, + # 价格落在截断范围内 → 聚成 8 组一组一只 (聚合等于没做), 而且截断落在词中间, + # 印出「…按现价 18.68 买」「…按现价 6.47 买不」这种半句话。 + rej = [{"ts_code": c, "reasons": [f"目标金额 60 元按现价 {p} 买不足一手 " + f"(已尝试: CUSTOM → BASE/FILL → BASE)"]} + for c, p in (("600764.SH", "18.68"), ("000800.SZ", "6.47"), + ("600104.SH", "11.22"), ("002594.SZ", "95.77"))] + s = summarize_rejects(rej) + assert s.count("只") == 1, f"没聚成一组: {s}" + assert "4 只" in s, s + assert "买不足一手" in s, f"截断落在词中间了: {s}" + assert "18.68" not in s and "95.77" not in s, f"具体价格不该出现在聚合标签里: {s}" + assert "已尝试" not in s, f"括号尾注是排查细节, 不该进分类标签: {s}" + + +@case("[G6] 有码与无码混在一起时各归各的, 仍按条数降序") +def t_g6(): + rej = [{"ts_code": "A", "reasons": ["目标金额 60 元按现价 1.00 买不足一手"]}, + {"ts_code": "B", "reasons": ["目标金额 60 元按现价 2.00 买不足一手"]}, + {"ts_code": "C", "reasons": ["目标金额 60 元按现价 3.00 买不足一手"]}, + {"ts_code": "D", "reasons": ["SECTOR_RATIO: 行业[白酒]占组合将达 55.0% > 上限 40%"]}, + {"ts_code": "E", "reasons": ["STOCK_CAP: 单股占规模将达 9.1% > 上限 8%"]}] + s = summarize_rejects(rej) + assert s.index("3 只") < s.index("SECTOR_RATIO"), f"没按条数降序: {s}" + assert "SECTOR_RATIO 1 只" in s and "STOCK_CAP 1 只" in s, s + + # ================================================================ [H] 一手取整的记账口径 # 2026-07-31 实机: 命令进度写着 planned_amount=600,000 / gap=0 ("完全满足"), 而 15 条方案 # 的金额合计只有 573,883 —— 少买 26,117 元 (4.4%) 且账面上完全看不出来。