# PMS ↔ QMT · WebSocket 指令与回报协议

> 版本：**V1.0.1**（2026-07-30）——V1.0 定稿（2026-07-28）+ 两处澄清，**消息集与状态机一字未动**，V1.0 的实现无需改动即合规。Q1~Q11 与 R1/R2 均已答复回填（见 §10），双方据此实现。
> 后续变更一律版本号递增。**变更记录（§11）按版本号从旧到新排列，读到最后一行才是当前版本**——曾因 V1.0 排在 V0.9.2 之上，对端漏掉了 `ack_seq`（§4.5）。
> 双方：**PMS**（tradingSystem，持仓管理系统，指令发起方）· **QMT**（券商终端侧执行服务，指令执行方）
> 本文档取代 `QMT_INTERFACE_REQUIREMENTS.md` 中 B 部分的表通道方案。A 部分（只读数据）与 C 部分（切换约定）继续有效。

---

## 0. 一句话说清分工

PMS 决定**买卖什么、多少股、什么价、有效到几点**；QMT 只负责**把这条指令报到交易所，并把发生的一切如实回报**。QMT 不做数量决策、不做价格决策、不做要不要执行的判断——拒绝除外（涨跌停、停牌、资金不足等硬约束）。

这条分工是 PMS 三条铁律里「分工不越权」的落地。任何让 QMT「自行判断」的设计都不要写进协议。

---

## 1. 传输与连接

| 项 | 约定 |
|---|---|
| 协议 | **明文 WebSocket（`ws://`），不启用 TLS**（双方商定）。仅限可信内网使用，**不得跨公网暴露该端口** |
| 角色 | **QMT 侧监听，PMS 侧主动连接并负责重连**。QMT 机器 IP 固定，PMS 跑在容器里 IP 会变 |
| 端点 | **`ws://192.168.16.98:8080`**（QMT 侧已提供） |
| 连接数 | 单条长连接双向复用。PMS 不开第二条连接（避免指令乱序） |
| 编码 | UTF-8 JSON，每帧一条完整消息，不分片 |
| 心跳 | PMS 每 **5 秒**发 `ping`，QMT 立即回 `pong`。任一侧 **15 秒**未收到对端消息即主动断开重连 |
| 重连退避 | 1s → 2s → 5s → 10s → 30s 封顶，无限重试 |
| 时钟 | 双方 NTP 对时，偏差需 < 5 秒。时间字段一律 **epoch 毫秒**（整数），展示口径 Asia/Shanghai |
| 访问控制 | 不用 TLS 意味着没有传输层身份校验，因此：①QMT 侧监听端口**必须绑定内网网卡并加 IP 白名单**，只放行 PMS 所在主机 ②消息层的**双向签名从「加固」升格为「必需」**，见第 2 节 |

> **关于不启用 TLS 的取舍**：明文传输意味着同网段的抓包者能看到持仓、资金与委托内容，也意味着任何能连到该端口的人都可以尝试发指令。第一点是可接受的信息泄露（内网、自有设备）；第二点则由第 2 节的签名机制兜住——没有私钥就伪造不出一条能通过验签的指令。换句话说，**去掉 TLS 之后，签名不再是可选项**，它同时承担了身份认证的职责。

---

## 2. 安全：签名、幂等、防重放

关于原始需求里说的「公钥对称加密」——这里实际要解决的是**两件不同的事**，拆开各自用合适的手段更稳妥，也更好实现：

**其一，别人不能冒充 PMS 下单，也不能篡改指令内容。** 这靠**签名**，不靠加密。采用 **Ed25519**：PMS 持私钥签名，QMT 只持公钥验签——即使 QMT 这台机器被翻了，拿到公钥也伪造不出一条指令。这正好是「公钥」的用法。

**其二，网络抖动重发不能造成重复下单。** 这靠**幂等键 + 去重表**，与加密无关。

**其三，内容不被中间人看到。** 本次商定不启用 TLS，即**放弃传输机密性**，靠内网边界与端口白名单控制风险（见第 1 节）。业务层不再自行加一层加密——自研加密协议出错的概率远高于它挡住的风险。

综上，业务层要做的是**签名 + 幂等**，不是「加密」。由于没有 TLS，签名同时是**唯一的身份认证手段**，双向必签、验签失败即丢弃，不设「宽松模式」。

### 2.1 签名

签名对象为**规范化字符串**：

```
canonical = v + "\n" + type + "\n" + msg_id + "\n" + ts + "\n" + nonce + "\n" + sha256_hex(payload_json)
sig       = base64( Ed25519_sign(private_key, canonical) )
```

