12 KiB
big-qmt 重新审计报告
- 审计日期:2026-08-29
- 审计范围:服务端
api/、客户端py-client/ - 审计方式:静态代码检查、关键交易调用链核对、Python 编译检查、现有单元测试
- 代码修改:无,本文件仅供人工确认
- 人工排除依据:
docs/todo.md
一、审计边界与结论
本次没有重复列出 docs/todo.md 中人工排除的事项,包括同步 QMT 调用阻塞、重复账户查询、大行情编码、信号运行期刷新、Handler/SDK 结构重构、全局配置重构和自动化测试体系等。
当前源码可以通过 Python 编译检查,py-client/tests 的 7 项测试全部通过。但在人工排除项之外,仍发现 5 项 P0 和 8 项 P1/P2 问题。最危险的路径是:程序每次启动都无条件申购新股;重启对账会删除尚未形成持仓的待成交订单状态;任意成交记录会把部分成交误判为全部完成;开仓和补仓没有共享同一个资金预算。
当前版本在完成 P0 整改和模拟账户验证前,不建议直接长期实盘运行。
二、P0:可能造成重复下单或资金失控
2.1 每次启动都会无条件执行新股申购
整改状态:已于 2026-08-29 按本节方案完成,新增 4 项专项测试。
位置:
py-client/main.py:127-130py-client/strategy/ipo/boot.py:4-22
现状:main() 在启动趋势策略前固定调用 AutoBuyIpo()。该流程没有配置开关、交易日期校验、执行记录或订单对账,会对接口返回的每只新股直接按最大额度申购。程序同日重启时会再次提交;申购接口异常还会阻止趋势策略启动。函数创建的 HTTP Client 也没有关闭。
影响:同日重启可能重复申报;IPO 服务短暂失败可能导致主策略完全无法启动。
解决方案:
- 增加明确的
enable_auto_ipo配置,默认关闭。 - 以
账户 + 交易日 + 证券代码建立本地幂等记录。 - 提交前查询当日委托或成交,已有申购记录时跳过。
- 仅在交易日和申购允许时段执行。
- 单只申购失败不能中止其他标的,也不能阻止趋势策略启动。
- 使用上下文管理器或
finally关闭 Client。
验收标准:同一账户同一交易日无论重启多少次,每只证券最多提交一次;IPO 接口失败时趋势策略仍可启动。
2.2 重启对账会删除尚未形成持仓的待成交开仓状态
位置:
py-client/strategy/trend/state.py:116-141py-client/strategy/trend/boot.py:153-160
现状:State.reconcile() 先以真实持仓构造 active_codes,随后删除所有不在持仓中的状态。新开仓委托从提交到成交之间通常还没有真实持仓,因此重启时其 ING 状态会先被删除,根本没有机会再与真实委托进行对账。运行期间也会按同样逻辑删除“不在持仓”的状态。
影响:程序在下单后、成交前重启,可能丢失本地订单状态;后续信号可能再次提交同一证券的买单。
解决方案:
- 删除状态前先按本地订单号匹配真实活动委托和成交。
ING且存在活动委托的状态必须保留,即使当前没有持仓。- 只有确认订单终结且不存在持仓时,才能删除状态。
- 运行期清理必须同时参考
OrderBook,不能只参考position_codes。 - 对无法确认的订单转为
UNKNOWN并禁止自动重复下单,等待下一轮对账或人工处理。
验收标准:下单未成交时强制退出并重启,状态和订单锁仍然存在,不会再次开仓。
2.3 任意成交记录都会把部分成交误判为全部完成
位置:py-client/strategy/trend/state.py:179-206
现状:_reconcile_leg() 只要在成交记录备注中找到本地订单号,就立即返回 STATUS_OK,没有比较委托数量、累计成交数量和剩余数量。
影响:部分成交后本地状态被视为完成,但真实剩余委托仍可能继续成交;补仓次数、成本和后续交易判断可能与真实账户不一致。
解决方案:
- 以真实委托状态作为主要状态来源。
- 汇总同一系统委托号的累计成交数量。
累计成交量 >= 委托量才标记OK。- 部分成交且委托仍活动时保持
ING。 - 部分成交后撤单应记录为部分完成状态;若不增加持久化字段,至少保持
UNKNOWN并阻止自动重复下单。
验收标准:0%、部分成交、全部成交、部分成交后撤单四种情况均能得到不同且安全的状态结果。
2.5 下单成功但状态文件保存失败后仍继续运行
位置:py-client/strategy/trend/open.py:52-70
现状:真实订单提交成功后才写入状态文件;save() 抛出 OSError 时只记录日志,随后仍清除观察器并当作开仓成功继续运行。
影响:真实订单存在,但本地状态文件没有订单号。程序再次崩溃或重启后更容易重复下单。
解决方案:
- 保存失败时保留内存中的 pending 订单锁,不能清除观察状态。
- 将证券标记为不可继续自动交易,直到持久化成功或真实订单对账完成。
- 重试状态落盘,并产生高优先级告警。
- 补仓路径的
state.save()也应采用同一故障策略。
验收标准:模拟状态目录只读时,真实订单提交后该证券不会再次下单,并能明确告警和恢复。
三、P1:订单状态和配置语义错误
3.1 委托缓存会覆盖同证券同方向的多笔订单
位置:py-client/strategy/trend/order.py:70-79
现状:refresh() 使用 BUY-证券代码 或 SELL-证券代码 作为 data 字典键。同一证券同方向存在多笔委托时,后面的记录会覆盖前面的记录。
影响:过期撤单、状态展示和订单对账只能看到其中一笔;被覆盖的活动订单可能继续成交。
解决方案:
- 以真实
order_sys_id作为订单字典主键。 - 单独维护
(side, code) -> set[order_sys_id]的活动索引。 busy()只需判断活动索引是否非空。- 刷新时对缺失系统委托号的记录单独告警,不能互相覆盖。
验收标准:同证券同方向同时存在两笔委托时,两笔都能被查询、对账和撤销。
3.2 过期撤单会遍历已完成订单并重复发请求
位置:py-client/strategy/trend/order.py:81-93
现状:cancel_expired() 只检查创建时间和订单 ID,没有限定订单状态必须属于 BUSY_STATUSES。如果 QMT 返回历史已完成订单,每 30 秒仍会对旧订单调用一次 cancel_by_id()。
影响:订单历史较多时会产生大量无效 HTTP/QMT 往返;真正需要撤销的订单还可能因 3.1 的覆盖问题被遗漏。
解决方案:只遍历活动状态订单;成功发出撤单请求后在本地标记“撤单处理中”,设置重试间隔和最大次数;不要每轮重复撤同一订单。
验收标准:一千条历史订单中仅两条活动超时订单时,只产生两次撤单请求。
3.3 多个交易配置字段定义后未参与业务判断
位置:
py-client/config/__init__.py:20-21py-client/config/__init__.py:48-50py-client/strategy/trend/open.py:18-40py-client/strategy/trend/positions.py:18,65,147-150
现状:
gt_last_price_is_open没有用于比较信号昨收价和当前价。loss_trigger_pct没有参与补仓阈值,代码固定使用(-30, -50)。min_profit_pct没有参与最低止盈判断。
影响:人工修改 YAML 后策略行为可能完全不变,实际交易规则与配置含义不一致。
解决方案:逐项明确配置是启用参数还是废弃参数;启用则进入唯一的决策公式并补充边界校验,废弃则从 dataclass 和配置样例中删除,禁止保留“看似可配置但实际无效”的字段。
验收标准:分别改变三个配置值时,确定性的策略输入会产生对应且可测试的决策变化。
3.4 规则撤单的参数错误会被改写成 HTTP 500
位置:api/QMT_API.py:1255-1285
现状:处理器在参数错误时主动抛出 HTTP 400,但外层 except Exception 又将其捕获并转换为 HTTP 500。
影响:客户端无法区分调用参数错误和服务端故障,可能执行错误的重试或告警策略。
解决方案:与 PassorderHandler 保持一致,先解析和校验参数并返回 400;except HTTPError: raise;QMT 撤单失败映射为 502;未知内部错误保留 500。
验收标准:缺少 stock 或 volume 非法稳定返回 400,QMT 撤单异常返回 502。
四、P1:启动和安全边界
4.1 服务端初始化失败后只记录日志,不向 QMT 抛出
位置:api/QMT_API.py:1526-1556
现状:init() 捕获所有异常并仅记录日志。账户绑定、股票池读取、端口监听或服务启动失败后,宿主可能继续认为策略已经完成初始化。
影响:形成“策略看似启动、HTTP 服务实际不可用”的假启动状态。
解决方案:只对明确可恢复的步骤做局部处理;不可恢复异常记录后重新抛出;启动完成标志必须在 listen() 成功后设置。
验收标准:端口被占用、账户无效或配置文件损坏时,QMT 明确显示策略启动失败。
4.2 pass_codes.json 被无条件要求存在
位置:api/QMT_API.py:1538-1542
现状:注释称“按需加载股票池”,但代码无条件打开文件。文件不存在、内容为空或根节点不是列表都会中止整个 HTTP 服务。
影响:不使用 ContextInfo 股票池的部署也必须准备额外文件;普通配置疏漏会导致服务不可用。
解决方案:文件存在时才读取;验证根节点为证券代码数组;不存在时使用空股票池并记录提示。如果业务确实强制要求股票池,应在启动前给出明确配置错误,而不是普通文件异常。
验收标准:不使用股票池时文件不存在仍能启动;文件格式错误时错误信息明确指出路径和格式要求。
4.3 认证令牌硬编码并输出到日志,且服务允许远程关停
位置:
api/QMT_API.py:14api/QMT_API.py:50api/QMT_API.py:1305-1310api/QMT_API.py:1551
现状:服务端令牌写死在源码并明文打印;服务监听全部网卡,持有同一令牌的调用方可访问交易接口和关停接口。
影响:源码、Git 历史或日志泄露即可获得交易与关停权限,且无法区分不同客户端。
解决方案:
- 令牌只从环境变量或受控配置读取,缺失时拒绝启动。
- 立即更换当前已进入源码和日志的令牌。
- 日志只记录“令牌已配置”,不得输出原文。
- 关停接口使用独立管理令牌,或仅允许环回地址调用。
- 在主机防火墙限制允许访问的客户端地址。
验收标准:仓库和日志中不存在真实令牌;普通交易客户端不能调用关停接口。
五、P2:资源生命周期
5.1 长生命周期 Client 缺少统一关闭路径
位置:
py-client/main.py:61-79py-client/strategy/ipo/boot.py:4-22py-client/strategy/trend/boot.py:67-119
现状:等待 API、IPO 和趋势策略分别创建 Client。正常无限循环时问题不明显,但启动异常、策略异常退出或人工终止时没有统一 finally 关闭;IPO Client 从不关闭。
影响:测试、重启或异常恢复过程中可能遗留连接和文件描述符,增加故障定位难度。
解决方案:主流程统一持有一个 Client,或各子流程使用上下文管理器;无限循环使用 try/finally,退出时关闭连接并保存必要状态。
六、建议整改顺序
- 为自动 IPO 增加开关、幂等记录和异常隔离。
- 修复待成交订单状态清理和重启对账顺序。
- 修复部分成交状态判断。
- 建立开仓与补仓共享资金预算。
- 处理下单成功但状态落盘失败的安全状态。
- 重构订单缓存主键和过期撤单过滤。
- 统一配置字段的真实业务语义。
- 修复服务端假启动、股票池加载与令牌安全。
七、验证记录与限制
python -B -m unittest discover -s tests -v:7 项通过。python -B -m compileall -q api py-client:通过。- 未连接真实 QMT,未执行真实下单、撤单、行情压力测试或故障注入。
api/QMT_API.py依赖 QMT 宿主注入的内置函数,服务端交易行为仍需在模拟账户中验证。docs/todo.md中的人工排除项没有计入本报告问题数量和整改顺序。