tradingSystem/QMT_TEST_ENV_REQUIREMENTS.md

8.2 KiB
Raw Blame History

QMT 模拟环境需求(用于完成协议 §9 的 S3 验收)

面向 QMT 侧研发。目标是让 S3「小额实盘前的全状态覆盖」能真正跑完。 当前进度:通道本身已经全线打通,卡住的只有模拟环境的成交行为不可控这一件事。


1. 已经跑通的部分(无需再动)

先把已验证的列出来,避免重复投入:

状态 实测依据
Ed25519 双向签名 canonical string、payload 序列化与 PMS 侧逐字一致,双向验签实测通过
握手 / 心跳 / 重连退避 累计近百次重连seq 水位每次无缝续接,无缺口
nonce 16 字节§2.2 已从 token_hex(8) 改为 token_hex(16)
ack_seq 累积确认§4.5 已实现PMS 侧不再降级停发
控制面内联、不被业务阻塞 place_order 实弹压测下 pong 未中断,投递抖动稳定在 0.1 秒量级
§6.1 序号补发 PMS 侧人为把水位回退 60 格重连QMT 从 last_seq+1 起严格按序补回 4817..4876 共 60 条,约 15ms/条,一条不漏
委托全链路 place_order → ack → order_update(SUBMITTED) → trade → persist_result → order_update(FILLED) → funds_update,字段与状态推进全部正确

协议的三条安全底座(签名、补发、对账兜底)已经齐了两条,第三条对账在 PMS 侧。


2. 当前阻塞:模拟环境无条件全成

实测三张委托,全部 100 股全额成交

委托 说明 结果
600000.SH sell 100 @ 99.99 浦发实际价 10 元上下,挂到市价近 10 倍 FILLED 100
600000.SH sell 100 @ 99.99valid_until 仅 1 分钟) 想验到期撤单 到期前已 FILLED
999999.SH sell 100 @ 9.99 一个不存在的证券代码 FILLED 100

结论:模拟环境不校验代码、不校验持仓、不看限价与市价的关系,来什么单成什么单

这导致协议 §9 的 S3 验收(五类各至少一次)只能完成一类:

S3 场景 能否测 卡在哪
全额成交 已验三次
部分成交 PARTIAL 只能由 QMT 侧构造
主动撤单 CANCELLED 秒成,没有在途窗口可撤
到期过期 EXPIRED 秒成,轮不到 valid_until
各类拒绝 REJECT 无任何校验,什么都不拒

PMS 侧无法从自己这端造出后四类——我们只能决定发什么单,成交行为完全在对端。


3. 需求清单

R1核心可控的成交行为

需要模拟环境支持「挂单但不成交」。有了它,撤单、过期两类立刻可测,部分成交也有了基础。

建议的实现(按推荐顺序,任选其一即可):

方案 A — 按限价偏离度自动判定(推荐)

卖单限价 高于 现价 × (1 + X%)  → 挂着不成
买单限价 低于 现价 × (1 - X%)  → 挂着不成
其余                          → 按现有逻辑成交

X 取 3% 或 5% 均可。好处是贴近真实撮合,一套规则同时覆盖「挂得上/挂不上」, PMS 侧只要调限价就能自由选择要不要成交,不需要额外协调。

方案 B — 显式模式开关

配置项 fill_mode,取值 auto(现状)/ never(一律挂着不成)/ partial / reject。 联调时手工切换。实现最省事,但每测一类都要人工改配置、重启,来回沟通成本高。

方案 C — A + B 组合(最理想)

默认走 A 的自动判定,另留一个强制覆盖开关用于定点构造某一类。

R2 参数与业务校验,不合法即 reject

至少覆盖以下几类,code 用协议既有取值:

情形 期望 code retryable
证券代码不存在 / 非法 BAD_PARAM false
卖出数量超过可用持仓 NO_POSITION(或贵方既有码,告知我们即可) false
买入资金不足 NO_CASH(同上) false
限价超出涨跌停 PRICE_LIMIT(同上) false
非交易时段 MARKET_CLOSED(同上) true

只要码值稳定并告知我们,用什么字面量都行PMS 侧照着记录与分流即可。 现在 999999.SH 能成交,说明这一层完全缺失。

R3 部分成交 PARTIAL

一张委托分多笔成交,期望消息序列:

ack                                     accepted=true
order_update  status=PARTIAL            cum_qty=30,  leaves_qty=70
trade         trade_no={broker_order_id}#1   qty=30
order_update  status=PARTIAL            cum_qty=80,  leaves_qty=20
trade         trade_no={broker_order_id}#2   qty=50
order_update  status=FILLED             cum_qty=100, leaves_qty=0
trade         trade_no={broker_order_id}#3   qty=20

重点是 trade_no 必须按 §5.5 用 {broker_order_id}#{n} 逐笔编号——PMS 侧靠它做第二层去重, 一委托多成交时没有它就无法逐笔判重。

另需一种「部分成交后不再继续」的情形(剩余部分最终走撤单或过期), 这是实盘最常见的收尾方式PMS 的窗口收口逻辑要靠它验证。

R4 valid_until 到期自动撤

到点后主动推 order_update status=EXPIREDleaves_qty 归零。 现在秒成所以从未触发过。R1 做完后这一类自然可测。


4. 每一类的期望消息序列(供实现参考)

主动撤单

PMS  → place_order
QMT  → ack (accepted=true, broker_order_id)
QMT  → order_update  status=SUBMITTED
PMS  → cancel_order  (带 cancel_id)
QMT  → order_update  status=CANCELLED, leaves_qty=0

到期过期

PMS  → place_order (valid_until = now + 60s)
QMT  → ack / order_update SUBMITTED
     ...挂着不成,到点...
QMT  → order_update  status=EXPIRED, leaves_qty=0

拒绝

PMS  → place_order (非法参数)
QMT  → reject { instruction_id, code, reason, retryable }

注意 reject 必须带 instruction_id——不带的话 PMS 会判定为「协议级拒绝」 (拒的是消息本身而非委托),走完全不同的处理分支。


5. 待确认的历史遗留项

5.1 _sender_loop 的队列与 socket 生命周期不一致

之前在 QMT_SIDE_CONTROL_PATH.md 里提过,不确定是否已处理:

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() 拿到的是 新队列的消息,却发往旧的已关闭 socketsend 抛异常 → break,消息丢失, 新连接那边在等一个永远不来的回报。重连越频繁越容易撞上。

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

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

5.2(可选,优先级低)pong 是否需要带 seq

现状 publish('pong', {}, with_seq=True) 符合 §5「所有上行消息带 seq」实现没问题。 只是 5 秒一条、一天约 1.7 万条纯 {},既占 Redis 也进 PMS 的 pms_qmt_inbox 且按 §6.1 断线重连时还要全部补发一遍。

若双方同意给 pong 免掉 seqPMS 侧无需改动(现有代码两种都能正确处理), 协议 §5 补一句例外说明即可。不急,联调完再议也行。


6. 联调配合方式

R1 落地后PMS 侧用 scripts/ws_smoke.py 逐类构造,每类跑完把 pms_qmt_order 的状态推进与 pms_qmt_inbox 的消息序列对一遍。 五类各至少一次 + 日终对账零差异,即为 S3 通过,之后进入小仓位实盘。

联调期 PMS 侧的测试单会带 SMOKE_ 前缀的父指令,成交不进 PMS 账本 所以贵方不必担心测试数据污染我们的持仓——放心构造各种极端情形。

优先级建议:R1 > R2 > R3 > R4。R1 一做完就能解锁撤单与过期两类, 是投入产出比最高的一项。