- `payload_json` 为 payload 对象的**紧凑 JSON**：分隔符用 `,` 与 `:`（无空格）、键按 Unicode 码点升序、中文**不转义**（UTF-8 原文，即 Python 的 `ensure_ascii=False`、Java 的默认行为）。
- `sha256_hex` 为小写十六进制；`ts` 与 `v` 以十进制整数的字符串形式参与拼接，无前导零。
- 上下行**分别使用不同密钥对**：PMS 签下行指令，QMT 签上行回报。互不复用。
- 验签失败 → 直接丢弃并回 `reject{code: "SIG_INVALID"}`，**不执行任何业务动作**。

### 2.1.1 测试向量（双方各自实现后先对这一组，对上再联调）

规范化串的拼接方式是本协议最容易两边写不一致的地方，用一组固定向量把它钉死。测试密钥仅供联调，**不得用于生产**。

```
私钥 seed (hex)  00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
公钥 (base64)    PM0kHP/Js2GARLl9A22GFFk9iwF8NA8d7odzOFUXZUs=
```

输入 payload（注意 `note` 字段含中文与全角间隔号，专门用来验证 UTF-8 与不转义）：

```json
{"instruction_id":"INS-20260728-a3f19c04","ts_code":"600000.SH","side":"sell",
 "qty":2000,"limit_price":12.35,"valid_until":1769512500000,
 "intent":"TRIM","note":"保垫减仓·垫厚先收"}
```

信封字段：`v=1`，`type="place_order"`，`msg_id="m-20260728-000123"`，`ts=1769500000123`，`nonce="9f2c8a1b7d3e4056"`

**中间结果（逐步核对，哪一步对不上就知道差在哪）**

```
payload_json    {"instruction_id":"INS-20260728-a3f19c04","intent":"TRIM","limit_price":12.35,"note":"保垫减仓·垫厚先收","qty":2000,"side":"sell","ts_code":"600000.SH","valid_until":1769512500000}
payload_sha256  9fa3f56b92ef9d79290b46416899bdbebd93e7fdddbfe5bee5b5e09380bb7c63
canonical       1\nplace_order\nm-20260728-000123\n1769500000123\n9f2c8a1b7d3e4056\n9fa3f56b92ef9d79290b46416899bdbebd93e7fdddbfe5bee5b5e09380bb7c63
sig             NzCILYw7ampmMtg41EBMubtlnDwr/jjGO+1cMTIxVdqaSaT779Kop5DAdADHL2aSbqrw2sp8hCQb+apCACIYCw==
```

`\n` 为单个换行符 `0x0A`，串尾无换行。`limit_price` 序列化为 `12.35`（不是 `12.350000000000001`，实现时若用浮点请确认序列化结果，必要时改用字符串或定点数传输）。

**参考实现（Python，可直接跑）**

```python
import base64, hashlib, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

def canonical(v, type_, msg_id, ts, nonce, payload):
    pj = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    ph = hashlib.sha256(pj.encode("utf-8")).hexdigest()
    return f"{v}\n{type_}\n{msg_id}\n{ts}\n{nonce}\n{ph}"

def sign(seed_hex, canon):
    sk = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(seed_hex))
    return base64.b64encode(sk.sign(canon.encode("utf-8"))).decode()
```

### 2.2 幂等（防重复下单）

| 机制 | 说明 |
|---|---|
| `instruction_id` | 由 **PMS 生成**、全局唯一、长度 ≤ 64。这是幂等键，也是贯穿全流程的关联键 |
| 去重表 | QMT 侧持久化已受理的 `instruction_id`，保留期 ≥ **7 个自然日**（跨周末） |
| 重复处理 | 收到已存在的 `instruction_id` → **不再下单**，回 `ack{duplicate: true, ...当前最新状态}`。这就是「重复则跳过」的落地方式：跳过执行，但仍要回一个状态，好让 PMS 知道原单现在怎么样了 |
| 时间窗 | `ts` 与 QMT 本地时钟偏差 > **30 秒**的消息一律拒绝（`code: "TS_SKEW"`），防止旧包重放 |
| `nonce` | 每条消息随机 16 字节 hex，仅参与签名，QMT 无需存储（时间窗已足够） |

`instruction_id` 建议格式：`INS-<yyyymmdd>-<8位随机>`，例如 `INS-20260728-a3f19c04`。撤单指令另用 `cancel_id`，格式同理，前缀 `CXL-`。

---

## 3. 消息信封

所有消息共用一个信封：

```json
{
  "v": 1,
  "type": "place_order",
  "msg_id": "m-20260728-000123",
  "ts": 1769500000123,
  "nonce": "9f2c8a1b7d3e4056",
  "seq": 10231,
  "corr_id": "INS-20260728-a3f19c04",
  "payload": { },
  "sig": "base64..."
}
```

