# 给 QMT 侧：S3 收尾的五件事（2026-07-30）

> 面向 QMT 侧研发。上一份是 `QMT_TEST_ENV_REQUIREMENTS.md`（R1–R4 模拟环境需求），
> **R1–R4 已于今天上午验完，主体可以划掉**——先说这个，避免重复投入。
> 本文只列剩下的五件事，按优先级排。第 1 条阻断切换，第 5 条最容易被忽略但后果最重。

---

## 0. 先说已经通了的（无需再动）

| 项 | 结果 | 实测依据 |
|---|---|---|
| **R1 可控成交** | ✅ | `600000.SH sell 100 @10.13`（涨跌停带内、高于市价）停在 `SUBMITTED`、成交 0。同代码同数量的 `@99.99` 昨天是秒成，行为确实改了 |
| **R2 代码校验** | ✅ | `999999.SH` → `reject{instruction_id, code:"UNKNOWN_CODE", reason:"instrument not found: 999999.SH"}`。三点全对：带 `instruction_id`（委托级而非协议级）、码值取自协议 §7.3 表而非自造、reason 带原文。**这一类做得很干净** |
| **R3 部分成交** | ✅ | `PARTIAL → PARTIAL → FILLED` 序列正确（见下面第 1 条，只有 `trade_no` 的格式要改） |
| **R4 到期自动撤** | ✅ | `valid_until` 到点推 `order_update status=EXPIRED`，`leaves_qty` 归零 |
| **主动撤单** | ✅ | `SUBMITTED → CANCELLED`，`leaves_qty=0`，与我方本地 `cancel_state` 一致 |
| 控制面不被业务阻塞 | ✅ | 时钟差稳定在 +0.92 秒，最慢一条上行只比最快的晚 164 ms。`ack_seq` 挪出控制面那一改很有效——昨天那个 +30 秒的投递卡顿再没出现 |

**协议 §9 的 S3「五类各至少一次」至此凑齐**（全成 / 部分成交 / 主动撤单 / 到期过期 / 拒绝）。

---

## 1. `trade_no` 的格式不合协议 §5.5（**这一条阻断切换**）

实测收到的是：

```
trade_no: "T-SHADOW-6a21…"    ← 每笔一个随机串
trade_no: "T-SHADOW-9f64…"
trade_no: "T-SHADOW-772b…"
```

协议 §5.5 定的是 **`{broker_order_id}#{n}`**，即同一张委托 `SHADOW-7c472c48f0` 的三笔应为：

```
SHADOW-7c472c48f0#1
SHADOW-7c472c48f0#2
SHADOW-7c472c48f0#3
```

**为什么这不是格式洁癖。** PMS 侧有两层去重：seq 水位（§6.1）+ `trade_no` 唯一索引（§5.5）。
随机串**当下**能去重——唯一索引照样拦得住重复。问题在**补发**：

按 §6.1 断线重连要重发 `last_seq+1` 之后的消息。如果贵方在补发时**重新生成**了随机
`trade_no`，同一笔成交就会带着两个不同的 `trade_no` 到达 PMS。第二层去重当场失效，只剩 seq
一层；而 seq 那层在我方水位回退或 inbox 清理后也不存在了。**结果是同一笔成交入账两次**——
持仓多算、摊薄成本算错，而摊薄成本是我方安全垫的分母，安全垫又是补仓/加仓/减仓的共同判据。

`{broker_order_id}#{n}` 是**确定性**的：委托号 + 第几笔，补发一万次都是同一个值。这正是协议
当初定这个格式的理由。

**请确认**：现在的 `T-SHADOW-xxx` 在补发时是原值重放，还是重新生成？如果是重新生成，
这条必须改完我们才敢切 `PMS_DISPATCH_MODE=ws`。

---

## 2. 限价超出涨跌停区间：请回 `BAD_PARAM`

实测 `600000.SH sell 100 @99.99`（浦发涨停价约 11.1，挂到市价近 10 倍）**既没被拒也没成交**，
一路挂到 `EXPIRED`。

**这一半是我们协议的锅，已经修了。** 原 §7.3 拒绝码表里：

- `LIMIT_UP` / `LIMIT_DOWN` 指的是「涨/跌停**无法成交该方向**」——价格合法，只是此刻撮不上，
  所以 `retryable=是`（换价重发有意义）
- `BAD_PARAM` 原文只写了「价格**精度**超限」
- **「限价超出当日涨跌停区间」没有落点**——贵方按表实现，找不到该回哪个码

协议已升 **V1.0.1**：`BAD_PARAM` 的含义扩为「价格不合法——精度超限，**或限价超出当日涨跌停
区间**」，并补了与 `LIMIT_UP`/`LIMIT_DOWN` 的分界说明。**消息集与状态机一字未动，贵方现有实现
不改也合规**，只是多一个校验点。

请在模拟环境补上这个校验：越界限价 → `reject{code:"BAD_PARAM", retryable:false}`。
（顺带提一句：变更记录表的顺序我们也修了，原先 V1.0 那行排在 V0.9.2 上面，正是 `ack_seq`
被漏掉的原因。现在按版本号从旧到新排，**读到最后一行才是当前版本**。）

---

## 3. 请提供 07-29 的 `signature verification failed` 日志段

我方 `pms_qmt_inbox` 里有 **19 条连续的**：

```json
seq 6299 … 6317
{"instruction_id": null, "code": "SIG_INVALID",
 "reason": "signature verification failed", "retryable": false}
```

