diff --git a/QMT_SIDE_S3_CLOSEOUT.md b/QMT_SIDE_S3_CLOSEOUT.md
index e79557e..cbfac15 100644
--- a/QMT_SIDE_S3_CLOSEOUT.md
+++ b/QMT_SIDE_S3_CLOSEOUT.md
@@ -150,6 +150,40 @@ sender = asyncio.create_task(self._sender_loop(websocket, queue))
2026-07-29 首次接管 22 只持仓时踩过一次。我方取值优先级已有单测守着,**但数据本身对不对
只能靠贵方**。`available_quantity`(T+1 可卖)同理,请填合理值。
+**5.1 先确认一件事:模拟仓现在那 1100 股是哪来的?**
+
+07-30 12:00 我方收到的 `query_positions` 回应(字段完全按 §5.7,这点很干净):
+
+```json
+{"kind": "positions", "as_of": 1785384340542, "items": [
+ {"ts_code": "600000.SH", "total_qty": 1100, "avail_qty": 1100, "frozen_qty": 0,
+ "cost_price": 9.2732, "market_price": 9.27}]}
+```
+
+`1100 股 @ 9.2732` 这两个数我方认得——**清账前 PMS 账本里那笔来历不明的持仓,成本正好是 9.273**,
+而它是 07-29 联调期那批测试买单成交出来的(我方当时误把停机遗留的联调成交当外部成交入了账)。
+所以想确认:**模拟仓这 1100 股是不是同一批联调单的产物?**
+
+- **是** → 请连它一起清掉,我方这边不认领;测试数据进了账本,后面每一步都建在错的基础上
+- **不是** → 请说明 `cost_price 9.2732` 的来历,我方按真实持仓认领
+
+在得到答复前,我方已停掉日终结算调度,不会自动认领。
+
+**5.2 装模拟持仓时,希望能覆盖这几种情形**
+
+只有一只、且成本≈现价(浮动盈亏 ≈ 0)的话,我方好几条纪律根本验不到——安全垫是盈利加仓
+(≥3%)、保垫减仓(峰值 ≥6%)、补仓评估档(−8% / −15%)**共同的判断依据**,全 0 就等于这些
+逻辑一条都跑不到。希望能有:
+
+| 情形 | 目的 |
+|---|---|
+| 一只明显浮盈(现价高于成本 5% 以上) | 验盈利加仓与保垫减仓 |
+| 一只明显浮亏(现价低于成本 10% 以上) | 验补仓评估档 |
+| 一只当日买入(`avail_qty < total_qty`) | 验 T+1 可卖量口径 |
+| 3~5 只、分散在不同板块 | 验组合上限与行业集中度 |
+
+数量不用大,每只 100~1000 股即可——我方验的是逻辑分支,不是金额。
+
**另外两件请提前通知我方的事:**
1. **重建模拟环境会不会重置 seq / 清空存上行消息的 Redis?** 目前我方水位 8052,与贵方
@@ -176,7 +210,7 @@ C2 只覆盖了 `trading_buy_plan` 这一条路,「直接执行决策系统卖
## 优先级建议
-**1(阻断切换) > 5(数据正确性,做在装持仓之前) > 3(安全底座) > 2 > 4**
+**5.1(一句话就能答,卡着我方认领)> 1(阻断切换)> 5 / 5.2(数据正确性,做在装持仓之前)> 3(安全底座)> 2 > 4**
第 1 条改完 + 第 5 条按口径装好持仓,我方即可进入「小仓位实盘」;第 3 条不查明,签名这条
安全底座上就一直挂着一个问号。
diff --git a/WS_INTEGRATION_STATUS.md b/WS_INTEGRATION_STATUS.md
index 4e6081b..8d16444 100644
--- a/WS_INTEGRATION_STATUS.md
+++ b/WS_INTEGRATION_STATUS.md
@@ -63,6 +63,9 @@
| 回放游标 | 重新 seed 到 `SELL_688819.SH_1774588308`(字典序锚点,此后不追认历史) |
| `PMS_RECON_STREAK` | 归零 |
| `PMS_SIGNAL_ENABLED` | **False**(清账期间关的)。**账本重建完必须打开**,理由见 §7 |
+| `pms-beat` | **停着**(07-30 12:xx 主动停的)。原因:账本空时爆炸半径闸放行,15:10 的 `daily_settle` 会**无条件**认领 `trading_position` / ws 快照里的东西,而那 1100 股来历待对端确认(§5.1)。`pms-web` / `pms-ws` 照常跑——页面能看,ws 继续拉快照 |
+| `PMS_TOTAL_SCALE` | **98 万**(07-30 由 200 万改,与模拟账户 `total_asset 981448.56` 对齐)。实盘前按真实投入资金重设 |
+| ws 持仓/资金快照 | ✅ 已通。`query_positions` / `query_funds` 每 300 秒一轮,对端字段完全按 §5.7。持仓 1 条:`600000.SH 1100 @ 9.2732`;资金:`total_asset 981448.56 / available_cash 971251.56 / sell_return_today 0` |
| 下游 `trading_buy_plan` 轮询 | **已于 2026-07-30 10:55 停止**——`QMT_INTERFACE_REQUIREMENTS.md` C2 那个「当前最大未决项」至此闭环 |
调度恢复后一轮四个调度位全部空转正常:`signal_digest` 报「已关闭」、`command_poll` 空、
@@ -122,6 +125,31 @@ trade 引用了 `4ec5ce4c` / `f0efcf41` / `43e47c39` 三个**出口表里根本
| 反查报错时一笔都不判(07-30) | 同上 | 原先把「库抖了」当「查不到」→ 当真单入账,一次超时就能造出一笔外部成交 |
| 挂起数字露出通道状态(07-30) | `dispatcher.channel_status().orphan_held` + `ws_smoke status` | 挂起的行不再进 `inbox_pending`,没人报数就是一笔无人知晓的漏账 |
+**07-30 午间两笔(都是「协议定了但代码没实现」,不是 bug)**
+
+| 补的东西 | 位置 | 为什么之前看不见 |
+|---|---|---|
+| **ws 快照对账通路**(协议 §6.2) | `runner._query_loop`(第五个协程)+ `ledger_service.positions_source` + `ws_codec.parse_positions_snapshot` | 协议 §6.2 写着「这是 PMS 侧既有的对账引擎」,实际一行都没有:不发 `query_*`、`snapshot`/`position_update` 落库即 `processed=2` 无人读、`reconcile` 只认 `trading_position` 表。而那张表在新架构下没有写入方——07-30 实测 QMT 已切模拟仓且功能正常,表却是空的(`fetch_positions` 的 `columns` 三个 `None` 就是铁证) |
+| **买入前资金校验**(A3 的原意) | `portfolio.cash_view` + `rule_gate._check_cash` | 全系统只有一个 `cash_est = scale − 市值`,那是**从参数算出来的虚数**。scale=200 万而账户真有 98 万,规划器排出的方案规则闸一路放行,要等 QMT 回 `INSUFFICIENT_CASH` 才被拒;而 reject 不自动重发,择时下一跳又算又发又拒——页面看着正常,实际一单也下不去 |
+| **偏差可见** | 页面「账户可用」指标 + 偏差 >10% 的 banner(`scaleGap`);日报关注区 `规模与账户不符` / `资金快照未接通`(阈值 `PMS_SCALE_GAP_ALARM`,默认 0.10) | 上面那条洞之所以到 07-30 才发现,就是因为**页面上完全看不出异常**:只显示 `cash_est`,而它永远等于 `scale − 市值`,看起来总是很充裕。改 `index.html` 前按 07-29 白屏那个坑做了全文体检(无自闭合自定义标签、`el-alert` 8/8、`div` 90/90) |
+
+**对账事实源的三条仲裁**(`positions_source`,ws 为主 / 表为兜底):① ws 快照新鲜 → 用 ws;
+表也非空且对不上 → 照样用 ws 但记 `SOURCE_DISAGREE`(说明表的写入方与 QMT 不同步)② ws
+缺失/过期/字段不认 → 退回表并说明**是哪种原因**(三种处理方式完全不同)③ 两个源都**没有应答**
+→ `source=none`,本端有持仓则拒绝对账、**force 也不放行**(没有读数可供人工确认,要清账走
+`reset_ledger.py`);本端也空则只留 note 不报 ERROR。
+**「应答了空集」≠「没应答」**:表查询成功返回 0 行是有效数据,归 `table` 交给爆炸半径闸处理,
+force 能放行——一开始把两者混为一谈,直接把 07-29 那个「下游读空绝不清账」用例的 force 分支挂了。
+
+**资金校验的两条口径**:`scale` 管仓位纪律(该不该买这么多)、`available_cash` 管买不买得起,
+两个不同的约束不合并;`cash_avail` 必须含 `sell_return_today`(当日回笼 T+0 可用),否则「卖一只
+买另一只」这条最常见的换仓路径会被判成资金不足。拿不到 ws 资金快照时**降级不拦但留痕**
+(`CASH_ESTIMATED`)——与研判闸同一口径,一律拒等于把通道故障升级成业务停摆。
+
+**代码格式归一**:ws 快照的 `ts_code` 强制转点式。不转的话 diff 会拿 `SH600000` 比账本里的
+`600000.SH`,**每一只都对不上**——账本那只判「下游没了」要核销、ws 那只判「新持仓」要补,
+一次格式不一致就能造出一轮双向全量重写,比读空还狠。有单测锁着。
+
**孤儿成交这条的判断依据**(值得记住的口径):ws 这条路上的 trade 必带 `instruction_id`(§5.5),
而那个 id 是 PMS 自己生成、自己写进出口表的,所以**反查不到只可能是数据不一致**,不可能是
「有人在 QMT 手工下了单」——手工成交走 `trading_order` 那条路,根本不进 inbox。两种错的代价
@@ -192,9 +220,10 @@ code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验
1. ~~确认对端实现了 R1–R4 的哪几条~~ ✅ 07-30 上午完成,结果见 §2
2. ~~清账重来~~ ✅ 07-30 11:14 完成,基线见 §3.0
3. **发函 QMT 侧**(§5.1~5.5 五条,见 `QMT_SIDE_S3_CLOSEOUT.md`)
-4. **等对端装持仓 → 认领账本**。对端装好之前,若不想让 15:10 的 `daily_settle` 拿半成品建账,
- 就先 `docker compose stop pms-beat`;装好确认 `cost_price` 无误后再 `--profile sched up -d pms-beat`。
- 探下游用 `curl -X POST '.../api/ops/reconcile?apply_fix=false'`(只看不改)
+4. **等对端答复 §5.1(那 1100 股的来历)再认领账本**。`pms-beat` 现在停着,所以 15:10 不会
+ 自动建账。答复回来后:是联调残留 → 请对端清掉、我方不认领;是真持仓 → 跑
+ `curl -X POST '.../api/ops/reconcile?apply_fix=true'` 认领,然后 `--profile sched up -d pms-beat`。
+ 探数据用 `apply_fix=false`(只看不改)或 `ws_smoke.py inbox --type snapshot --width 600`
5. **账本重建完立刻把 `PMS_SIGNAL_ENABLED` 打开**(见 §7 最后一条)
6. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单
7. 日终对账零差异 → S3 完整通过
diff --git a/app/core/rule_gate.py b/app/core/rule_gate.py
index 07a92c5..9f42782 100644
--- a/app/core/rule_gate.py
+++ b/app/core/rule_gate.py
@@ -132,10 +132,39 @@ def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard)
failed.extend(bad)
if not caps.get("sector_source_ready", True):
warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)")
+ _check_cash(failed, warns, qty, price, caps, hard)
else:
failed.append("CAPS_MISSING: 缺组合上下文, 无法校验上限 (宁可不动)")
+def _check_cash(failed, warns, qty, price, caps, hard):
+ """买入前的资金校验 (QMT_INTERFACE_REQUIREMENTS A3 的原意)。
+
+ 与组合上限是**两个不同的约束**, 所以单列一条而不是塞进 check_all_caps:
+ 上限管「该不该买这么多」—— 仓位纪律, 基准是 PMS_TOTAL_SCALE 这个用户命令参数
+ 资金管「买不买得起」 —— 账户事实, 基准是 ws 资金快照的 available_cash + 当日回笼
+ 2026-07-30 之前只有前者: scale=200 万而模拟账户实际只有 98 万, 于是排出的方案规则闸一路
+ 放行, 要等 QMT 回 `INSUFFICIENT_CASH` 才被拒; 而 reject 不自动重发, 择时下一跳又算又发
+ 又拒 —— 页面看着一切正常, 实际一单也下不去。
+
+ **拿不到真实资金时降级不拦, 但必须留痕。** 与研判闸「拿不到不算通过、也不当拒绝, 降级
+ 并记录」同一口径 —— 一律拒会让资金快照一断就把所有买入停掉, 那是把通道故障升级成业务
+ 停摆。降级期间 QMT 那层的 `INSUFFICIENT_CASH` 仍是最后一道防线, 只是会吵一点。
+ """
+ src = str(caps.get("cash_source") or "estimate")
+ avail = caps.get("cash_avail")
+ need = round(qty * price, 2)
+ hard.update({"cash_need": need, "cash_source": src,
+ "cash_avail": None if avail is None else round(_num(avail), 2)})
+ if src != "ws" or avail is None:
+ warns.append("CASH_ESTIMATED: 拿不到 ws 资金快照, 本单未按真实可用资金校验 "
+ "(scale−市值 的估算值不能当真钱用)")
+ return
+ if need > _num(avail):
+ failed.append(f"INSUFFICIENT_CASH: 需 {need:.0f} 元 > 可用 {_num(avail):.0f} 元 "
+ f"(含当日卖出回笼)")
+
+
def summarize(results: list) -> dict:
"""一批指令的闸门统计 (日报关注区用)。"""
passed = [r for r in results if r.get("passed")]
diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py
index 6157511..e945e13 100644
--- a/app/services/ledger_service.py
+++ b/app/services/ledger_service.py
@@ -865,6 +865,22 @@ def build_daily_report(ymd: int = None) -> dict:
attention.append({"type": "行情缺失", "codes": v["price_missing"]})
if not v["sector_ready"]:
attention.append({"type": "行业约束停用", "hint": "PMS_SECTOR_SOURCE 未配置"})
+ # 总规模与账户实际总资产的偏差。**这条必须每天摆出来**: 三道仓位闸 (总仓/单股/只数) 全部
+ # 以 PMS_TOTAL_SCALE 为基准算, 而它是用户命令参数, 不是账户余额。两者一旦差得远, 规划器
+ # 会排出账户根本执行不了的方案, 却一路过闸 —— 到 QMT 那儿才被 INSUFFICIENT_CASH 拒。
+ # 07-30 就是这个情形: scale 200 万 vs 模拟账户 98 万, 差一倍而页面上看不出任何异常。
+ ta, scale_v = t.get("total_asset"), float(t.get("scale") or 0)
+ if ta and scale_v > 0:
+ gap = float(ta) / scale_v - 1
+ if abs(gap) > param_store.get_float("PMS_SCALE_GAP_ALARM", 0.10):
+ attention.append({"type": "规模与账户不符", "scale": scale_v,
+ "total_asset": round(float(ta), 2), "gap": round(gap, 4),
+ "hint": "仓位上限按 scale 算, 资金校验按账户算 —— 差得远时方案会"
+ "过闸但下不出去。改 PMS_TOTAL_SCALE 或核对账户"})
+ if t.get("cash_source") != "ws":
+ attention.append({"type": "资金快照未接通", "hint": t.get("cash_why") or "",
+ "note": "买入前的资金校验已降级 (只按 scale−市值 估算), 见规则闸的 "
+ "CASH_ESTIMATED 告警"})
if td.calendar_degraded():
attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"})
diff --git a/app/services/portfolio.py b/app/services/portfolio.py
index 9a32d83..73c36ee 100644
--- a/app/services/portfolio.py
+++ b/app/services/portfolio.py
@@ -14,12 +14,57 @@ import json
import logging
from app.core import cushion as cu
-from app.repo import pms_repo
+from app.core import ws_codec as wsc
+from app.repo import pms_repo, qmt_repo
from app.services import industry, market, param_store
logger = logging.getLogger("pms.portfolio")
NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK"
+CASH_WS, CASH_EST = "ws", "estimate"
+
+
+def cash_view() -> dict:
+ """真实可用资金 (ws 资金快照 §5.7 kind=funds)。取不到则回落估算并**标明是估的**。
+
+ 为什么必须有这一层
+ ------------------
+ 原来全系统只有一个 `cash_est = PMS_TOTAL_SCALE − 持仓市值` —— 那是**从参数算出来的虚数**,
+ 与账户里真有多少钱毫无关系。`PMS_TOTAL_SCALE` 是用户命令参数 (「我打算投多少」),
+ `available_cash` 是账户事实 (「现在有多少钱」), 两者是不同的约束, 不该混:
+ * scale 管**仓位纪律** —— 单股不超几成、总仓不超几成、最多几只
+ * 可用资金管**买不买得起** —— 这一单的钱够不够
+ 07-30 实测: scale=200 万, 而模拟账户 total_asset 只有 98.1 万、available_cash 97.1 万。
+ 少了第二个约束, 规划器会按 200 万排出账户根本执行不了的方案, 规则闸一路放行, 直到 QMT
+ 回 `INSUFFICIENT_CASH` 才被拒 —— 而 PMS 收到 reject 不自动重发, 择时下一跳又算又发又拒,
+ 页面看着一切正常, 实际一单也下不去。
+
+ **`sell_return_today` 必须加进来**: 当日卖出回笼资金 T+0 可用 (协议 §5.7, A3 点名要的
+ 字段)。不加会把「刚卖掉一只、拿回笼的钱买另一只」这条最常见的换仓路径判成资金不足。
+ """
+ max_age = param_store.get_int("PMS_RECON_WS_SNAPSHOT_MAX_AGE_SEC", 900)
+ out = {"source": CASH_EST, "cash_avail": None, "total_asset": None,
+ "age_sec": None, "why": ""}
+ try:
+ snap = qmt_repo.latest_snapshot("funds")
+ except Exception as e:
+ out["why"] = f"读 ws 资金快照失败: {type(e).__name__}: {e}"
+ return out
+ if not snap:
+ out["why"] = "ws 从未回过 funds 快照 (对端未实现 query_funds, 或 pms-ws 没在跑)"
+ return out
+ age = snap.get("age_sec")
+ if age is not None and age > max_age:
+ out.update({"why": f"ws 资金快照已过期 ({age:.0f}s > {max_age}s)", "age_sec": age})
+ return out
+ f = wsc.parse_funds_snapshot(snap["payload"])
+ avail = f.get("available_cash")
+ if avail is None:
+ out["why"] = "ws 资金快照里认不出可用资金字段 (补进 ws_codec.parse_funds_snapshot)"
+ return out
+ out.update({"source": CASH_WS, "age_sec": age, "total_asset": f.get("total_asset"),
+ "cash_avail": round(float(avail) + float(f.get("sell_return_today") or 0), 2)})
+ return out
def neg_streak_map() -> dict:
@@ -91,10 +136,17 @@ def positions_view(*, with_price: bool = True) -> dict:
sector_names[s] = sector_names.get(s, 0) + 1
sector_mv[s] = sector_mv.get(s, 0.0) + x["market_value"]
cost_sum = sum((x["avg_cost"] or 0) * x["total_qty"] for x in held)
+ cash = cash_view()
totals = {
"scale": scale, "portfolio_mv": round(port_mv, 2),
"portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None,
+ # cash_est 是 scale−市值 的**估算**, 与账户里有多少钱无关;
+ # cash_avail 才是账户事实 (ws 资金快照)。两个都留着并标明来源, 免得后来者
+ # 又把估算值当真钱用 —— 见 cash_view 的说明。
"names_count": len(held), "cash_est": round(scale - port_mv, 2) if scale > 0 else None,
+ "cash_avail": cash["cash_avail"], "cash_source": cash["source"],
+ "cash_age_sec": cash["age_sec"], "cash_why": cash["why"],
+ "total_asset": cash["total_asset"],
"float_pnl": round(port_mv - cost_sum, 2) if cost_sum else 0.0,
"float_pnl_pct": round(port_mv / cost_sum - 1, 4) if cost_sum > 0 else None,
"sector_names": sector_names, "sector_mv": sector_mv,
@@ -130,6 +182,9 @@ def caps_ctx(view: dict, *, ts_code=None, is_new_name=False, sector=None) -> dic
"sector_mv_map": t["sector_mv"] if ready else {},
"sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"],
"cash_reserve": p["cash_reserve"], "sector_source_ready": ready,
+ # 真实可用资金随 caps 一起进规则闸 (executor 已经在传 caps, 不必再改它的取数)。
+ # cash_source 一定要跟着传: 规则闸靠它区分「校验过了」和「拿不到数据只能降级」。
+ "cash_avail": t.get("cash_avail"), "cash_source": t.get("cash_source", "estimate"),
}
@@ -145,7 +200,12 @@ def overview() -> dict:
"ok": True,
"scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"],
"portfolio_cap": p["portfolio_cap"], "names_count": t["names_count"],
- "max_names": p["max_names"], "cash_est": t["cash_est"], "float_pnl": t["float_pnl"],
+ "max_names": p["max_names"], "cash_est": t["cash_est"],
+ # 真钱与来源一起给页面 —— 只显示 cash_est 的话, 那个数在 scale 与账户不一致时
+ # 会让人以为钱多得是 (07-30: cash_est 199 万 vs 真实可用 97 万)
+ "cash_avail": t["cash_avail"], "cash_source": t["cash_source"],
+ "total_asset": t["total_asset"], "cash_why": t["cash_why"],
+ "float_pnl": t["float_pnl"],
"float_pnl_pct": t["float_pnl_pct"], "solid_names": t["solid_names"],
"neg_names": t["neg_names"], "autonomy": p["autonomy"],
"buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"],
diff --git a/app/web/static/index.html b/app/web/static/index.html
index db4dbe8..c02689e 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -70,12 +70,17 @@