| 字段 | 必填 | 说明 |
|---|---|---|
| `v` | 是 | 协议版本，当前恒为 `1`。对端收到不认识的版本回 `reject{code:"VERSION"}` |
| `type` | 是 | 消息类型，见第 4、5 节 |
| `msg_id` | 是 | 单条消息唯一，仅用于日志追踪与重传识别，**不是**业务幂等键 |
| `ts` | 是 | 发送时刻，epoch 毫秒 |
| `nonce` | 是 | 随机串，参与签名 |
| `seq` | 上行必填 | **仅 QMT→PMS 方向**。全局单调递增整数，QMT 侧持久化，跨重启不回退。断线补发靠它 |
| `corr_id` | 视类型 | 关联的 `instruction_id`（或 `cancel_id`）。所有与某条指令相关的回报都必须带上 |
| `payload` | 是 | 业务体 |
| `sig` | 是 | 签名 |

---

## 4. 下行：PMS → QMT

### 4.1 `hello` —— 握手（连接建立后 PMS 发的第一条）

```json
{ "client": "pms", "last_seq": 10230, "protocol": 1 }
```

`last_seq` 是 PMS 已持久化的最后一条上行消息序号。QMT 据此补发（见 6.1）。首次连接或本地无记录时传 `0`。

### 4.2 `place_order` —— 下单

建仓、加仓、减仓、清仓**共用这一条消息**，用 `side` + `qty` 表达，`intent` 只作归类与人工看盘用，不改变执行行为。

```json
{
  "instruction_id": "INS-20260728-a3f19c04",
  "ts_code": "600000.SH",
  "side": "sell",
  "qty": 2000,
  "limit_price": 12.35,
  "valid_until": 1769512500000,
  "intent": "TRIM",
  "note": "保垫减仓·垫厚先收"
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `instruction_id` | string | 幂等键，见 2.2 |
| `ts_code` | string | **点式**代码，如 `600000.SH` / `000001.SZ`。与 `trading_*` 表现用格式一致 |
| `side` | string | `buy` / `sell` |
| `qty` | int | **股数**，非金额。买入恒为整百；卖出通常整百，**清仓时可能带零股尾数**（如 2130），QMT 需支持 |
| `limit_price` | number | **必填**，2 位小数。PMS 的择时模块负责定价，QMT 不做价格决策。不接受 null |
| `valid_until` | int | 有效期截止，epoch 毫秒。到期仍未全成，QMT **自动撤单**并回 `EXPIRED` 终态 |
| `intent` | string | `OPEN`/`FILL`/`ADD`/`DCA`/`TRIM`/`EXIT`/`T0`，仅归类 |
| `note` | string | ≤ 200 字，人工看盘用，QMT 原样落库即可 |

`valid_until` 是这套设计里替代「10 秒轮询撤单」的关键：PMS 不必盯着撤，到点 QMT 自己收。

### 4.3 `cancel_order` —— 撤单

```json
{ "cancel_id": "CXL-20260728-71b2ef39", "instruction_id": "INS-20260728-a3f19c04" }
```

QMT 撤在途部分，已成交部分保留。撤单完成后该指令进入 `CANCELLED` 终态（若撤单前已全成，则回 `reject{code:"ALREADY_FINAL"}` 并附当前状态）。

### 4.4 查询类

| type | payload | 回应 |
|---|---|---|
| `query_positions` | `{}` | `snapshot`（`kind: "positions"`） |
| `query_funds` | `{}` | `snapshot`（`kind: "funds"`） |
| `query_orders` | `{"date":"2026-07-28"}` 或 `{"instruction_id":"..."}` | `snapshot`（`kind: "orders"`） |

查询消息同样需要签名，但**不需要** `instruction_id`（无副作用，天然幂等）。

### 4.5 `ack_seq` —— 上行消息的累积确认

```json
{ "seq": 10450 }
```

**累积确认**：表示 `seq ≤ 10450` 的上行消息 PMS 已全部落库，QMT 可安全清理 Redis 中这部分消息。语义同 TCP 的累积 ACK——确认 10450 即隐含确认了之前所有消息，中间漏发一条不会被误确认。

发送时机：PMS **每落库 20 条或每 2 秒**（取先到）发一次，只发当前连续水位。**确认前必须已持久化**，不能收到就 ack——否则 QMT 清理了消息、PMS 又崩在落库前，那段数据就永久丢了。

断线重连时以 `hello.last_seq` 为准，`ack_seq` 只用于让 QMT 及时释放存储，两者不冲突。

### 4.6 `ping`

`payload: {}`。QMT 立即回 `pong`。

---

## 5. 上行：QMT → PMS

所有上行消息带 `seq`，PMS 按 `seq` 顺序消费并持久化水位。

### 5.1 `hello_ack`

```json
{ "server_version": "...", "server_seq": 10450, "resume_from": 10231, "resync_required": false }
```

`resync_required: true` 表示 QMT 无法补齐 PMS 请求的区间（日志已滚动），PMS 收到后必须走全量快照对账（见 6.2）。

### 5.2 `ack` —— 指令已受理

```json
{ "instruction_id": "INS-...", "accepted": true, "duplicate": false,
  "broker_order_id": "QMT-88123", "status": "ACCEPTED" }
