Files
big-qmt/docs/state.md
2026-08-31 13:00:22 +08:00

100 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 趋势策略状态机审计
审计日期2026-08-31
## 结论
当前状态机的职责明确为:按账户和策略持久化每只证券的底仓信息与**补仓业务数据**。补仓委托提交成功后立即写入状态机是必要且正确的30 秒轮询中的对账用于用券商快照校正订单状态和接管持仓,不能替代补仓次数等业务数据的记录。
状态机主链路可运行,但补仓单被撤销、拒绝或部分成交时,没有完整的状态回写规则;这会使 `added_status``added_num` 与实际成交结果不一致。该问题应在实盘前明确处理。
## 数据模型与持久化
状态文件路径为:`{qmt_data_dir}/{strategy}_{account_id}_state.json`
每个证券对应一个 `StateItem`
| 数据 | 含义 | 写入来源 |
| --- | --- | --- |
| `base_qty``base_cost``base_status` | 首次接管时的底仓数量、成本及状态 | `sync_positions()` |
| `added_num` | 已提交的补仓次数 | 补仓委托提交成功后立即递增 |
| `added_order_id``added_qty``added_cost``added_status` | 最近一笔补仓委托及状态 | 补仓提交后写入30 秒对账尝试校正状态 |
`State.save()` 先写临时文件,再以 `replace()` 原子替换正式 JSON能避免半写入文件但不构成“券商下单 + 本地持久化”的跨系统事务。
## 运行流程
```text
启动
├─ 刷新券商订单 → OrderBook.data
├─ 查询持仓
└─ State.reconcile(持仓, 订单快照)
每 30 秒的 RunOnce
├─ 刷新订单;失败则本轮退出
├─ 查询资产、市场、持仓
├─ State.reconcile(持仓, OrderBook.data)
├─ 开仓OrderBook 方向锁防重;不写 State
└─ 补仓:下单成功 → 立即写 StateItem 并持久化
```
`OrderBook``State` 的边界如下:
- `OrderBook.lock`:进程内的同方向未决委托锁,防止同轮或相邻轮重复提交。
- `OrderBook.data`:本轮的进行中或完成订单快照,提供给 `State.reconcile()`
- `State`:跨进程保存持仓补仓层级及最近补仓记录;它不是开仓防重的唯一来源。
## 对账和清理规则
`State.reconcile()` 在每轮执行以下操作:
1. 对尚未接管的真实持仓创建状态,记录为已完成底仓。
2.`local_order_id` 匹配本地订单号;若拆单全部为状态 `56`,将底仓或补仓状态更新为 `OK`,否则保持 `ING`
3. 删除不再存在真实持仓的证券状态。
4. 保存状态文件。
这意味着:持仓是状态项是否保留的最终依据;补仓计数是业务状态,不会由当前持仓反推或重置。
## 发现的问题
### P1撤销、拒绝和部分成交没有完整回写规则
`OrderBook.refresh()` 仅将进行中状态和完成状态 `56` 放入 `OrderBook.data`。撤销、拒绝等订单会被过滤;超时订单发出 `cancel_by_id()` 后也会直接跳过。`State.reconcile()` 因此找不到对应 `added_order_id`,只能保持原有 `added_status=ING`
影响:状态文件可能长期显示“处理中”,而 `added_num` 已经递增。系统目前未定义以下情况是否消耗补仓档位:
- 委托完全拒绝;
- 撤单且零成交;
- 部分成交后撤单。
建议:使对账可获得终态订单,或在订单簿中显式传递终态映射;为上述三种情况定义 `added_status``added_qty``added_num` 的最终规则,并增加回归测试。
### P1下单成功但状态文件保存失败时重启恢复会丢失补仓层级
补仓路径顺序是 `orders.place()` 成功后,再执行 `state.set()``state.save()`。若保存失败,当前进程仍有订单簿方向锁,但程序重启后状态文件不含本次补仓。`sync_positions()` 只能接管当前持仓,不能从持仓推导历史补仓次数,因此存在重复使用补仓档位的风险。
建议:捕获并明确处理 `state.save()` 失败;至少将其作为不可忽略的交易一致性故障告警。若要求重启后绝不重复补仓,需要可恢复的补仓事件记录或可查询的成交历史作为补偿来源。
### P2每轮对账会执行两次状态文件写入
`reconcile()` 调用 `sync_positions()`,而 `sync_positions()` 无条件 `save()``reconcile()` 结束时又再次 `save()`。这不改变正确性,但每 30 秒至少两次磁盘原子替换。
建议:让 `sync_positions()` 返回是否发生变化,由 `reconcile()` 统一进行一次保存。
### P2底仓订单字段与当前开仓流程不一致
开仓路径不再创建 `StateItem`,而首次出现真实持仓时由 `sync_positions()` 建立底仓状态。因此新开仓的 `base_order_id` 通常为空,底仓订单状态对账主要只适用于遗留/外部写入的数据。
建议:保留该字段前,应明确它是否仍承担审计用途;否则可在后续数据模型整理时移除无效的底仓订单状态分支。
## 测试覆盖
现有测试覆盖了拆单全成后状态改为 `OK`、补仓档位边界和订单簿方向锁,但没有覆盖:
- 补仓单撤销、拒绝、部分成交后的状态和次数;
- `state.save()` 在下单成功后失败的恢复处理;
- 启动恢复后的补仓层级保持;
- 状态项随平仓删除的边界。
`py-client` 目录运行 `python -m compileall -q .` 通过。现有全量单测仍有趋势替身接口不匹配及 IPO 调用契约问题,无法作为状态机全绿的证明。