From 680210ebcc8f3dca58e32106a0e2994d99b68f37 Mon Sep 17 00:00:00 2001 From: zlt Date: Mon, 3 Aug 2026 09:24:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E7=8E=B0=E6=9C=89=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++-- app/services/ledger_service.py | 42 ++++++++++++++++++++++++++++++++++ scripts/run_tests.py | 4 ++-- scripts/test_batch10_units.py | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c561c1..698076d 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 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例 - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 36 例 + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 38 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) check_db.py 实机连通性与表结构自检 (需真实 .env) @@ -302,7 +302,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 ## 已实现 / 待开发 -**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 387 例**。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 389 例**。 ### 静默失败专项(2026-07-31,八条已修) @@ -377,6 +377,7 @@ git pull → make test → ALL SUITES PASS - 账本已清空(`reset_ledger --purge-channel --reset-ws --drop-reports`),ws seq 水位归零 → pms-ws 一重连对端会从 seq 1 全量补发约 1.2 万条。账本空的时候**不要**点「成交回放」。 - 宿主机放行 docker 网段到 8300 的规则若用 `iptables -I` 加的,**重启就没了**,需 `netfilter-persistent save` 或改 firewalld permanent。不持久化的话服务器重启后候选池会静默变空。 - 自主档位仍是 `propose_only`,下发通道仍是 `shadow`。 +- **`PMS_TOTAL_SCALE` 与账户实际资金要对得上。** 两个数本来就是不同的东西(scale 是「我打算投多少」的命令参数,账户总资产是「现在有多少钱」),允许不等,但差太远会产出一堆执行不了的方案:规划器只认 scale,按 200 万 × 总仓上限排出 140 万的买入,而账户只有 98 万,多出来的会在**规则闸**被一条条判 `INSUFFICIENT_CASH`——方案排得出来、下不出去,每一步看着都正常。2026-07-31 实测就是这组数。盘前准备(`make t-pre`)现在会把两个数摆在一起,差太多就在 `scale_check` 里说破。 - 静默失败专项修完之后,**几处「以前默默过去」的地方现在会明着报失败**,这是设计如此:开关类命令(`HALT_BUY` / `RESUME_ALL` 等)写不进参数就置 `CANCELLED` 并回 `ok=False`,不再显示「已完成」;`daily_settle` 在对账被拦(爆炸半径 / 两源无应答 / 成本价体检不过)或连续不一致升到 ERROR 时 `ok=False`;`premarket` 在「该踩刹车却没踩上」时把它记进 `errors` 而不是 warnings。**看到这些报错先别急着改代码——多半是数据库或下游真的出问题了,以前只是没人告诉你。** - 新增第 15 张表 `pms_plan_snapshot`(名册快照),**部署后要跑一次 `make initdb`**,否则榜单变化那段会一直报「读名册快照失败:表不存在」——候选池不受影响。第一份快照落下之前不报任何变化(`kind=first`),这是设计如此不是坏了。 diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py index 6aab679..3e3fbad 100644 --- a/app/services/ledger_service.py +++ b/app/services/ledger_service.py @@ -991,10 +991,52 @@ def premarket() -> dict: (out["errors"] if "刹车未生效" in w else out.setdefault("warnings", [])).append(w) except Exception as e: out["errors"].append(f"刹车结算失败: {e}") + try: + s = _scale_vs_account() + out["scale_check"] = s + if s.get("warning"): + out.setdefault("warnings", []).append(s["warning"]) + except Exception as e: + out.setdefault("warnings", []).append(f"规模与账户资金比对失败: {e}") out["ok"] = not out["errors"] return out +def _scale_vs_account() -> dict: + """`PMS_TOTAL_SCALE` 与账户实际资金差太多时, 盘前就说出来。 + + 两个数是**不同的东西**, 本来就允许不等 (scale 是"我打算投多少"的命令参数, + 账户总资产是"现在有多少钱"), 但差得太远会产出一堆执行不了的方案: + 规划器只认 scale —— 按 200 万 × 总仓上限 70% 排出 140 万的买入方案, 而账户只有 98 万, + 多出来那 40 万会在**规则闸**被一条条判 INSUFFICIENT_CASH。方案表里躺着一堆 PENDING, + 评审账本里刷一屏拒绝, 每一步看着都"正常工作", 只有把两个数摆在一起才看得出病根。 + 2026-07-31 实测: scale=200 万, 模拟账户 total_asset=98.1 万。 + + 只在**拿得到真实资金**时判 (估算值是 scale−市值, 拿它比 scale 是自己跟自己比)。 + """ + v = portfolio.positions_view() + t = v["totals"] + scale = float(t.get("scale") or 0) + equity = t.get("total_asset") + out = {"scale": scale, "equity": equity, "cash_source": t.get("cash_source"), + "warning": ""} + if t.get("cash_source") != portfolio.CASH_WS or not equity or scale <= 0: + return out + equity = float(equity) + cap = param_store.get_float("PMS_PORTFOLIO_CAP", 0.6) + plan_ceiling = scale * cap + out.update({"equity": equity, "plan_ceiling": round(plan_ceiling, 2), + "ratio": round(equity / scale, 4)}) + if plan_ceiling > equity: + out["warning"] = ( + f"规模参数与账户对不上: PMS_TOTAL_SCALE={scale:,.0f} × 总仓上限 {cap:.0%} " + f"= {plan_ceiling:,.0f} 元的方案空间, 而账户总资产只有 {equity:,.0f} 元。" + f"超出的部分会在规则闸被逐条判 INSUFFICIENT_CASH —— 方案排得出来、下不出去。" + f"要么把 PMS_TOTAL_SCALE 调到 {equity / max(cap, 1e-9):,.0f} 以内, 要么先注资") + logger.warning("[盘前] %s", out["warning"]) + return out + + def _settle_brake() -> dict: """组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 0f78021..0bc48ef 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 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 例) - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (36 例) + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (38 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 387 例 + 共 389 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch10_units.py b/scripts/test_batch10_units.py index 8ed26df..c5ee254 100644 --- a/scripts/test_batch10_units.py +++ b/scripts/test_batch10_units.py @@ -749,6 +749,47 @@ def _(): ls.positions_source = orig +@case("[L6] 规模参数远大于账户资金 → 盘前就说破 (别等规则闸刷一屏 INSUFFICIENT_CASH)") +def _(): + from test_wiring import install_fakes + from app.services import ledger_service as ls, portfolio + install_fakes(prices={}) + orig = portfolio.positions_view + try: + # 2026-07-31 实测的那组数: scale 200 万 × 上限 70% = 140 万方案空间, 账户只有 98.1 万 + portfolio.positions_view = lambda **kw: { + "held": [], "positions": [], "params": {}, + "totals": {"scale": 2000000.0, "portfolio_mv": 0.0, "total_asset": 981448.56, + "cash_source": "ws", "cash_why": ""}} + s = ls._scale_vs_account() + assert s["warning"] and "INSUFFICIENT_CASH" in s["warning"], s + assert s["plan_ceiling"] > s["equity"], s + # 规模压到账户以内就不该再吵 + portfolio.positions_view = lambda **kw: { + "held": [], "positions": [], "params": {}, + "totals": {"scale": 1000000.0, "portfolio_mv": 0.0, "total_asset": 981448.56, + "cash_source": "ws", "cash_why": ""}} + assert not ls._scale_vs_account()["warning"], ls._scale_vs_account() + finally: + portfolio.positions_view = orig + + +@case("[L7] 拿不到真实资金时不比 (估算值是 scale−市值, 拿它比 scale 是自己跟自己比)") +def _(): + from test_wiring import install_fakes + from app.services import ledger_service as ls, portfolio + install_fakes(prices={}) + orig = portfolio.positions_view + try: + portfolio.positions_view = lambda **kw: { + "held": [], "positions": [], "params": {}, + "totals": {"scale": 2000000.0, "portfolio_mv": 0.0, "total_asset": None, + "cash_source": "estimate", "cash_why": "ws 没回 funds 快照"}} + assert not ls._scale_vs_account()["warning"], ls._scale_vs_account() + finally: + portfolio.positions_view = orig + + @case("[L5] 单测清单里的文件缺失不许算通过 (镜像旧了正好整批不存在)") def _(): import re