```

`duplicate: true` 时 `status` 反映**首次执行**该指令的当前状态，`broker_order_id` 也是首次那笔的。

### 5.3 `reject` —— 指令被拒

```json
{ "instruction_id": "INS-...", "code": "LIMIT_UP", "reason": "涨停价无法买入", "retryable": false }
```

### 5.4 `order_update` —— 委托状态变更

每次状态发生变化推一条，**终态必须推且只推一次**。

```json
{
  "instruction_id": "INS-...", "broker_order_id": "QMT-88123",
  "status": "PARTIAL", "final": false,
  "cum_qty": 600, "cum_avg_price": 12.34, "leaves_qty": 1400,
  "update_time": 1769500123456
}
```

| 字段 | 说明 |
|---|---|
| `cum_qty` | **本委托**累计成交股数。口径明确为本委托，不是当日、不是该股票持仓 |
| `cum_avg_price` | **本委托**累计成交均价，2 位小数 |
| `leaves_qty` | 未成交剩余 = `qty - cum_qty`。撤单/过期后应为 0 |
| `final` | 是否终态。终态后该 `instruction_id` 不再有任何 `order_update` |

> 这三个字段是为了消灭原 `trading_log` 示例里 `total_filled=5000` 而 `order_quantity=600` 那种口径歧义。凡是累计量，一律以「本委托」为准。

### 5.5 `trade` —— 逐笔成交

**每一笔成交推一条**，只描述这一笔，不含累计：

```json
{
  "instruction_id": "INS-...", "broker_order_id": "QMT-88123",
  "trade_no": "QMT-88123#1",
  "ts_code": "600000.SH", "side": "sell",
  "qty": 600, "price": 12.34, "amount": 7404.00,
  "fee": 3.21, "fee_estimated": true, "trade_time": 1769500123456
}
```

| 字段 | 说明 |
|---|---|
| `trade_no` | 该笔成交的唯一编号。**格式固定为 `{broker_order_id}#{n}`**，`n` 为该委托内成交序号，从 1 起递增。券商原生只提供委托号（`order_id`），一次委托多次成交时委托号相同、无法区分，故由 QMT 按此规则拼出笔号。跨重启唯一性由「委托号唯一 + n 从该委托已回报笔数续接」保证 |
| `qty` / `price` | **本笔**成交股数与价格 |
| `amount` | `price × qty`，QMT 给出，PMS 会校验一致性（差异 > 0.01 元告警） |
| `fee` | 本笔手续费（佣金 + 印花税 + 过户费合计）。QMT 侧按费率**估算**，与实际扣费可能有误差 |
| `fee_estimated` | `true` 表示 `fee` 为估算值。PMS 据此决定是否把它计入成本 |

**关于手续费的处理口径**（QMT 侧已说明逐笔费用未必精确）：PMS **不把 `fee` 摊进持仓成本**，成本价只由 `price × qty` 决定；费用单独记为一笔现金流出。这样估算误差不会污染摊薄成本，进而不会污染安全垫（cushion）与保垫减仓的判断——这是 PMS 里最不能被噪声干扰的一条计算链。真实费用在日终用资金快照反推校准（总资产变动 − 成交净额 = 当日实际费用），只入现金账，不回溯改成本。

**PMS 的账本以 `trade` 为唯一入账依据**，`order_update` 只用于状态跟踪与最终核对（用终态的 `cum_qty` 校验逐笔加总是否相等，不等则告警）。这样即使某条 `order_update` 丢失也不会记错账。

去重是双层的：**第一层按 `seq`**（补发时同一条消息 `seq` 不变，见 §6.1），**第二层按 `trade_no`**。任一层命中即丢弃。第二层的意义是防住 `seq` 实现出 bug 的场景，两层都命中才入账。

### 5.6 `persist_result` —— 落库结果

QMT 把成交写入 `trading_order` / `trading_position` / `trading_log` 之后回一条，供 PMS 确认下游表已同步（trading_service 的可视化统计依赖这些表）。

```json
{
  "instruction_id": "INS-...", "scope": "trade",
  "trade_no": "T-20260728-000991",
  "ok": true,
  "tables": { "trading_order": "QMT-88123", "trading_log": 88231, "trading_position": "600000.SH" },
  "error": null
}
```

- `scope`: `trade`（逐笔落库）/ `final`（该指令终态落库完成）
- `ok: false` 时 `error` 给出原因。**落库失败不影响成交事实**，PMS 照常入账，但会记一条下游不一致告警。

### 5.7 `snapshot` —— 查询回应

```json
{ "kind": "positions", "as_of": 1769500000000, "items": [
  { "ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 4000, "frozen_qty": 0,
    "cost_price": 11.80, "market_price": 12.34 }
]}
```

