532 lines
32 KiB
Markdown
532 lines
32 KiB
Markdown
# 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 | **股数**,非金额。买入恒为整百;卖出通常整百,**清仓时可能带零股尾数**(如 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` 触发降级告警。
|
||
|
||
---
|
||
|
||
## 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) |
|