tradingSystem/WS_INTEGRATION_STATUS.md

216 lines
14 KiB
Markdown
Raw Normal View History

# ws 联调进度与交接(截至 2026-07-30 上午)
2026-07-30 08:50:41 +08:00
> 给下一次接手的人(或下一个对话)用。三分钟读完就能接着干。
> 入口文档仍是 `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` 未查明。**
2026-07-30 08:50:41 +08:00
---
## 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` 划了个问号。
2026-07-30 08:50:41 +08:00
---
## 3. 当前状态与未决项
### 3.1 账本不是空的——是脏的07-30 更正)
2026-07-30 08:50:41 +08:00
07-29 那次事故15:10 `daily_settle``reconcile()` 读到**下游持仓表空集**,按铁律
「以下游为准」把 22 个持仓、约 126 万市值全部核销)依然成立,机制也已由代码确认
`reconcile` 无条件应用 fixes且空结果集会跳过数量列校验。但**清空之后账本又长出了东西**
2026-07-30 08:50:41 +08:00
```
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` → 重新认领
2026-07-30 08:50:41 +08:00
### 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,13916.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`
2026-07-30 08:50:41 +08:00
---
## 5. 待对端QMT 侧)
R1R4 已在 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`),不自造,所以是对端的格式。
2026-07-30 08:50:41 +08:00
为什么必须改:随机串**当下**能去重(唯一索引照样拦),但它**不确定**。§6.1 的补发场景里,
对端若重新生成一次随机串,同一笔成交就拿到两个不同的 `trade_no` → 第二层去重失效,只剩 seq
一层;而 seq 那层在水位回退或 inbox 被清后也没了 → **重复入账,摊薄成本与安全垫一起错**
`{broker_order_id}#{n}` 是确定性的,补发多少次都是同一个值——这正是 §5.5 当初定这个格式的理由。
2026-07-30 08:50:41 +08:00
**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 62996317 连续 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 的现象可以
互相解释**(都是「消息发出去了但对端没正确收到」),一起问更省事。
2026-07-30 08:50:41 +08:00
---
## 6. 下一步
1. ~~确认对端实现了 R1R4 的哪几条~~ ✅ 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
2026-07-30 08:50:41 +08:00
4. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单
5. 日终对账零差异 → S3 完整通过
6. 两边都确认,再切 `PMS_DISPATCH_MODE=ws`
2026-07-30 08:50:41 +08:00
第 4 步必须在**交易时段**做,原因见下。第 3 步之前先跑一次 `ws_smoke status`
`挂起待人工` 那个数字——07-30 那 11 笔已经入了账,新的孤儿成交才会被挂起。
2026-07-30 08:50:41 +08:00
---
## 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` 的「挂起待人工」。
2026-07-30 08:50:41 +08:00
**`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。
改的是定稿协议正文里的一处事实值,故未擅动。