```json
{ "kind": "funds", "as_of": 1769500000000, "data": {
  "total_asset": 2013456.78, "available_cash": 312450.10,
  "frozen_cash": 12000.00, "sell_return_today": 48900.00, "market_value": 1701006.68
}}
```

`sell_return_today`（当日卖出回笼，T+0 可用）是 PMS 买入前资金校验必需的，请务必提供。

### 5.8 `position_update` / `funds_update` —— 主动推送

QMT 侧已确认支持。有变化即推，字段同 `snapshot` 的单项。PMS 的 5 分钟 `query_*` 轮询退为兜底手段，不再是主要来源。

### 5.9 `pong`

`payload: {}`。

---

## 6. 断线、补发与对账

WebSocket 相比数据库轮询，最大的代价是**断线期间的消息没地方补读**。因此以下三条是本协议的安全底座，缺一不可。

### 6.1 序号补发

QMT 侧为每条上行消息分配单调递增 `seq` 并**持久化到 Redis**（跨重启、跨交易日不回退）。PMS 重连时在 `hello` 里带 `last_seq`，QMT 从 `last_seq + 1` 起按序补发，补完再推实时消息。

补发的消息与首次推送**完全一致**（同 `seq`、同 `msg_id`、同内容、**同签名**），PMS 侧按 `seq` 与 `trade_no` 双重去重。

**消息保留策略**：QMT 收到 `ack_seq{seq: N}`（见 §4.5）后可清理 `seq ≤ N` 的消息。除此之外还需一条时间下限兜底——**已确认的消息也至少保留 7 天**，防止 PMS 侧数据库回滚或误删后无从追溯。

> 联调/测试期 QMT 侧 Redis 不设过期时间（双方已确认），上线前需按上述策略配置，并确认 Redis 本身开启持久化（AOF 或 RDB）——`seq` 与消息体都在 Redis 里，Redis 一旦丢数据，补发能力就没了，只能退回全量对账。

### 6.2 快照对账（兜底）

即使有补发，PMS 仍会：盘中每 **5 分钟**发一次 `query_positions` + `query_funds`，与本地账本比对；日终收盘后做一次全量对账。任何一次比对不一致，PMS 记录差异并按严重度决定是否冻结自主动作（这是 PMS 侧既有的对账引擎，QMT 只需保证快照准确）。

`resync_required: true` 或发现 `seq` 缺口不可补时，PMS 强制走全量对账，对不齐则停止一切自主动作、告警等人工裁决。

### 6.3 故障时双方的行为（「故障即守成」）

| 情形 | PMS 行为 | QMT 行为 |
|---|---|---|
| PMS 断连 | 重连并补发 | **不做任何自主决策**。在途委托按各自 `valid_until` 到期自动撤单，不新下单、不替 PMS 判断 |
| QMT 断连/不可用 | **停发一切增持指令**，仅保留减持路径；告警 | — |
| 对账不一致 | 冻结自主动作，等人工 | 配合提供快照 |

减持在故障期仍放行，是 PMS 既有口径（冻结与刹车只挡增持不挡减持），这里保持一致。

---

## 7. 状态机与枚举

### 7.1 协议状态（本协议使用的正式枚举）

```
                 ┌──────────────┐
   place_order → │   ACCEPTED   │ QMT 已受理，未报盘
                 └──────┬───────┘
                        ↓
                 ┌──────────────┐
                 │  SUBMITTED   │ 已报盘，交易所已接受
                 └──────┬───────┘
              ┌─────────┼─────────┐
              ↓         ↓         ↓
        ┌─────────┐ ┌────────┐ ┌───────────┐
        │ PARTIAL │→│ FILLED │ │ CANCELLED │  ← cancel_order 或 valid_until
        └────┬────┘ └────────┘ └───────────┘
             └──────────→ ┌──────────┐
                          │ EXPIRED  │  valid_until 到期，QMT 自动撤
                          └──────────┘
   任意阶段失败 → ┌──────────┐
                  │ REJECTED │
                  └──────────┘
```

**终态**：`FILLED` / `CANCELLED` / `EXPIRED` / `REJECTED`。终态唯一、不可再变、必须推送一次 `order_update{final:true}`。

`CANCELLED` 与 `EXPIRED` 均可能带部分成交（`cum_qty > 0`），这是正常情况，PMS 会如实入账。

### 7.2 与现有 `trading_order.order_status` 的映射

QMT 侧落库时沿用现有表字段，映射关系固定如下：

| 协议状态 | `trading_order.order_status` | 备注 |
|---|---|---|
| `ACCEPTED` | `pending` | 挂单成功、QMT 尚无响应 |
| `SUBMITTED` | `submitted` | 已提交 |
| `PARTIAL` | `filled` | ⚠️ 现有表里 `filled` 表示**部分成交**，命名易误解，协议层不复用该词 |
| `FILLED` | `completed` | 已完成 |
| `CANCELLED` | `cancelled` | 表已有 `cancel_time` 列，请同时写入 |
| `EXPIRED` | `cancelled` | 落库层**不区分**主动撤与到期撤，见下方说明 |
| `REJECTED` | `failed` | 委托失败 |

