tradingSystem/QMT_WS_PROTOCOL.md

532 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PMS ↔ QMT · WebSocket 指令与回报协议
> 版本:**V1.0 定稿**2026-07-28· Q1~Q11 与 R1/R2 均已答复回填(见 §10双方据此实现。
> 后续变更走版本号递增,不再直接改 V1.0 正文。
> 双方:**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 | **股数**,非金额。买入恒为整百;卖出通常整百,**清仓时可能带零股尾数**(如 2130QMT 需支持 |
| `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` 触发降级告警。
---
## 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 测试向量与参考实现,消除规范化串的实现歧义 |
| **V1.0** | 2026-07-28 | **定稿**。回填 R1/R2旧通道**即刻停用**、端点定为 `ws://192.168.16.98:8080`;补 §10.1.1 部署前检查清单白名单、公钥交换、Redis 持久化、密钥只走 `.env`);调整 §9 灰度策略(旧通道已停,无从并行,改为小仓位实跑) |
| 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 |