即**贵方验不过我方的签名**，连续 19 条。签名是协议三条安全底座之一，这个数量不像偶发。

我方现在查不动了：那段 pms-ws 日志随容器重建消失，且 PMS 侧不留发送侧流水，所以**无从知道
被拒的是哪 19 条消息**。这很要紧——协议级 `reject` 我方不自动重发，**那 19 条消息永久丢失**。
如果里面有 `place_order` 或 `cancel_order`，那就是「发出去了没人执行」，而通道一路显示 ONLINE。

**请帮忙查**：贵方收到的原始帧一定在日志里。想知道两件事：

1. 被拒的是哪些 `type`（`place_order` / `cancel_order` / `ack_seq` / `hello` / `ping`？）
2. 验签失败的直接原因——公钥读错？规范化串对不上？还是某类消息的字段序列化有差异？

**一个我们比较怀疑的方向**：带中文 `note` 的 `place_order`。我方联调单的 `note` 是
「ws 联调测试单」，UTF-8 处理只要有一点差异（转义、`ensure_ascii`、字节序）规范化串就对不上，
而其它不带中文的消息都正常。协议 §2.1.1 的测试向量建议双方再各跑一次比对。

---

## 4. `_sender_loop` 的队列与 socket 生命周期不一致（第三次提，仍未得答复）

`QMT_SIDE_CONTROL_PATH.md` 与 `QMT_TEST_ENV_REQUIREMENTS.md` §5.1 都提过：

```python
sender = asyncio.create_task(self._sender_loop(websocket))   # socket 创建时绑死

async def _sender_loop(self, websocket):
    while True:
        env = await self._send_queue.get()      # 队列每轮重读实例属性
        await websocket.send(...)               # 发往绑死的那个 socket
```

`self._send_queue` 每次新连接会被重新赋值。旧连接的 `sender` 若尚未被 cancel（`finally` 是
异步执行的，`close()` 返回不代表旧任务已收尾），下一轮 `get()` 拿到的是**新队列**的消息，却
发往**旧的已关闭 socket** → `send` 抛异常 → `break`，**消息静默丢失**，新连接那边在等一个
永远不来的回报。

建议把队列作为参数传入，与 socket 同生命周期：

```python
queue = asyncio.Queue()
self._send_queue = queue
sender = asyncio.create_task(self._sender_loop(websocket, queue))
```

**这条和第 3 条的现象可以互相解释**（都是「消息发出去了但对端没正确处理」），所以一起看。

---

## 5. 重建模拟持仓时，`cost_price` 必须是真实成本价（最容易忽略，后果最重）

背景：贵方今天上午清空了 `trading_position`，我方也把账本清干净了（PMS 侧 07-30 11:14）。
**现在双方都是空的**，等贵方装上持仓后我方按「以下游为准」认领重建。

请务必注意——`trading_position` 里的 **`cost_price` 会直接变成我方账本的开仓价**：

- 我方 `ADD_RECON_LOT` 的开仓价**优先取 `cost_price`**，取不到才拿现价兜底（并标注是估的）
- **若 `cost_price` 填 0、留空、或等于现价**，我方摊薄成本就等于当天价、**安全垫齐刷刷是 0**
- 安全垫是盈利加仓(≥3%)、保垫减仓(峰值≥6%)、补仓评估档(−8%/−15%)**共同的判断依据**，
  它一错，整条纪律链跟着错，页面和日报上的浮动盈亏也全是 0
- 一只真实成本 20、现价 10 的票（实亏 50%）会被记成「不赚不亏」，该评估的补仓不评估

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，与贵方
   自报 8045 在握手那一刻是对齐的。若贵方 seq 重置回小值而我方水位不动，就是**序号倒挂**——
   我方会把贵方此后发的每一条（**含成交**）都当成「不高于水位」静默丢弃，而通道显示一切正常。
   这种情况必须双方**商定同时归零**，我方绝不会单方面下调水位（那会导致区间内成交重复入账）。
2. **装持仓的时点**。我方 15:10 的日终结算会自动认领 `trading_position` 的内容，且账本为空时
   不设任何拦阻。所以贵方装到一半时如果正好跨过 15:10，我方会拿半成品建账。装好了说一声
   即可，我方在那之前会把调度停掉。

---

## 6. 已经闭环、不用再回的

- **`trading_buy_plan` 轮询已于 2026-07-30 10:55 停止** ——
  `QMT_INTERFACE_REQUIREMENTS.md` C2「旧通道停用清单」那个长期未决项至此关闭，多谢。
- `nonce` 16 字节（§2.2）、`ack_seq` 累积确认（§4.5）、§6.1 序号补发、Ed25519 双向签名互通 —— 全部实测通过。

**唯一还想确认的是 D3**：贵方当前消费决策系统信号的**全部位置**是否都停了？
C2 只覆盖了 `trading_buy_plan` 这一条路，「直接执行决策系统卖出指令与盘中 ENTRY/EXIT 信号」
那一条如果还有别的消费点，切换当天两套系统会对同一只票同时下单。

---

## 优先级建议

**5.1（一句话就能答，卡着我方认领）> 1（阻断切换）> 5 / 5.2（数据正确性，做在装持仓之前）> 3（安全底座）> 2 > 4**

第 1 条改完 + 第 5 条按口径装好持仓，我方即可进入「小仓位实盘」；第 3 条不查明，签名这条
安全底座上就一直挂着一个问号。
