diff --git a/README.md b/README.md index 51dc2c4..0dcd813 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ scripts/ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例 - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 46 例 + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 48 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) check_db.py 实机连通性与表结构自检 (需真实 .env) @@ -304,7 +304,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 ## 已实现 / 待开发 -**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 399 例**。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 401 例**。 ### 静默失败专项(2026-07-31,八条已修) diff --git a/app/services/executor.py b/app/services/executor.py index e63f37c..bd28693 100644 --- a/app/services/executor.py +++ b/app/services/executor.py @@ -22,7 +22,7 @@ from app.core import command_spec as cs from app.core import exec_timing as et from app.core import rule_gate from app.core import tradedays as td -from app.repo import pms_repo +from app.repo import pms_repo, qmt_repo from app.services import (command_service, dispatcher, industry, market, param_store, portfolio) @@ -153,8 +153,7 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: pos = _pos_of(view, code) quota = et.daily_quota(remaining, tdays_left, allow_odd_tail=(ins.get("action") == "EXIT")) - fired_today = sum(int(c.get("qty") or 0) for c in children - if int(c.get("ymd") or 0) == ymd_today) + fired_today = _consumed_today(children, ymd_today) day_ctx = {**day, "support": pos.get("support_ref"), "limit_up": _limit_up(day), "limit_down": _limit_down(day), "halted": not day or not day.get("price")} @@ -351,6 +350,49 @@ def _child_id(instruction_id: str, n: int) -> str: return f"{instruction_id}_D{n:02d}" +def _consumed_today(children: list, ymd_today: int) -> int: + """今日已投放量 —— **到期/被撤/被拒且一股没成的分片不算数**。 + + 2026-08-03 实机: 002518.SZ 的分片挂到 TTL 到期没成交, 当天配额却显示「已出完」, + 一股没买到而当日不再尝试。这跟 `exec_timing.slice_deadline` 自己写的设计本意直接 + 冲突 —— 那里说得很清楚:「普通分片只给 ttl_min 个交易分钟, run_tick 每分钟重评一次, + **撤掉重下比挂着更好**」。可只要作废的分片仍占着当日额度, 重下就永远轮不上。 + + 配额的语义是「今天投放多少」, 但**已经作废且零成交的投放不该占额度** —— 那笔钱没花 + 出去, 敞口也没建立, 它什么都没发生。所以按委托的真实结局算: + * 还在途 (未终态) → 按下发量算, 它随时可能成交 + * 已终态 → 只算真正成交的 cum_qty, 未成交部分释放回配额 + * 查不到出口行 → 按下发量算 (影子模式没有出口行; 宁可少投也不重复投) + """ + total = 0 + for c in children or []: + if int(c.get("ymd") or 0) != ymd_today: + continue + qty = int(c.get("qty") or 0) + ref = c.get("ref") + if not ref or str(ref).startswith("manual:"): + total += qty # 影子模式: 没有出口行可查 + continue + try: + o = qmt_repo.get_order(ref) + except Exception: + logger.warning("[配额] 反查出口行失败 %s, 本轮按下发量计入", ref) + total += qty + continue + if not o: + total += qty + continue + if str(o.get("status") or "").upper() in qmt_repo.FINAL: + done = int(o.get("cum_qty") or 0) + if done < qty: + logger.info("[配额] %s 已终态 %s, 成交 %s/%s —— 未成交部分释放回当日配额", + ref, o.get("status"), done, qty) + total += done + else: + total += qty + return total + + def _limit_up(day: dict) -> bool: """一字板粗判: 当日最高=最低=现价 且 涨幅为正 (无涨跌停价字段时的兜底口径)。""" if not day: diff --git a/scripts/run_tests.py b/scripts/run_tests.py index db1b281..83bdde4 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -14,9 +14,9 @@ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例) - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (46 例) + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 399 例 + 共 401 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch10_units.py b/scripts/test_batch10_units.py index c5c3f93..7324841 100644 --- a/scripts/test_batch10_units.py +++ b/scripts/test_batch10_units.py @@ -910,6 +910,56 @@ def _(): assert "已下发但父指令未更新" in src, "下发成功但更新影响 0 行时没有留出口" +# ================================================================ +# [N] 当日配额: 作废且零成交的分片不该占额度 +# ================================================================ +@case("[N1] 分片到期未成交 → 未成交部分释放回当日配额 (否则「撤掉重下」永远轮不上)") +def _(): + from test_wiring import install_fakes + from app.services import executor + from app.repo import qmt_repo + install_fakes(prices={}) + orders = {"P_D01": {"status": "EXPIRED", "cum_qty": 0}, + "P_D02": {"status": "SUBMITTED", "cum_qty": 0}, + "P_D03": {"status": "FILLED", "cum_qty": 100}, + "P_D04": {"status": "EXPIRED", "cum_qty": 40}} + orig = qmt_repo.get_order + try: + qmt_repo.get_order = lambda ref: orders.get(ref) + kids = [{"ymd": 20260803, "qty": 100, "ref": r} for r in + ("P_D01", "P_D02", "P_D03", "P_D04")] + # D01 到期零成交 → 0; D02 在途 → 按下发量 100; D03 全成 → 100; D04 到期成 40 → 40 + assert executor._consumed_today(kids, 20260803) == 240 + # 不是今天的分片一概不算 + assert executor._consumed_today( + [{"ymd": 20260731, "qty": 999, "ref": "P_D01"}], 20260803) == 0 + finally: + qmt_repo.get_order = orig + + +@case("[N2] 影子模式 / 查不到出口行 → 按下发量算 (宁可少投也不重复投)") +def _(): + from test_wiring import install_fakes + from app.services import executor + from app.repo import qmt_repo + install_fakes(prices={}) + orig = qmt_repo.get_order + try: + qmt_repo.get_order = lambda ref: None + assert executor._consumed_today( + [{"ymd": 20260803, "qty": 100, "ref": "manual:INS_X"}], 20260803) == 100 + assert executor._consumed_today( + [{"ymd": 20260803, "qty": 100, "ref": "P_D01"}], 20260803) == 100 + + def boom(ref): + raise RuntimeError("库挂了") + qmt_repo.get_order = boom + assert executor._consumed_today( + [{"ymd": 20260803, "qty": 100, "ref": "P_D01"}], 20260803) == 100 + finally: + qmt_repo.get_order = orig + + # ================================================================ def main(): ok = fail = 0