22 KiB
ws 联调进度与交接(截至 2026-07-30 午间)
给下一次接手的人(或下一个对话)用。三分钟读完就能接着干。 入口文档仍是
README.md/POSITION_MGMT_DESIGN.md(V0.4 定稿,勿改)/QMT_WS_PROTOCOL.md(V1.0 定稿)。本文只记当前状态和踩过的坑。
1. 一句话状态
ws 通道全线打通并实测通过;S3 五类已凑齐(07-30 上午补完撤单/过期两类);账本已于 07-30 11:14
清干净,PMS 与下游双方持仓皆空、等对端装持仓后认领;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.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 |
pms-beat |
停着(07-30 12:xx 主动停的)。原因:账本空时爆炸半径闸放行,15:10 的 daily_settle 会无条件认领 trading_position / ws 快照里的东西,而那 1100 股来历待对端确认(§5.1)。pms-web / pms-ws 照常跑——页面能看,ws 继续拉快照 |
PMS_TOTAL_SCALE |
98 万(07-30 由 200 万改,与模拟账户 total_asset 981448.56 对齐)。实盘前按真实投入资金重设 |
| ws 持仓/资金快照 | ✅ 已通。query_positions / query_funds 每 300 秒一轮,对端字段完全按 §5.7。持仓 1 条:600000.SH 1100 @ 9.2732;资金:total_asset 981448.56 / available_cash 971251.56 / sell_return_today 0 |
下游 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 万市值全部核销)依然成立,机制也已由代码确认
(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 堵掉
- 处置:已按 §3.0 清账重来。那 22 只的批次结构不可逆地没了,接受损失
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,没人报数就是一笔无人知晓的漏账 |
07-30 午间两笔(都是「协议定了但代码没实现」,不是 bug)
| 补的东西 | 位置 | 为什么之前看不见 |
|---|---|---|
| ws 快照对账通路(协议 §6.2) | runner._query_loop(第五个协程)+ ledger_service.positions_source + ws_codec.parse_positions_snapshot |
协议 §6.2 写着「这是 PMS 侧既有的对账引擎」,实际一行都没有:不发 query_*、snapshot/position_update 落库即 processed=2 无人读、reconcile 只认 trading_position 表。而那张表在新架构下没有写入方——07-30 实测 QMT 已切模拟仓且功能正常,表却是空的(fetch_positions 的 columns 三个 None 就是铁证) |
| 买入前资金校验(A3 的原意) | portfolio.cash_view + rule_gate._check_cash |
全系统只有一个 cash_est = scale − 市值,那是从参数算出来的虚数。scale=200 万而账户真有 98 万,规划器排出的方案规则闸一路放行,要等 QMT 回 INSUFFICIENT_CASH 才被拒;而 reject 不自动重发,择时下一跳又算又发又拒——页面看着正常,实际一单也下不去 |
| 偏差可见 | 页面「账户可用」指标 + 偏差 >10% 的 banner(scaleGap);日报关注区 规模与账户不符 / 资金快照未接通(阈值 PMS_SCALE_GAP_ALARM,默认 0.10) |
上面那条洞之所以到 07-30 才发现,就是因为页面上完全看不出异常:只显示 cash_est,而它永远等于 scale − 市值,看起来总是很充裕。改 index.html 前按 07-29 白屏那个坑做了全文体检(无自闭合自定义标签、el-alert 8/8、div 90/90) |
对账事实源的三条仲裁(positions_source,ws 为主 / 表为兜底):① ws 快照新鲜 → 用 ws;
表也非空且对不上 → 照样用 ws 但记 SOURCE_DISAGREE(说明表的写入方与 QMT 不同步)② ws
缺失/过期/字段不认 → 退回表并说明是哪种原因(三种处理方式完全不同)③ 两个源都没有应答
→ source=none,本端有持仓则拒绝对账、force 也不放行(没有读数可供人工确认,要清账走
reset_ledger.py);本端也空则只留 note 不报 ERROR。
「应答了空集」≠「没应答」:表查询成功返回 0 行是有效数据,归 table 交给爆炸半径闸处理,
force 能放行——一开始把两者混为一谈,直接把 07-29 那个「下游读空绝不清账」用例的 force 分支挂了。
资金校验的两条口径:scale 管仓位纪律(该不该买这么多)、available_cash 管买不买得起,
两个不同的约束不合并;cash_avail 必须含 sell_return_today(当日回笼 T+0 可用),否则「卖一只
买另一只」这条最常见的换仓路径会被判成资金不足。拿不到 ws 资金快照时降级不拦但留痕
(CASH_ESTIMATED)——与研判闸同一口径,一律拒等于把通道故障升级成业务停摆。
代码格式归一:ws 快照的 ts_code 强制转点式。不转的话 diff 会拿 SH600000 比账本里的
600000.SH,每一只都对不上——账本那只判「下游没了」要核销、ws 那只判「新持仓」要补,
一次格式不一致就能造出一轮双向全量重写,比读空还狠。有单测锁着。
孤儿成交这条的判断依据(值得记住的口径):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 重建模拟持仓时 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 的现象可以
互相解释(都是「消息发出去了但对端没正确收到」),一起问更省事。
6. 下一步
确认对端实现了 R1–R4 的哪几条✅ 07-30 上午完成,结果见 §2清账重来✅ 07-30 11:14 完成,基线见 §3.0- 发函 QMT 侧(§5.1~5.5 五条,见
QMT_SIDE_S3_CLOSEOUT.md) - 等对端答复 §5.1(那 1100 股的来历)再认领账本。
pms-beat现在停着,所以 15:10 不会 自动建账。答复回来后:是联调残留 → 请对端清掉、我方不认领;是真持仓 → 跑curl -X POST '.../api/ops/reconcile?apply_fix=true'认领,然后--profile sched up -d pms-beat。 探数据用apply_fix=false(只看不改)或ws_smoke.py inbox --type snapshot --width 600 - 账本重建完立刻把
PMS_SIGNAL_ENABLED打开(见 §7 最后一条) - 盘中跑
scan-proposals?dry_run=true+exec-tick?dry_run=true,看真实候选清单 - 日终对账零差异 → S3 完整通过
- 两边都确认,再切
PMS_DISPATCH_MODE=ws
第 6 步必须在交易时段做,原因见下。
7. 已知的坑(别再踩)
docker compose restart 不重载代码。 源码是 COPY . . 打进镜像的,restart 只是
重启老容器。改完代码要生效:./scripts/deploy.sh,或在开发机上
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 告警。
上下游停机但遗留数据没清时最容易撞上,重建账本前先看一眼 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。
改的是定稿协议正文里的一处事实值,故未擅动。