100 lines
5.4 KiB
Markdown
100 lines
5.4 KiB
Markdown
# 趋势策略状态机审计
|
||
|
||
审计日期: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 调用契约问题,无法作为状态机全绿的证明。
|