diff --git a/QMT_SIDE_S3_CLOSEOUT.md b/QMT_SIDE_S3_CLOSEOUT.md new file mode 100644 index 0000000..e79557e --- /dev/null +++ b/QMT_SIDE_S3_CLOSEOUT.md @@ -0,0 +1,182 @@ +# 给 QMT 侧:S3 收尾的五件事(2026-07-30) + +> 面向 QMT 侧研发。上一份是 `QMT_TEST_ENV_REQUIREMENTS.md`(R1–R4 模拟环境需求), +> **R1–R4 已于今天上午验完,主体可以划掉**——先说这个,避免重复投入。 +> 本文只列剩下的五件事,按优先级排。第 1 条阻断切换,第 5 条最容易被忽略但后果最重。 + +--- + +## 0. 先说已经通了的(无需再动) + +| 项 | 结果 | 实测依据 | +|---|---|---| +| **R1 可控成交** | ✅ | `600000.SH sell 100 @10.13`(涨跌停带内、高于市价)停在 `SUBMITTED`、成交 0。同代码同数量的 `@99.99` 昨天是秒成,行为确实改了 | +| **R2 代码校验** | ✅ | `999999.SH` → `reject{instruction_id, code:"UNKNOWN_CODE", reason:"instrument not found: 999999.SH"}`。三点全对:带 `instruction_id`(委托级而非协议级)、码值取自协议 §7.3 表而非自造、reason 带原文。**这一类做得很干净** | +| **R3 部分成交** | ✅ | `PARTIAL → PARTIAL → FILLED` 序列正确(见下面第 1 条,只有 `trade_no` 的格式要改) | +| **R4 到期自动撤** | ✅ | `valid_until` 到点推 `order_update status=EXPIRED`,`leaves_qty` 归零 | +| **主动撤单** | ✅ | `SUBMITTED → CANCELLED`,`leaves_qty=0`,与我方本地 `cancel_state` 一致 | +| 控制面不被业务阻塞 | ✅ | 时钟差稳定在 +0.92 秒,最慢一条上行只比最快的晚 164 ms。`ack_seq` 挪出控制面那一改很有效——昨天那个 +30 秒的投递卡顿再没出现 | + +**协议 §9 的 S3「五类各至少一次」至此凑齐**(全成 / 部分成交 / 主动撤单 / 到期过期 / 拒绝)。 + +--- + +## 1. `trade_no` 的格式不合协议 §5.5(**这一条阻断切换**) + +实测收到的是: + +``` +trade_no: "T-SHADOW-6a21…" ← 每笔一个随机串 +trade_no: "T-SHADOW-9f64…" +trade_no: "T-SHADOW-772b…" +``` + +协议 §5.5 定的是 **`{broker_order_id}#{n}`**,即同一张委托 `SHADOW-7c472c48f0` 的三笔应为: + +``` +SHADOW-7c472c48f0#1 +SHADOW-7c472c48f0#2 +SHADOW-7c472c48f0#3 +``` + +**为什么这不是格式洁癖。** PMS 侧有两层去重:seq 水位(§6.1)+ `trade_no` 唯一索引(§5.5)。 +随机串**当下**能去重——唯一索引照样拦得住重复。问题在**补发**: + +按 §6.1 断线重连要重发 `last_seq+1` 之后的消息。如果贵方在补发时**重新生成**了随机 +`trade_no`,同一笔成交就会带着两个不同的 `trade_no` 到达 PMS。第二层去重当场失效,只剩 seq +一层;而 seq 那层在我方水位回退或 inbox 清理后也不存在了。**结果是同一笔成交入账两次**—— +持仓多算、摊薄成本算错,而摊薄成本是我方安全垫的分母,安全垫又是补仓/加仓/减仓的共同判据。 + +`{broker_order_id}#{n}` 是**确定性**的:委托号 + 第几笔,补发一万次都是同一个值。这正是协议 +当初定这个格式的理由。 + +**请确认**:现在的 `T-SHADOW-xxx` 在补发时是原值重放,还是重新生成?如果是重新生成, +这条必须改完我们才敢切 `PMS_DISPATCH_MODE=ws`。 + +--- + +## 2. 限价超出涨跌停区间:请回 `BAD_PARAM` + +实测 `600000.SH sell 100 @99.99`(浦发涨停价约 11.1,挂到市价近 10 倍)**既没被拒也没成交**, +一路挂到 `EXPIRED`。 + +**这一半是我们协议的锅,已经修了。** 原 §7.3 拒绝码表里: + +- `LIMIT_UP` / `LIMIT_DOWN` 指的是「涨/跌停**无法成交该方向**」——价格合法,只是此刻撮不上, + 所以 `retryable=是`(换价重发有意义) +- `BAD_PARAM` 原文只写了「价格**精度**超限」 +- **「限价超出当日涨跌停区间」没有落点**——贵方按表实现,找不到该回哪个码 + +协议已升 **V1.0.1**:`BAD_PARAM` 的含义扩为「价格不合法——精度超限,**或限价超出当日涨跌停 +区间**」,并补了与 `LIMIT_UP`/`LIMIT_DOWN` 的分界说明。**消息集与状态机一字未动,贵方现有实现 +不改也合规**,只是多一个校验点。 + +请在模拟环境补上这个校验:越界限价 → `reject{code:"BAD_PARAM", retryable:false}`。 +(顺带提一句:变更记录表的顺序我们也修了,原先 V1.0 那行排在 V0.9.2 上面,正是 `ack_seq` +被漏掉的原因。现在按版本号从旧到新排,**读到最后一行才是当前版本**。) + +--- + +## 3. 请提供 07-29 的 `signature verification failed` 日志段 + +我方 `pms_qmt_inbox` 里有 **19 条连续的**: + +```json +seq 6299 … 6317 +{"instruction_id": null, "code": "SIG_INVALID", + "reason": "signature verification failed", "retryable": false} +``` + +即**贵方验不过我方的签名**,连续 19 条。签名是协议三条安全底座之一,这个数量不像偶发。 + +我方现在查不动了:那段 pms-ws 日志随容器重建消失,且 PMS 侧不留发送侧流水,所以**无从知道 +被拒的是哪 19 条消息**。这很要紧——协议级 `reject` 我方不自动重发,**那 19 条消息永久丢失**。 +如果里面有 `place_order` 或 `cancel_order`,那就是「发出去了没人执行」,而通道一路显示 ONLINE。 + +**请帮忙查**:贵方收到的原始帧一定在日志里。想知道两件事: + +1. 被拒的是哪些 `type`(`place_order` / `cancel_order` / `ack_seq` / `hello` / `ping`?) +2. 验签失败的直接原因——公钥读错?规范化串对不上?还是某类消息的字段序列化有差异? + +**一个我们比较怀疑的方向**:带中文 `note` 的 `place_order`。我方联调单的 `note` 是 +「ws 联调测试单」,UTF-8 处理只要有一点差异(转义、`ensure_ascii`、字节序)规范化串就对不上, +而其它不带中文的消息都正常。协议 §2.1.1 的测试向量建议双方再各跑一次比对。 + +--- + +## 4. `_sender_loop` 的队列与 socket 生命周期不一致(第三次提,仍未得答复) + +`QMT_SIDE_CONTROL_PATH.md` 与 `QMT_TEST_ENV_REQUIREMENTS.md` §5.1 都提过: + +```python +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()` 拿到的是**新队列**的消息,却 +发往**旧的已关闭 socket** → `send` 抛异常 → `break`,**消息静默丢失**,新连接那边在等一个 +永远不来的回报。 + +建议把队列作为参数传入,与 socket 同生命周期: + +```python +queue = asyncio.Queue() +self._send_queue = queue +sender = asyncio.create_task(self._sender_loop(websocket, queue)) +``` + +**这条和第 3 条的现象可以互相解释**(都是「消息发出去了但对端没正确处理」),所以一起看。 + +--- + +## 5. 重建模拟持仓时,`cost_price` 必须是真实成本价(最容易忽略,后果最重) + +背景:贵方今天上午清空了 `trading_position`,我方也把账本清干净了(PMS 侧 07-30 11:14)。 +**现在双方都是空的**,等贵方装上持仓后我方按「以下游为准」认领重建。 + +请务必注意——`trading_position` 里的 **`cost_price` 会直接变成我方账本的开仓价**: + +- 我方 `ADD_RECON_LOT` 的开仓价**优先取 `cost_price`**,取不到才拿现价兜底(并标注是估的) +- **若 `cost_price` 填 0、留空、或等于现价**,我方摊薄成本就等于当天价、**安全垫齐刷刷是 0** +- 安全垫是盈利加仓(≥3%)、保垫减仓(峰值≥6%)、补仓评估档(−8%/−15%)**共同的判断依据**, + 它一错,整条纪律链跟着错,页面和日报上的浮动盈亏也全是 0 +- 一只真实成本 20、现价 10 的票(实亏 50%)会被记成「不赚不亏」,该评估的补仓不评估 + +2026-07-29 首次接管 22 只持仓时踩过一次。我方取值优先级已有单测守着,**但数据本身对不对 +只能靠贵方**。`available_quantity`(T+1 可卖)同理,请填合理值。 + +**另外两件请提前通知我方的事:** + +1. **重建模拟环境会不会重置 seq / 清空存上行消息的 Redis?** 目前我方水位 8052,与贵方 + 自报 8045 在握手那一刻是对齐的。若贵方 seq 重置回小值而我方水位不动,就是**序号倒挂**—— + 我方会把贵方此后发的每一条(**含成交**)都当成「不高于水位」静默丢弃,而通道显示一切正常。 + 这种情况必须双方**商定同时归零**,我方绝不会单方面下调水位(那会导致区间内成交重复入账)。 +2. **装持仓的时点**。我方 15:10 的日终结算会自动认领 `trading_position` 的内容,且账本为空时 + 不设任何拦阻。所以贵方装到一半时如果正好跨过 15:10,我方会拿半成品建账。装好了说一声 + 即可,我方在那之前会把调度停掉。 + +--- + +## 6. 已经闭环、不用再回的 + +- **`trading_buy_plan` 轮询已于 2026-07-30 10:55 停止** —— + `QMT_INTERFACE_REQUIREMENTS.md` C2「旧通道停用清单」那个长期未决项至此关闭,多谢。 +- `nonce` 16 字节(§2.2)、`ack_seq` 累积确认(§4.5)、§6.1 序号补发、Ed25519 双向签名互通 —— 全部实测通过。 + +**唯一还想确认的是 D3**:贵方当前消费决策系统信号的**全部位置**是否都停了? +C2 只覆盖了 `trading_buy_plan` 这一条路,「直接执行决策系统卖出指令与盘中 ENTRY/EXIT 信号」 +那一条如果还有别的消费点,切换当天两套系统会对同一只票同时下单。 + +--- + +## 优先级建议 + +**1(阻断切换) > 5(数据正确性,做在装持仓之前) > 3(安全底座) > 2 > 4** + +第 1 条改完 + 第 5 条按口径装好持仓,我方即可进入「小仓位实盘」;第 3 条不查明,签名这条 +安全底座上就一直挂着一个问号。 diff --git a/WS_INTEGRATION_STATUS.md b/WS_INTEGRATION_STATUS.md index 485d50d..4e6081b 100644 --- a/WS_INTEGRATION_STATUS.md +++ b/WS_INTEGRATION_STATUS.md @@ -1,4 +1,4 @@ -# ws 联调进度与交接(截至 2026-07-30 上午) +# ws 联调进度与交接(截至 2026-07-30 午间) > 给下一次接手的人(或下一个对话)用。三分钟读完就能接着干。 > 入口文档仍是 `README.md` / `POSITION_MGMT_DESIGN.md`(V0.4 定稿,勿改)/ @@ -8,9 +8,9 @@ ## 1. 一句话状态 -**ws 通道全线打通并实测通过;S3 五类已凑齐(07-30 上午补完撤单/过期两类);`PMS_DISPATCH_MODE` -仍是 `shadow`,一次真实指令都没发过。卡在切换前的是三件事:对端 `trade_no` 不合 §5.5、 -账本被停机遗留数据污染待重建、19 条 `SIG_INVALID` 未查明。** +**ws 通道全线打通并实测通过;S3 五类已凑齐(07-30 上午补完撤单/过期两类);账本已于 07-30 11:14 +清干净,PMS 与下游双方持仓皆空、等对端装持仓后认领;`PMS_DISPATCH_MODE` 仍是 `shadow`,一次 +真实指令都没发过。卡在切换前的是两件事:对端 `trade_no` 不合 §5.5、19 条 `SIG_INVALID` 未查明。** --- @@ -46,7 +46,30 @@ ## 3. 当前状态与未决项 -### 3.1 账本不是空的——是脏的(07-30 更正) +### 3.0 账本已清干净(07-30 11:14)——当前基线 + +`reset_ledger.py --yes --drop-reports` 执行完毕,删 816 行: +`pms_lot` 33 / `pms_position` 24 / `pms_instruction` 7 / `pms_action_ledger` 750 / +`pms_proposal` 1 / `pms_daily_report` 1(`pms_cash_flow`、`pms_plan`、`pms_command` 本来就是 0)。 + +**当前基线**(下一次开工从这里读起): + +| 项 | 状态 | +|---|---| +| PMS 账本 | 空。`/api/positions` 返回 `positions: []`,`cash_est` = 200 万 | +| 下游 `trading_position` | **QMT 侧已清空**(07-30 上午),那行 `999999.SH × 2000` 假数据没了 | +| 通道两表 | **有意保留**:`pms_qmt_order` 7 行 / `pms_qmt_inbox` 7881 行。留着是零风险(trade 行都是 `processed=1`,只有 0 才会被消费),换来上行审计与 trade_no 去重层都在,且出口表是 SMOKE_ 联调单的唯一判据 | +| ws seq 水位 | 8052,**未动**。与对端自报 8045 在握手那一刻是对齐的(收发差即会话内 pong 推进),无倒挂无缺口 | +| 回放游标 | 重新 seed 到 `SELL_688819.SH_1774588308`(字典序锚点,此后不追认历史) | +| `PMS_RECON_STREAK` | 归零 | +| `PMS_SIGNAL_ENABLED` | **False**(清账期间关的)。**账本重建完必须打开**,理由见 §7 | +| 下游 `trading_buy_plan` 轮询 | **已于 2026-07-30 10:55 停止**——`QMT_INTERFACE_REQUIREMENTS.md` C2 那个「当前最大未决项」至此闭环 | + +调度恢复后一轮四个调度位全部空转正常:`signal_digest` 报「已关闭」、`command_poll` 空、 +`intraday_exec` `candidates: 0` / `mode: shadow`、`replay_fills` `fills 0` + `ws.trades 0` + +`light_recon {diffs: 0, severity: OK}`。 + +### 3.1 清账前是什么样(留档,问题已修) 07-29 那次事故(15:10 `daily_settle` 的 `reconcile()` 读到**下游持仓表空集**,按铁律 「以下游为准」把 22 个持仓、约 126 万市值全部核销)依然成立,机制也已由代码确认 @@ -65,7 +88,7 @@ trade 引用了 `4ec5ce4c` / `f0efcf41` / `43e47c39` 三个**出口表里根本 - 14 条 trade 现在全部 `processed=1`(已入账),不是「已消化」 - 根因是**上下游都停了但遗留数据没清**——停机遗留 ≠ 外部成交,这个缺口已在 §4 堵掉 -- 结论不变:接受损失。等对端装上像样持仓 → `reset_ledger.py` → 重新认领 +- 处置:已按 §3.0 清账重来。那 22 只的批次结构不可逆地没了,接受损失 ### 3.2 下游只有垃圾 @@ -144,7 +167,19 @@ code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验 - 只能向 QMT 侧要 07-29 的 `signature verification failed` 日志段——他们收到的原始帧一定在 - 顺带一个待补的能力:PMS 侧不留发送侧流水,所以事后无从知道被拒的是哪几条。要不要加,见 §6 -**5.4 `_sender_loop` 生命周期(`QMT_SIDE_CONTROL_PATH.md` §5.1,仍未得答复)** +**5.4 重建模拟持仓时 `cost_price` 必须是真实成本(新增,最容易被忽略)** + +账本空着的时候**爆炸半径闸是完全放开的**(`_recon_blast_guard` 第一行 `if not held: return None`), +而 `daily_settle`(15:10,`apply_fix=True`,休假模式照跑)会把 `trading_position` 里的东西 +**无条件**认领进账本。所以对端往那张表里写什么,账本就长成什么。 + +`ADD_RECON_LOT` 的开仓价优先取下游 `cost_price`,取不到才拿现价兜底。**若 `cost_price` 是 0 +或等于现价**,摊薄成本就等于当天价、安全垫齐刷刷是 0——而安全垫是盈利加仓(≥3%)、保垫减仓 +(峰值≥6%)、补仓评估档(−8%/−15%)共同的判断依据,一错就是整条纪律链。07-29 首次接管 22 只时 +踩过一次,已有单测守着 PMS 侧的取值优先级,但**数据本身对不对只能靠对端**。 +`available_quantity`(T+1 可卖)同理,请填合理值。 + +**5.5 `_sender_loop` 生命周期(`QMT_SIDE_CONTROL_PATH.md` §5.1,仍未得答复)** 队列绑定(每轮重读 `self._send_queue`)与 socket 绑定(创建时绑死)生命周期不一致,重连 频繁时旧 sender 会从新队列取消息发往已关闭 socket,消息静默丢失。**这条与 5.3 的现象可以 @@ -155,15 +190,17 @@ code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验 ## 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` +2. ~~清账重来~~ ✅ 07-30 11:14 完成,基线见 §3.0 +3. **发函 QMT 侧**(§5.1~5.5 五条,见 `QMT_SIDE_S3_CLOSEOUT.md`) +4. **等对端装持仓 → 认领账本**。对端装好之前,若不想让 15:10 的 `daily_settle` 拿半成品建账, + 就先 `docker compose stop pms-beat`;装好确认 `cost_price` 无误后再 `--profile sched up -d pms-beat`。 + 探下游用 `curl -X POST '.../api/ops/reconcile?apply_fix=false'`(只看不改) +5. **账本重建完立刻把 `PMS_SIGNAL_ENABLED` 打开**(见 §7 最后一条) +6. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单 +7. 日终对账零差异 → S3 完整通过 +8. 两边都确认,再切 `PMS_DISPATCH_MODE=ws` -第 4 步必须在**交易时段**做,原因见下。第 3 步之前先跑一次 `ws_smoke status`, -看 `挂起待人工` 那个数字——07-30 那 11 笔已经入了账,新的孤儿成交才会被挂起。 +第 6 步必须在**交易时段**做,原因见下。 --- @@ -174,6 +211,18 @@ code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验 `cp docker-compose.dev.yml docker-compose.override.yml` 挂载源码(此后 `restart` 才有效, 但改 `requirements.txt` 仍需 build)。**这个坑吃掉过一整轮排查。** +**账本空的时候,对账的爆炸半径闸是完全放开的。** `_recon_blast_guard` 第一行就是 +`if not held: return None # 空账本 → 建账/认领, 放行`,这是为首次建账留的口子。代价是:空账本 +期间 `trading_position` 里出现什么,15:10 的 `daily_settle` 就无条件认领什么(`apply_fix=True`, +且 `respect_exec_halt=False` 休假模式照跑)。盘中那个轻对账是 `apply_fix=False`,只看不改, +所以**风险窗口只有 15:10 这一次**。对端持仓没稳定前,停 `pms-beat` 比事后再清一遍账省事。 + +**账本空的时候消化信号会把信号 ACK 掉。** `_handle` 判 `ACT_IGNORE` 之后照样 `_ack(db, key, msg_id)` +——而账本空 → `positions_view()` 取不到持仓 → 所有卖出信号一律 IGNORE。真实持仓在 QMT 那边, +那些本该处理的风控卖出会被消费组静默吃掉,跨日再也拿不回来。所以清账期间 `PMS_SIGNAL_ENABLED=False` +是对的,**但账本重建完必须立刻打开**。(候选改进:IGNORE 原因是「账本无此持仓」时不 ACK, +或账本为空时整体跳过而非消费——与孤儿成交同一类毛病,丢的东西重建不回来且一声不响。) + **出口表被清过(或换过环境)会造出「孤儿成交」。** inbox 里的 trade 带着 instruction_id, 出口表里却没有对应行——`SMOKE_` 隔离的判据正是反查出口表,所以那道闸对孤儿成交**完全失效**。 07-30 之前这类成交会被当成外部成交并入 BASE;现在改为挂起(`processed=3`)+ ERROR 告警。