tradingSystem/QMT_WS_PROTOCOL.md

32 KiB
Raw Blame History

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

版本:V1.0 定稿2026-07-28· Q1~Q11 与 R1/R2 均已答复回填(见 §10双方据此实现。 后续变更走版本号递增,不再直接改 V1.0 正文。 双方:PMStradingSystem持仓管理系统指令发起方· QMT(券商终端侧执行服务,指令执行方) 本文档取代 QMT_INTERFACE_REQUIREMENTS.md 中 B 部分的表通道方案。A 部分(只读数据)与 C 部分(切换约定)继续有效。


0. 一句话说清分工

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

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


1. 传输与连接

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

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


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

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

其一,别人不能冒充 PMS 下单,也不能篡改指令内容。 这靠签名,不靠加密。采用 Ed25519PMS 持私钥签名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 为小写十六进制;tsv 以十进制整数的字符串形式参与拼接,无前导零。
  • 上下行分别使用不同密钥对PMS 签下行指令QMT 签上行回报。互不复用。
  • 验签失败 → 直接丢弃并回 reject{code: "SIG_INVALID"}不执行任何业务动作

2.1.1 测试向量(双方各自实现后先对这一组,对上再联调)

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

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

输入 payload注意 note 字段含中文与全角间隔号,专门用来验证 UTF-8 与不转义):

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

信封字段:v=1type="place_order"msg_id="m-20260728-000123"ts=1769500000123nonce="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可直接跑

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. 消息信封

所有消息共用一个信封:

{
  "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 发的第一条)

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

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

4.2 place_order —— 下单

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

{
  "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 —— 撤单

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

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

4.4 查询类

type payload 回应
query_positions {} snapshotkind: "positions"
query_funds {} snapshotkind: "funds"
query_orders {"date":"2026-07-28"}{"instruction_id":"..."} snapshotkind: "orders"

查询消息同样需要签名,但不需要 instruction_id(无副作用,天然幂等)。

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

{ "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

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

5.1 hello_ack

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

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

5.2 ack —— 指令已受理

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

duplicate: truestatus 反映首次执行该指令的当前状态,broker_order_id 也是首次那笔的。

5.3 reject —— 指令被拒

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

5.4 order_update —— 委托状态变更

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

{
  "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=5000order_quantity=600 那种口径歧义。凡是累计量,一律以「本委托」为准。

5.5 trade —— 逐笔成交

每一笔成交推一条,只描述这一笔,不含累计:

{
  "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 × qtyQMT 给出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 的可视化统计依赖这些表)。

{
  "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: falseerror 给出原因。落库失败不影响成交事实PMS 照常入账,但会记一条下游不一致告警。

5.7 snapshot —— 查询回应

{ "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 }
]}
{ "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_seqQMT 从 last_seq + 1 起按序补发,补完再推实时消息。

补发的消息与首次推送完全一致(同 seq、同 msg_id、同内容、同签名PMS 侧按 seqtrade_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}

CANCELLEDEXPIRED 均可能带部分成交(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 消息为准。

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

因此协议层 order_update.status 仍必须区分 CANCELLEDEXPIREDQMT 知道自己是被要求撤的还是到点自动撤的,如实填写即可,成本为零),只有落库时才合并。事后统计以 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=wsS4 改为「小仓位实跑数日、日终对账连续一致」再放开全量。


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=5000order_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 持久化 消息序列存 Redisws 中断后下次上线按最后未 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.pyPMS_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=5000order_quantity=600traded_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