Files
big-qmt/docs/trend-audit.md
2026-09-05 11:46:31 +08:00

152 lines
9.1 KiB
Markdown
Raw Permalink 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.
# Trend 策略代码审计报告
> 历史报告:当前 IPO / Trend 复核结果见 [ipo-trend-audit.md](ipo-trend-audit.md)。下述问题状态与测试统计不代表当前版本;尤其 busy_keys 防重已实现,旧测试文件已按要求清理。
审计日期2026-09-05
审计对象:`py-client/strategy/trend` 当前工作树版本。
关联范围:仅核对直接影响 Trend 行为的 `sdk``libs``config` 和 Trend 测试。
审计方法:重新读取当前代码,不沿用历史审计结论;本次只更新审计文档,不修改策略代码。
## 结论摘要
当前版本不建议直接用于无人值守实盘。发现 2 个严重问题、6 个高风险问题、6 个中风险问题和 2 个低风险/测试问题。最高优先级是本地防重与券商实际活动委托脱节,以及空订单快照会清除未决状态。另有 5 项 Trend 测试在目标断言前报错,当前测试结果不能为下单路径提供有效回归保障。
## 严重问题
### S1. 防重只依赖进程内 TTL完全忽略券商活动委托
- 位置:`strategy/trend/order.py:45-48,80-92,97-104`
- 证据:`refresh()` 虽统计 `busy_keys`,但没有写入 `SimpleCache``busy()``place()` 都只查询 `busy_cache`,没有检查 `self.data`。缓存也不会跨进程恢复。
- 触发场景:程序重启后券商仍有活动订单;或本地 180 秒 TTL 到期但订单仍未终结。
- 影响:同一证券、同一方向可能重复开仓、补仓或止盈。卖出路径可能再次按全部可用持仓提交委托。
- 建议:`busy()``place()` 在同一互斥区内同时检查 TTL 缓存及 `self.data` 中的 `BUSY_STATUSES`
### S2. 一次空订单快照会不可逆地清除待成交状态
- 位置:`strategy/trend/order.py:57-92``state.py:101-160`
- 证据:`refresh()` 每轮用当前券商结果覆盖 `OrderBook.data`。订单刚提交但暂未出现在快照时,`reconcile()` 会把对应 `ING` 改为空字符串;若尚无持仓,随后还会删除整个 `StateItem`
- 影响:底仓状态可能丢失,补仓次数可能不增加;后续轮次可能重新开仓或重复使用同一亏损档位。
- 建议:本地 pending 应保留至明确终态或确认超时;短暂缺席不能立即视为失败。
## 高风险问题
### H1. 大盘风控被固定为允许开仓
- 位置:`libs/market.py:34-38`;调用位置:`strategy/trend/boot.py:153,185-188`
- 证据:`market_allow_open()` 无条件返回 `True`,不读取 `_market_status`
- 影响:市场状态为下跌、未知或刷新失败时,策略仍可开仓和亏损补仓。
- 测试证据:`test_market.MarketCacheTests.test_refresh_failure_blocks_open` 失败。
- 建议:仅在缓存状态明确为 `UP` 时允许开仓;未知状态采用 fail-closed。
### H2. 过滤异常订单后再对账,会把拆单结果判错
- 位置:`strategy/trend/order.py:62-65,80-92``state.py:112-139,211-220`
- 证据:`refresh()` 丢弃取消、拒绝和异常订单,只把处理态及完成态写入 `data`。同一本地订单若一笔完成、一笔取消,传给 `State` 的只剩完成记录,`_order_status()` 会返回 `OK`
- 影响:部分成交可能被视为完整成交;状态数量、成本和补仓次数与真实结果不一致。
- 建议:展示/防重列表可以过滤,但状态对账必须使用完整原始订单快照。
### H3. 柜台受理与状态落盘之间存在崩溃窗口
- 位置:`strategy/trend/open.py:78-98``positions.py:174-193`
- 证据:开仓和补仓都先执行 `orders.place()`,成功返回后才写入并保存 `StateItem`
- 影响:委托受理后若进程退出或状态写入失败,本地没有对应状态;重启后容易重复下单。
- 建议:提交前持久化订单意图,提交后记录柜台结果;不确定结果保留为可对账状态。
### H4. 开仓与补仓并发,资金预算彼此隔离
- 位置:`strategy/trend/boot.py:180-190``open.py:15-74``positions.py:28-91`
- 证据:持仓管理与开仓并发执行。补仓只在自身循环扣减余额;每个开仓信号均独立使用完整 `buy_value`,两条路径没有账户级资金预留。
- 影响:同轮总委托金额可能超过真实可用资金,成交组合取决于柜台顺序。
- 建议:统一生成订单意图,用账户级单一预算预留资金后再提交。
### H5. 撤单结果未校验,活动订单却立即从跟踪列表删除
- 位置:`strategy/trend/order.py:67-78`
- 证据:超过 10 秒即调用 `cancel_by_id()`,不检查响应是否成功,随后无条件 `continue`
- 影响:撤单失败或结果未知时,本地已不再跟踪仍有效的订单;结合 S1 可能重复提交。
- 建议:仅在券商明确返回取消终态后移除;失败或不确定时继续保留活动状态。
### H6. 单笔预算不足一手时仍强制买入 100 股
- 位置:`libs/calc.py:10-12`;调用位置:`strategy/trend/open.py:49``positions.py:168`
- 证据:`calc_buy_volume()` 使用 `max(1, floor(...)) * 100`
- 影响:`buy_value < price * 100` 时委托金额必然超过预算,并放大 H4。
- 建议:不足一手时返回 `0`,由调用方记录并跳过。
## 中风险问题
### M1. 现金安全线只限制新开仓,不限制亏损补仓
- 位置:`strategy/trend/boot.py:145-148,183-188``positions.py:68-80`
- 证据:`allow_open_by_cash` 只控制 `open_signal()``manage_positions()` 始终获得全部 `assets.available`
- 影响:账户已低于最小现金比例时仍可能增加亏损仓位。
- 建议:若安全线也约束补仓,应只传递扣除安全储备后的预算。
### M2. 两个风控配置未参与 Trend 决策
- 位置:`config/__init__.py:48-50``strategy/trend/positions.py:16,52-59,157-160`
- 证据:配置提供 `loss_trigger_pct``min_profit_pct`,但补仓使用固定 `LOSS_TIERS`,止盈门槛按股价区间硬编码。
- 影响:修改配置不会改变实盘行为,运维人员可能误判实际参数。
- 建议:让策略明确使用配置,或删除无效配置并输出最终生效参数。
### M3. 非 `APIError` 下单异常会终止整批持仓管理
- 位置:`strategy/trend/order.py:105-116``positions.py:38-91`
- 证据:`OrderBook.place()` 只捕获 `APIError``manage_positions()` 没有逐持仓异常边界。
- 影响:一只证券发生连接、超时等异常后,其余持仓当轮不再处理。
- 建议:订单层捕获明确的传输异常,持仓循环增加逐证券隔离。
### M4. 启动阶段异常不会可靠释放 HTTP Client
- 位置:`strategy/trend/boot.py:47-84`
- 证据Client 创建后的组合查询、撤单、状态读取和信号加载不在统一 `try/finally` 中。
- 影响:初始化失败时连接池不能确定及时释放。
- 建议:建立统一资源生命周期,在 `finally` 中关闭已创建资源。
### M5. 信号只在启动时加载一次
- 位置:`strategy/trend/boot.py:67-71,88-110`
- 证据:`init_signals()` 位于永久循环外。
- 影响:运行期间新增、撤销或修正的远程信号不会生效。
- 建议:按业务时效定期刷新,或明确“启动快照整日有效”的约束。
### M6. 明确拒单后仍保留 180 秒缓存锁
- 位置:`strategy/trend/order.py:97-126`
- 证据:柜台调用前设置缓存,但 API 明确失败、响应格式无效或抛出 `APIError` 时均不删除键。
- 影响:可立即纠正或重试的订单被无条件抑制 180 秒,可能错过窗口。
- 建议:明确未受理时释放缓存;结果不确定时保留保护并等待对账。
## 低风险与测试问题
### L1. 结束时间条件与“大于 15:00”不一致
- 位置:`strategy/trend/boot.py:90-91`
- 证据:当前使用 `>= (15, 0, 0)`,在恰好 `15:00:00` 时退出,而约定是大于 15:00 后退出。
- 影响:边界秒行为与需求不一致。
- 建议:使用严格大于比较,并让日志描述与条件一致。
### L2. Trend 测试夹具已与生产接口漂移
- 位置:`tests/test_trend.py:19-42,291-352`
- 证据:生产代码调用 `client.passorder(...)`,但测试替身只实现已删除的 `passorder_latest_tagged()`;两个 `RunOnce` 夹具缺少采集任务需要的 `account_id`
- 影响:核心下单和调度测试在目标断言前报错,无法验证真实调用契约。
- 建议:测试替身实现当前 `passorder` 签名并补齐 `account_id`增加券商活动订单、TTL、空快照、撤单失败和并发预算测试。
## 回归结果
执行:`cd py-client && python -B -m unittest tests.test_trend tests.test_market -v`
结果17 项测试中 11 项通过、1 项失败、5 项错误。
- 失败:市场状态刷新失败后仍允许开仓。
- 错误3 项下单测试使用旧接口替身2 项 `RunOnce` 测试缺少 `account_id`
## 建议处理顺序
1. S1、S2恢复可靠的订单防重和未决状态对账。
2. H2、H3、H5确保订单生命周期、撤单和成交状态可信。
3. H1、H4、H6、M1恢复风控并统一账户预算。
4. M2、M3、M4、M5、M6处理配置、异常隔离、资源和刷新策略。
5. 修复测试夹具并补齐跨轮、重启、空快照、撤单失败和并发预算回归。