216 lines
14 KiB
Markdown
216 lines
14 KiB
Markdown
# ws 联调进度与交接(截至 2026-07-30 上午)
|
||
|
||
> 给下一次接手的人(或下一个对话)用。三分钟读完就能接着干。
|
||
> 入口文档仍是 `README.md` / `POSITION_MGMT_DESIGN.md`(V0.4 定稿,勿改)/
|
||
> `QMT_WS_PROTOCOL.md`(V1.0 定稿)。本文只记**当前状态**和**踩过的坑**。
|
||
|
||
---
|
||
|
||
## 1. 一句话状态
|
||
|
||
**ws 通道全线打通并实测通过;S3 五类已凑齐(07-30 上午补完撤单/过期两类);`PMS_DISPATCH_MODE`
|
||
仍是 `shadow`,一次真实指令都没发过。卡在切换前的是三件事:对端 `trade_no` 不合 §5.5、
|
||
账本被停机遗留数据污染待重建、19 条 `SIG_INVALID` 未查明。**
|
||
|
||
---
|
||
|
||
## 2. 已验证(实测,非推演)
|
||
|
||
| 项 | 依据 |
|
||
|---|---|
|
||
| Ed25519 双向签名 | 与对端 `crypto.py` 逐字比对 + 双向验签实测 |
|
||
| 握手 / 心跳 / 重连退避 | 一天累计近百次重连,seq 水位每次无缝续接 |
|
||
| `ack_seq` 累积确认(§4.5) | 对端补上实现后,PMS 不再降级停发 |
|
||
| **§6.1 序号补发** | `ws_smoke rewind --by 60` 人为造缺口,对端从 `last_seq+1` 起严格按序补回 60 条,约 15ms/条,一条不漏 |
|
||
| 委托全链路 | `place_order → ack → order_update(SUBMITTED) → trade → persist_result → order_update(FILLED) → funds_update`,状态推进与字段全对 |
|
||
| 入账分流 | 只有 `trade` 进账本(`processed=0`),其余一律「已消化」 |
|
||
| 页面 ws 监控页签 | 真浏览器实测(含告警渲染、pong 过滤、轮询起停) |
|
||
|
||
**07-30 上午补测(对端已上模拟环境的 R1/R2)**:
|
||
|
||
| 项 | 结果 | 实测依据 |
|
||
|---|---|---|
|
||
| R1 可控成交 | ✅ | `600000.SH sell 100 @10.13`(涨跌停带内、高于市价)停在 `SUBMITTED`、成交 0——同代码同数量的 `@99.99` 07-29 是秒成 |
|
||
| 主动撤单 `CANCELLED` | ✅ | seq 7089 `SUBMITTED` → 7106 `CANCELLED`;本地 `cancel_state=SENT` 与对端终态一致,无不一致告警 |
|
||
| 到期过期 `EXPIRED` | ✅ | `--ttl 1` → seq 7124 `SUBMITTED` → 7137 `EXPIRED` |
|
||
| R2 代码校验 | ✅ | `999999.SH` → `reject{instruction_id, code=UNKNOWN_CODE, reason="instrument not found"}`;码值是协议 §7.3 表里的,带 `instruction_id`(委托级而非协议级),三点全对 |
|
||
| 部分成交 `PARTIAL` | ✅ | 07-29 已产出:`INS-20260729-4ec5ce4c` 走 seq 6028 `PARTIAL` → 6035 `PARTIAL` → 6040 `FILLED` |
|
||
| 时钟与投递 | ✅ | `min +868 ms` = 真实时钟差约 0.87 秒,`max-min` 仅 74 ms——07-29 那个 +30213 ms 的事件循环卡顿没再出现,`ack_seq` 挪出控制面的改法生效了 |
|
||
|
||
**S3 五类至此各至少一次**(全成 / 部分成交 / 主动撤单 / 到期过期 / 拒绝)。
|
||
剩下的验收条件是**日终对账零差异**,以及下面 §5 的三件待办。
|
||
|
||
协议三条安全底座(签名、补发、对账兜底)前两条已齐——但签名那条被 §5 的 `SIG_INVALID` 划了个问号。
|
||
|
||
---
|
||
|
||
## 3. 当前状态与未决项
|
||
|
||
### 3.1 账本不是空的——是脏的(07-30 更正)
|
||
|
||
07-29 那次事故(15:10 `daily_settle` 的 `reconcile()` 读到**下游持仓表空集**,按铁律
|
||
「以下游为准」把 22 个持仓、约 126 万市值全部核销)依然成立,机制也已由代码确认
|
||
(`reconcile` 无条件应用 fixes,且空结果集会跳过数量列校验)。但**清空之后账本又长出了东西**:
|
||
|
||
```
|
||
600000.SH HOLDING 1100 股 avg_cost 9.273 ← 11 笔联调成交被判成「外部成交」并入 BASE
|
||
999999.SH HOLDING 2000 股 avg_cost 9.274 ← 假代码, 防线部署前从下游对账补进来的
|
||
```
|
||
|
||
污染路径(07-30 查明,不是推测):出口表 `pms_qmt_order` 现在只剩 7 行,而 inbox 里的
|
||
trade 引用了 `4ec5ce4c` / `f0efcf41` / `43e47c39` 三个**出口表里根本没有**的 instruction_id
|
||
(共 11 笔)。`consume_ws_trades` 原先沿用 `replay_fills` 的口径「反查不到就按真单走,宁可
|
||
多入账也不能漏账」,于是这 11 笔走了真单分支 → 指令也查不到 → 判为外部成交并入 BASE。
|
||
`SMOKE_` 那道闸**压根没机会生效**,因为它的判据正是「反查出口表」。
|
||
|
||
- 14 条 trade 现在全部 `processed=1`(已入账),不是「已消化」
|
||
- 根因是**上下游都停了但遗留数据没清**——停机遗留 ≠ 外部成交,这个缺口已在 §4 堵掉
|
||
- 结论不变:接受损失。等对端装上像样持仓 → `reset_ledger.py` → 重新认领
|
||
|
||
### 3.2 下游只有垃圾
|
||
|
||
`trading_position` 现在只剩 `999999.SH × 2000`——我们自己测拒绝路径用的假代码。
|
||
现已被代码合法性校验过滤,不会再流进账本。
|
||
|
||
### 3.3 7 张风控 EXIT 已撤销
|
||
|
||
`origin_type=system` / `origin_id=intraday` / `from_signal=true`,理由「风控 SELL 置信度
|
||
95% ≥ 85%, 清仓」。持仓归零后指令悬空,已全部 cancel。**不是用户下的命令。**
|
||
|
||
### 3.4 组合基线(事故前,供重建时参考)
|
||
|
||
22 只 · 市值 1,258,357 / 成本 1,515,139(−16.9%)· 62.9% vs 60% 上限 ·
|
||
22 家 vs 15 家上限 · 16 只 ≤ −15%。
|
||
`688825.SH` 成本 8.66 / 现价 48.5 是**新股中签**,数据正确,非异常。
|
||
|
||
---
|
||
|
||
## 4. 今天新加的防线(都有测试锁死)
|
||
|
||
| 防线 | 位置 | 起因 |
|
||
|---|---|---|
|
||
| 对账爆炸半径 | `ledger_service._recon_blast_guard` | 下游读空清了 22 个持仓 |
|
||
| 下游代码合法性 | `command_spec.is_stock_code` | `999999.SH` 差点被补进账本 |
|
||
| 联调单不入账 | `ledger_service.consume_ws_trades` + `qmt_repo.SMOKE_PREFIX` | 对端按限价全成,每张测试单都在赌账本 |
|
||
| 卖出认领不到时告警 | `recon.map_trades_to_book` | 买入告警而卖出静默核销,不对称 |
|
||
| 补发期告警限流 | `runner._persist_upstream` | 一次补发刷 60 行 WARNING,真乱序会被埋掉 |
|
||
| 时延采样跳过补发件 | `runner._track_skew` | 补发原样重放旧 ts,误报 345 秒卡顿 |
|
||
| **孤儿成交挂起不入账**(07-30) | `ledger_service.consume_ws_trades` + `qmt_repo.PROC_ORPHAN` | 11 笔停机遗留成交被判成外部成交,账本长出 1100 股 |
|
||
| 反查报错时一笔都不判(07-30) | 同上 | 原先把「库抖了」当「查不到」→ 当真单入账,一次超时就能造出一笔外部成交 |
|
||
| 挂起数字露出通道状态(07-30) | `dispatcher.channel_status().orphan_held` + `ws_smoke status` | 挂起的行不再进 `inbox_pending`,没人报数就是一笔无人知晓的漏账 |
|
||
|
||
**孤儿成交这条的判断依据**(值得记住的口径):ws 这条路上的 trade 必带 `instruction_id`(§5.5),
|
||
而那个 id 是 PMS 自己生成、自己写进出口表的,所以**反查不到只可能是数据不一致**,不可能是
|
||
「有人在 QMT 手工下了单」——手工成交走 `trading_order` 那条路,根本不进 inbox。两种错的代价
|
||
并不对称:**漏账看得见**(inbox 留着行、有 ERROR、通道状态报数字),**错账看不见**(凭空长出来的
|
||
持仓与真持仓同形,而摊薄成本和安全垫已经全错,补仓/加仓/保垫减仓全挂在安全垫上)。所以不确定时
|
||
挂起等人工,不猜。`processed=3` 与 `2`(已消化)必须分开——`2` 的语义是「确认过不用管」,混在
|
||
一起事后就分不出哪些是真丢账。要追认:确认这些成交真该入账后,把那些行的 `processed` 改回 `0`。
|
||
|
||
---
|
||
|
||
## 5. 待对端(QMT 侧)
|
||
|
||
R1–R4 已在 07-30 上午验完(结果见 §2)。**R1、R2 的代码校验、R3、R4 均已到位**,
|
||
`QMT_TEST_ENV_REQUIREMENTS.md` 的主体可以划掉。剩下三件:
|
||
|
||
**5.1 `trade_no` 不合协议 §5.5(阻断切换)**
|
||
|
||
实测值是 `T-SHADOW-6a21…` / `T-SHADOW-9f64…`,协议要求 `{broker_order_id}#{n}`,
|
||
即 `SHADOW-7c472c48f0#1/#2/#3`。已核实 PMS 只是原样读 payload 的 `trade_no`
|
||
(`ws_codec.py:418` / `runner.py:488`),不自造,所以是对端的格式。
|
||
|
||
为什么必须改:随机串**当下**能去重(唯一索引照样拦),但它**不确定**。§6.1 的补发场景里,
|
||
对端若重新生成一次随机串,同一笔成交就拿到两个不同的 `trade_no` → 第二层去重失效,只剩 seq
|
||
一层;而 seq 那层在水位回退或 inbox 被清后也没了 → **重复入账,摊薄成本与安全垫一起错**。
|
||
`{broker_order_id}#{n}` 是确定性的,补发多少次都是同一个值——这正是 §5.5 当初定这个格式的理由。
|
||
|
||
**5.2 价格带校验缺失**
|
||
|
||
`600000.SH sell 100 @99.99`(浦发涨停约 11.1)既没被拒也没成交,一路挂到 `EXPIRED`。
|
||
根因一半在协议:原 §7.3 里 `LIMIT_UP`/`LIMIT_DOWN` 指的是「涨跌停无法成交该方向」,
|
||
`BAD_PARAM` 只写了「价格精度超限」,**「限价超出当日涨跌停区间」没有落点**。
|
||
协议已升 **V1.0.1**:`BAD_PARAM` 的含义扩为「价格不合法(精度超限或限价越界)」,
|
||
并补了与 `LIMIT_UP`/`LIMIT_DOWN` 的分界说明。**消息集与状态机一字未动,V1.0 的实现无需改动即合规。**
|
||
|
||
**5.3 19 条 `SIG_INVALID` 未查明(签名底座上的问号)**
|
||
|
||
`pms_qmt_inbox` 里 seq 6299–6317 连续 19 条 `reject{instruction_id: null,
|
||
code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验不过我们的签名**。
|
||
|
||
- PMS 侧日志已随 `force-recreate` 消失,`logs/` 与 `docker compose logs pms-ws` 都翻不到
|
||
- **这 19 条消息永久丢了**:`runner` 对 reject 不自动重发。若其中有 `place_order` 或
|
||
`cancel_order`,那就是「发出去了没人执行」,而通道一路显示 ONLINE
|
||
- 只能向 QMT 侧要 07-29 的 `signature verification failed` 日志段——他们收到的原始帧一定在
|
||
- 顺带一个待补的能力:PMS 侧不留发送侧流水,所以事后无从知道被拒的是哪几条。要不要加,见 §6
|
||
|
||
**5.4 `_sender_loop` 生命周期(`QMT_SIDE_CONTROL_PATH.md` §5.1,仍未得答复)**
|
||
|
||
队列绑定(每轮重读 `self._send_queue`)与 socket 绑定(创建时绑死)生命周期不一致,重连
|
||
频繁时旧 sender 会从新队列取消息发往已关闭 socket,消息静默丢失。**这条与 5.3 的现象可以
|
||
互相解释**(都是「消息发出去了但对端没正确收到」),一起问更省事。
|
||
|
||
---
|
||
|
||
## 6. 下一步
|
||
|
||
1. ~~确认对端实现了 R1–R4 的哪几条~~ ✅ 07-30 上午完成,结果见 §2
|
||
2. **发函 QMT 侧**:§5.1 `trade_no` 改回 `{broker_order_id}#{n}`、§5.2 越界价按 `BAD_PARAM` 拒、
|
||
§5.3 索要 07-29 的 `SIG_INVALID` 日志段、§5.4 再确认一次
|
||
3. **重建账本**:对端装上像样持仓 → `reset_ledger.py` → 认领。注意现在账本是**脏的**不是空的(§3.1)
|
||
4. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单
|
||
5. 日终对账零差异 → S3 完整通过
|
||
6. 两边都确认,再切 `PMS_DISPATCH_MODE=ws`
|
||
|
||
第 4 步必须在**交易时段**做,原因见下。第 3 步之前先跑一次 `ws_smoke status`,
|
||
看 `挂起待人工` 那个数字——07-30 那 11 笔已经入了账,新的孤儿成交才会被挂起。
|
||
|
||
---
|
||
|
||
## 7. 已知的坑(别再踩)
|
||
|
||
**`docker compose restart` 不重载代码。** 源码是 `COPY . .` 打进镜像的,`restart` 只是
|
||
重启老容器。改完代码要生效:`./scripts/deploy.sh`,或在开发机上
|
||
`cp docker-compose.dev.yml docker-compose.override.yml` 挂载源码(此后 `restart` 才有效,
|
||
但改 `requirements.txt` 仍需 build)。**这个坑吃掉过一整轮排查。**
|
||
|
||
**出口表被清过(或换过环境)会造出「孤儿成交」。** inbox 里的 trade 带着 instruction_id,
|
||
出口表里却没有对应行——`SMOKE_` 隔离的判据正是反查出口表,所以那道闸对孤儿成交**完全失效**。
|
||
07-30 之前这类成交会被当成外部成交并入 BASE;现在改为挂起(`processed=3`)+ ERROR 告警。
|
||
**上下游停机但遗留数据没清时最容易撞上**,重建账本前先看一眼 `ws_smoke status` 的「挂起待人工」。
|
||
|
||
**`pms_ws_state.last_seq` 运行时手改无效。** 两条机制会撤销它:`_shutdown` 会把内存水位
|
||
刷回库;`_boot` 会用 `inbox_recover_watermark` 顺着 inbox 连续段走回去。要造缺口用
|
||
`ws_smoke rewind`(它会拒绝在 pms-ws 运行时执行,也会拒绝跨过已入账的成交)。
|
||
|
||
**对端模拟环境按限价全成、不校验代码。** 一张 99.99 的浦发卖单(实际价 10 元)会全成,
|
||
`999999.SH` 也会全成。**任何测试单都会产生成交回报**——所以联调单隔离(`SMOKE_` 前缀)
|
||
是必需的,不是可选的。
|
||
|
||
**收盘后的 dry run 看不出东西。** 取不到实时价时 `positions_view` 用摊薄成本兜底当现价,
|
||
安全垫齐刷刷归零,DCA/ADD/TRIM 三个评估器全不触发,结果永远是 `candidates: 0`。
|
||
判断"切了 ws 会发生什么"必须在交易时段跑。
|
||
|
||
**`pong` 带 seq(符合 §5)。** 5 秒一条、一天约 1.7 万行进 `pms_qmt_inbox`。
|
||
看 inbox 记得用 `ws_smoke inbox --type trade` 过滤,否则全是 pong。
|
||
|
||
**协议 `reject` 不带 `instruction_id` 时是「协议级拒绝」**——拒的是消息本身不是委托,
|
||
走完全不同的分支。对端曾因未实现 `ack_seq` 而回 `unknown type ack_seq`,形成
|
||
「我们 ack → 对端拒 → 拒绝带 seq → 我们又要 ack」的 2 秒自激循环,通道显示 ONLINE
|
||
但只在刷 reject。
|
||
|
||
---
|
||
|
||
## 8. 文档欠账
|
||
|
||
**✅ 已清(2026-07-30,用户批准)**:`QMT_WS_PROTOCOL.md` §11 变更记录里 V1.0 那行原本
|
||
排在 V0.9.2 上面,而 `ack_seq` 恰恰是 V0.9.2 补进来的——对端从上往下读到「V1.0 定稿」
|
||
就停,`ack_seq` 因此被漏掉,直接导致了 §7 那个自激循环。现已改为
|
||
V0.9 → V0.9.1 → V0.9.2 → V1.0。**纯调两行顺序,条款一字未动**(总行数不变,可 `git show` 核)。
|
||
|
||
**遗留一项(待定夺,暂不动)**:端点在 `README.md`「ws 通道实现清单」与协议 §11 的 V1.0
|
||
那行里都写作 `ws://192.168.16.98:8080`,而 `config/settings.py:94` 实际是
|
||
`ws://192.168.16.98:9443/pms`(提交 `0a1a932 调整端口`)。**代码是对的、文档是旧的**,
|
||
联调不受影响(运行时读 settings / 运行参数,不读文档),但下一个接手的人会按文档去连 8080。
|
||
改的是定稿协议正文里的一处事实值,故未擅动。
|