> 提醒：现有表里 `filled` = 部分成交这个命名，未来接手的人几乎必然会读成「已全部成交」。协议层用 `PARTIAL` / `FILLED` 把它区分开，落库时再映射回去。**PMS 不再直接读 `order_status` 判断成交**，一律以 `trade` 消息为准。

**关于 `CANCELLED` 与 `EXPIRED` 的区分**：落库层两者都写 `cancelled`，靠「成交数量 + 订单状态」无法区分——两种情况都可能带部分成交，组合完全一样。但这不需要 QMT 额外配合，因为**PMS 自己知道**：主动撤是 PMS 发过 `cancel_order` 的，到期撤是 PMS 没发过撤单却收到了终态。PMS 侧按本地是否发过撤单来标注，不依赖下游表。

因此**协议层 `order_update.status` 仍必须区分 `CANCELLED` 与 `EXPIRED`**（QMT 知道自己是被要求撤的还是到点自动撤的，如实填写即可，成本为零），只有落库时才合并。事后统计以 PMS 账本为准。

### 7.3 拒绝码 `code`

| code | 含义 | `retryable` |
|---|---|---|
| `SIG_INVALID` | 签名验证失败 | 否 |
| `TS_SKEW` | 时间戳超出容忍窗口 | 是（对时后重发） |
| `VERSION` | 协议版本不支持 | 否 |
| `DUP_INSTRUCTION` | 幂等键重复（正常经 `ack{duplicate}` 表达，此码仅用于异常场景） | 否 |
| `BAD_PARAM` | 字段缺失/类型错/数量非整百/**价格不合法**——精度超限，或限价超出当日涨跌停区间 | 否 |
| `UNKNOWN_CODE` | 股票代码不存在 | 否 |
| `SUSPENDED` | 停牌 | 否 |
| `LIMIT_UP` / `LIMIT_DOWN` | 涨/跌停无法成交该方向 | 是（换价重发） |
| `INSUFFICIENT_CASH` | 可用资金不足 | 是 |
| `INSUFFICIENT_POSITION` | 可用持仓不足（T+1 未解冻/已冻结） | 是 |
| `NOT_TRADING_TIME` | 非交易时段 | 是 |
| `ALREADY_FINAL` | 指令已终态，无法撤单 | 否 |
| `BROKER_ERROR` | 券商柜台返回错误，`reason` 带原文 | 视情况 |
| `INTERNAL` | QMT 内部错误 | 是 |

QMT 侧如有本表未覆盖的拒绝场景，请在定稿时补充，**不要归入 `INTERNAL`**——PMS 会对 `INTERNAL` 触发降级告警。

> **`BAD_PARAM` 与 `LIMIT_UP` / `LIMIT_DOWN` 别混**（V1.0.1 澄清）：前者是「这个价格本身不合法」——超出当日涨跌停区间的限价，柜台根本不会受理，换价重发才有意义但**本单已死**，故 `retryable=否`；后者是「价格合法、但该方向此刻撮不成」——已封涨停时的买入、已封跌停时的卖出，单子是好的只是撮不上，故 `retryable=是`。2026-07-30 联调实测：一张远超涨停价的卖单在模拟环境既没被拒也没成交，一路挂到 `EXPIRED`——起因是原表只写了「价格精度超限」，越界价没有落点。

---

## 8. 数量、价格与代码口径（易错点集中说明）

| 项 | 约定 |
|---|---|
| 数量单位 | **股**，不是手、不是金额。`trading_buy_plan.buy_amount` 那套金额口径在本通道不再使用 |
| 整百规则 | 买入必整百；卖出整百，**清仓允许零股尾数** |
| 价格精度 | 2 位小数。超出精度直接 `BAD_PARAM`，不做四舍五入（避免双方舍入方向不一致） |
| 累计口径 | 凡 `cum_*` 一律「本委托」口径 |
| 金额 | `amount = price × qty`，不含费用；`fee` 单列 |
| 股票代码 | 点式 `600000.SH`。PMS 内部与本协议统一使用点式 |
| 时间 | epoch 毫秒整数；文字展示为 Asia/Shanghai |
| 一条指令 = 一张委托 | QMT **不得**自行把一条指令拆成多张委托。确需拆单请在定稿前提出，协议要相应扩展 |

---

## 9. 联调与灰度

| 阶段 | 内容 | 通过标准 |
|---|---|---|
| S1 握手与心跳 | 连接、签名、`ping`/`pong`、断线重连 | 断网 60 秒后自动恢复，`seq` 无缺口 |
| S2 影子模式 | PMS 正常发指令，QMT **只回报不下单**（`ack` + 模拟 `order_update`） | 全链路消息格式互通，PMS 账本与模拟回报一致 |
| S3 小额实盘 | 单笔 ≤ 100 股，覆盖：全成、部分成交、主动撤单、到期过期、各类拒绝 | 五类场景各至少 1 次，账本与快照对账零差异 |
| S4 灰度并行 | 旧通道只读不执行，新通道实际执行，观察 N 个交易日 | 日终对账连续 N 日一致 |
| S5 切换 | 旧通道**已于 2026-07-28 停用**，本阶段即新通道全量承接 | — |

> 旧通道已先行停掉，因此 S4 的「灰度并行」不再有旧通道可比对——过渡期实际形态是：PMS 影子运行 + 人工在 QMT 侧执行。S2/S3 通过后即可切 `PMS_DISPATCH_MODE=ws`，S4 改为「小仓位实跑数日、日终对账连续一致」再放开全量。

---

## 10. Q1~Q11 答复与处置（QMT 侧已答复，2026-07-28）

| # | 事项 | QMT 答复 | 处置 |
|---|---|---|---|
| Q1 | 旧通道停用时点 | **旧通道停止**，时点为**立即** | ✅ 已闭环，见 §10.1 R1。停用期间 PMS 影子运行，需人工执行 |
| Q2 | `filled_quantity` 是否可靠 | 可靠，但**只作故障校验**，主依据仍是 `order_update` | ✅ 与本协议设计一致。PMS 账本以 `trade` 为准，`order_update` 作状态跟踪，该列仅在对账出现分歧时作第三方参照 |
| Q3 | `trading_log` 的 JSON 落在哪列、`total_filled` 口径 | 落在 **`extra_data`** 列；`total_filled` 是**单笔订单的下单数量** | ⚠️ **答复与示例数据矛盾**：示例第二条 `total_filled=5000` 而 `order_quantity=600`，若它是「单笔下单数量」两者应相等。处置见 §10.2 |
| Q4 | `EXPIRED` 如何落库 | 用 order 表的已成交数量 + 订单状态**联合判断** | ⚠️ 该组合区分不了主动撤与到期撤（两者都是 `cancelled` 且都可能带部分成交）。**已自行化解**：PMS 按本地是否发过 `cancel_order` 自记，不依赖下游表；协议层 `status` 仍区分。见 §7.2 |
| Q5 | 签名方案 | **Ed25519** | ✅ 已锁定。公钥带外交换列入部署清单 §10.1.1 |
| Q6 | 逐笔手续费能否提供 | 逐笔手续费与印花税**不一定准确**，与实际费率可能有误差 | ✅ 已按此调整口径：**费用不摊进持仓成本**，只记现金流出，日终用资金快照反推校准。避免估算误差污染安全垫。见 §5.5。新增 `fee_estimated` 字段 |
| Q7 | `trade_no` 券商是否原生提供 | 原生有 **`order_id`** | ⚠️ `order_id` 是**委托号**不是成交笔号——Q11 已确认一次委托多次回报，同一委托各笔的 `order_id` 相同，无法逐笔去重。**已定规则**：`trade_no = {broker_order_id}#{n}`，QMT 拼接，成本极低。见 §5.5 |
| Q8 | 上行消息保留与 `seq` 持久化 | 消息序列存 **Redis**；ws 中断后下次上线**按最后未 ack 的消息**续发；测试期 Redis 不设过期 | ⚠️ 「按未 ack 续发」意味着需要 PMS 逐条确认，而**原协议没有定义 ack 机制**——已补：新增下行消息 **`ack_seq`（累积确认）**，见 §4.5；保留策略与 Redis 持久化要求见 §6.1 |
| Q9 | 服务地址、端口、白名单 | **`ws://192.168.16.98:8080`** | ✅ 已闭环，见 §10.1 R2 与 §10.1.1 部署清单 |
| Q10 | 是否支持主动推送 | **支持** | ✅ `position_update` / `funds_update` 按 §5.8 实现，PMS 的 5 分钟轮询退为兜底 |
| Q11 | 一条指令是否会被拆成多张委托 | **一次委托，多次返回**（成交信息 + 最终状态） | ✅ 确认「一指令一委托」成立，多次返回正是 §5.5 逐笔成交 + §5.4 状态更新的模型。`broker_order_id` 保持单值 |

### 10.1 R1 / R2 答复（2026-07-28，已闭环）

| # | 事项 | 答复 | 处置 |
|---|---|---|---|
| R1 | 旧通道停用时点 | **现在直接停掉** | ✅ 即刻生效。下游停止：①直接执行决策系统卖出信号 ②轮询 `trading_buy_plan` 自动挂单。**注意由此产生的空窗**：新通道实现前 PMS 处于影子运行（`PMS_DISPATCH_MODE=shadow`），指令照常生成、过闸、记账，但需**人工在 QMT 侧执行**，成交由回放认领。这段时间没有任何系统会自动下单——这是安全的，但要知道它是这个状态 |
| R2 | 服务端点与白名单 | **`ws://192.168.16.98:8080`**，白名单即该地址 | ✅ 已写入 §1 与 `config/settings.py` 的 `PMS_QMT_WS_URL`。**部署期还需两步**：①把 PMS 宿主机的内网 IP 报给 QMT 侧加入其入站白名单（PMS 跑在容器里，须用 host 网络或固定出口 IP，否则重启换 IP 会被挡）②双方交换 Ed25519 公钥（各自 `ssh`/U 盘等带外方式送达，私钥不出机器） |

### 10.1.1 部署前的检查清单

- [ ] PMS 宿主机内网 IP 已确定并报给 QMT 侧加白
- [ ] 双方 Ed25519 公钥已交换，用 §2.1.1 的测试向量互验通过
- [ ] QMT 侧 `192.168.16.98:8080` 已绑定内网网卡、未暴露公网
- [ ] QMT 侧 Redis 已开启持久化（AOF/RDB），过期策略按 §6.1 配置
- [ ] PMS 侧 `PMS_QMT_SIGN_SEED_HEX` / `PMS_QMT_PEER_PUBKEY_B64` 已从 `.env` 注入（**不入库、不进 ParamStore、不写进代码**）

### 10.2 `total_filled` 的处置（PMS 侧自行规避，不再追问）

对方答「`total_filled` 是单笔订单的下单数量」，但示例数据里 `total_filled=5000` 而 `order_quantity=600`、`traded_volume=600`，三者对不上。同时可以验证：`47.02 × 600 = 28212 = traded_amount`，说明 `traded_price` / `traded_volume` / `traded_amount` 三个字段**自洽且可信**。

由于主链路已走 WebSocket、账本以 `trade` 消息为准，`trading_log` 仅用于事后人工核对，因此不再为此多耗一轮：**PMS 事后核对只读 `traded_volume` / `traded_price` / `traded_amount`，不读 `total_filled`**。该列口径存疑，已在此记录，避免后来者踩坑。

---

## 11. 变更记录

| 版本 | 日期 | 变更 |
|---|---|---|
| V0.9 | 2026-07-28 | 初稿。依据 QMT 侧对《QMT_INTERFACE_REQUIREMENTS.md》六项问询的答复，将指令通道由表轮询改为 WebSocket 双向长连接；补齐签名、幂等、序号补发与对账兜底 |
| V0.9.1 | 2026-07-28 | 按双方最终意见调整：①**不启用 TLS**，改为明文 `ws://` + 内网 IP 白名单，并补充取舍说明与相应的安全补偿（签名升格为必需、承担身份认证职责）②`hello` 去掉 `client_version` ③签名方案锁定 Ed25519（原 Q5 二选一取消）④补齐 §2.1.1 测试向量与参考实现，消除规范化串的实现歧义 |
| V0.9.2 | 2026-07-28 | 回填 QMT 侧 Q1~Q11 答复（§10），并据此补齐四处：①新增下行消息 **`ack_seq` 累积确认**（§4.5）——对方的「按最后未 ack 的消息续发」实现依赖它，原协议缺失 ②`trade_no` 定为 `{broker_order_id}#{n}`（§5.5）——券商原生只有委托号，一委托多成交时无法逐笔去重 ③手续费**不摊入持仓成本**、新增 `fee_estimated` 标志（§5.5）——对方明确逐笔费用可能有误差，避免污染安全垫 ④`CANCELLED`/`EXPIRED` 改由 PMS 侧自记（§7.2），不再要求下游区分。另记录 `total_filled` 口径存疑及规避方式（§10.2）。剩余阻塞项收敛为 R1/R2 两条（§10.1） |
| **V1.0** | 2026-07-28 | **定稿**。回填 R1/R2：旧通道**即刻停用**、端点定为 `ws://192.168.16.98:8080`；补 §10.1.1 部署前检查清单（白名单、公钥交换、Redis 持久化、密钥只走 `.env`）；调整 §9 灰度策略（旧通道已停，无从并行，改为小仓位实跑） |
| V1.0.1 | 2026-07-30 | 依 07-30 联调实测澄清两处，**不改消息集与状态机**：①§7.3 `BAD_PARAM` 的含义由「价格精度超限」扩为「价格不合法（精度超限或限价越界）」，并补一段与 `LIMIT_UP`/`LIMIT_DOWN` 的分界说明——原表对「限价超出当日涨跌停区间」没有落点，实测该单既未被拒也未成交，一路挂到 `EXPIRED` ②本表顺序修正：V1.0 那行原排在 V0.9.2 之上，导致对端读到「定稿」即止、漏掉 V0.9.2 引入的 `ack_seq`（§4.5），进而形成 ack→reject 自激循环。**顺序修正不涉及任何条款变更** |
