Files
big-qmt/docs/CODE_AUDIT_2026-08-29.md
2026-08-30 00:34:27 +08:00

12 KiB
Raw Permalink Blame History

big-qmt 代码复审报告2026-08-29

1. 审计范围与方法

  • 审计范围:当前工作区中的 api/py-client/、启动脚本及测试。
  • 明确排除:docs/todo.md,本报告没有读取或引用该文件作为判断依据。
  • 关注项:致命错误、交易业务逻辑错误、代码冗余、过度验证。
  • 动态验证:python -B -m unittest discover -s tests -v;共 11 项8 项通过、3 项错误。
  • 编译验证:python -B -m compileall -q . 通过。
  • 限制:未连接真实 QMT未执行真实委托、撤单或成交回报验证。

2. 总结

当前版本不适合直接实盘运行。至少存在 4 条会使计划业务完全不执行或造成重复/超额下单的高风险路径:

  1. IPO 功能在启用时必然先抛出 TypeError,而且其后仍有路径拼接、锁判断和调度生命周期错误。
  2. 趋势策略的 UNKNOWN 状态虽然不会被删除,但并未参与开仓过滤,无法兑现“禁止自动重复下单”。
  3. 多个开仓信号之间没有共享可用资金预算,且首笔开仓数量函数可能主动超过配置金额。
  4. OrderBook 会覆盖同证券同方向的多笔订单,并可能反复撤销已终结的历史订单。

测试失败并非测试环境问题,而是直接命中了当前生产函数的确定性参数错误。

3. P0致命或可能造成错误交易

3.1 IPO 入口启用后必然报错,全部核心测试无法执行

位置:

  • py-client/strategy/ipo/boot.py:22-29
  • py-client/libs/calc.py:5-7

AutoBuyIpo(now) 调用 trading_time() 时没有传入必需的 now 参数。只要 enable_auto_ipo=True,函数便在创建客户端、读取候选和检查重复委托之前抛出 TypeError

复现结果:

  • test_local_record_prevents_duplicate_after_restartERROR
  • test_broker_order_prevents_duplicateERROR
  • test_one_rejection_does_not_stop_other_candidatesERROR

影响:自动打新完全不可用;如果由主程序同步调用,异常还可能中止启动流程。

建议:传入 now or datetime.now(),并将交易日与交易时段判断统一成一个可测试入口。修复后必须重新运行现有 4 项 IPO 测试。

3.2 IPO 路径还有两层确定性错误:字符串路径相除、锁语义反向

位置:

  • py-client/strategy/ipo/boot.py:37-49
  • py-client/config/__init__.py:28,109
  • py-client/libs/lockfile.py:9-18

即使修复 3.1,代码仍执行:

Path(config.global_config.qmt_data_dir / f"{stock}.lock")

qmt_data_dirGlobalConfig 中是 str,因此先计算 str / str,会再次抛出 TypeError。此外,is_lock() 的语义是“锁文件已存在”,当前代码却仅在其返回 True 时提交申购;首次运行没有锁文件,所有候选都会被跳过。正确业务语义应是“没有本地成功记录时才继续”,并且还要和券商订单/成交记录交叉核对。

影响修复第一个异常后IPO 仍无法正常首申购;若人工预建锁文件绕过判断,则反而允许对已锁定证券再次申购。

建议:使用 Path(qmt_data_dir) / f"{stock}.lock";反转本地锁判断;提交成功后才写锁;提交失败不得写锁;恢复测试中已经表达但当前实现缺失的券商订单和成交去重。

3.3 UNKNOWN 只被保存在状态文件中,没有真正阻止再次开仓

位置:

  • py-client/strategy/trend/state.py:76-82,175-228
  • py-client/strategy/trend/boot.py:136-164
  • py-client/strategy/trend/open.py:17-70

State.delete() 会静默保留 ING/UNKNOWN,但 RunOnce() 构造 allow_open 时只排除真实持仓代码,不排除 State 中的未决代码。open_signal() 也只检查 OrderBook.busy()。当重启对账无法找到真实活动委托并把订单转成 UNKNOWN 后,如果券商订单查询暂时缺失或委托已终结但结果不明,OrderBook 没有活动锁,下一次同证券信号仍可提交新买单。

另外,RunOnce() 无论 State.delete() 是否因保护而拒绝删除,都会执行 open_watch.forget()add_watch.forget(),形成“状态保留但观察锁被清除”的不一致状态。

影响:前一笔订单结果无法确认时仍可能重复开仓;这与状态修复目标和验收标准直接冲突。

建议:开仓候选必须同时排除持仓代码、ING/UNKNOWN 状态代码以及活动买单代码。delete() 应返回是否实际删除,调用方只在删除成功后清理观察器。运行期每轮应将订单簿刷新结果用于状态对账,而不是仅在启动时对账一次。

4. P1重要业务逻辑错误

4.1 IPO 定时任务实际上不会在 10:00 被调度

位置:py-client/main.py:111-115

代码注册每日 10:00 任务后只调用一次 schedule.run_pending(),随后进入 StartTrend() 的永久循环,再也没有机会驱动调度器。除非程序恰好在任务已到期的极窄窗口启动,否则自动打新不会执行。

建议:把调度轮询合并进主循环,或使用独立受控线程/独立进程IPO 异常必须隔离,不能终止趋势策略。

4.2 样例账户没有启用任何趋势信号

位置:

  • py-client/config/__init__.py:48
  • py-client/etc/dev.yaml
  • py-client/libs/signal.py:23-28

signal_allow 默认空列表,而当前 dev.yaml 没有该字段。init_signals() 只加载显式出现在允许列表中的信号,因此样例配置启动后趋势策略永远没有开仓候选,日志也不会提示“允许列表为空”。

建议:明确选择一种语义:空列表表示全部禁用,并在启动时显著告警;或空列表表示允许全部。样例配置应与预期业务一致。

4.3 同证券同方向的多笔委托会互相覆盖

位置:py-client/strategy/trend/order.py:68-77,118-122

订单缓存以 BUY-code / SELL-code 为唯一键。券商返回同一证券同方向的多笔委托时,字典推导只保留最后一笔。过期撤单、状态展示和锁判断无法看到被覆盖的订单。

建议:以系统委托号作为主键,另建 (side, code) -> set[system_order_id] 活动索引。

4.4 过期撤单没有过滤活动状态

位置:py-client/strategy/trend/order.py:79-90

cancel_expired() 遍历 data.values() 时只检查时间和系统委托号,没有要求 order.status in BUSY_STATUSES。如果接口返回历史委托,程序每轮都可能对已成交、已撤销或已失败订单调用撤单。

建议:只对活动状态且超过时限的订单撤单,并维护撤单请求中的冷却状态,避免每 30 秒重复请求。

4.5 状态落盘失败后的故障策略不一致

位置:

  • py-client/strategy/trend/open.py:59-70
  • py-client/strategy/trend/positions.py:175-183

开仓提交成功但保存失败时只记录日志,仍清除观察器并继续运行;补仓保存失败则异常向外传播,但真实订单已经提交。两条路径都可能出现“真实订单存在、本地恢复信息缺失”,处理策略却不一致。

建议:真实下单成功后若持久化失败,应将证券置于内存级熔断集合,保留订单簿锁并持续重试落盘;禁止该证券继续自动交易,直至对账确认。

4.6 启动脚本会杀死机器上的所有 Python 进程

位置:run.bat:1-7

taskkill /IM python.exe /F 不限定当前项目、PID 或命令行,会强制终止机器上所有 Python 工作负载。脚本也没有先切换到自身目录,python main.py 是否能找到文件取决于调用时当前目录;仓库根目录本身并不存在 main.py

影响:可能中断无关服务、研究任务或其他交易程序,同时自身仍可能因工作目录错误无法启动。

建议:保存本项目 PID 并只终止该 PID脚本开头使用 cd /d "%~dp0py-client" 或调用绝对脚本路径;不要在生产启动流程中无条件 git pull

5. P2代码冗余、失效代码和过度验证

5.1 API 服务文件完全重复且正式入口不明确

位置:

  • api/qmt_api_new.py
  • api/qmt_api_rele.py
  • 工作区状态中的已删除 api/QMT_API.py

两个现存文件 SHA-256 完全一致,属于逐字节重复;原正式命名文件当前又处于删除状态。部署人员无法从启动脚本或文档可靠判断应加载哪个版本,后续修复也容易只改到其中一份。

建议:保留唯一正式入口,开发版本通过 Git 分支管理,不以 _new_rele 复制整文件。

5.2 IPO 模块呈现“旧实现覆盖新设计”的死代码特征

位置:py-client/strategy/ipo/boot.py:5-19

jsonAnyIPO_STRATEGY_NAMEIPO_REMARKSIPO_SESSIONSTRADING_CALENDAR_SYMBOL 均未使用;测试则期待交易日查询、券商对账、异常隔离、上下文管理器和返回成功数量,但当前函数都未实现。这不是单纯格式问题,而是实现与测试/设计发生大段脱节的信号。

建议:不要逐个删除常量掩盖问题;先恢复完整 IPO 流程,再移除确认无用的符号。

5.3 主程序存在不可达代码、拼写错误和未使用符号

位置:

  • py-client/main.py:5,15,100-101,115-116

StartTrend() 正常情况下永久循环,因此其后的“策略启动成功”日志不可达;即便未来返回,使用的 config.account_config.strateg 也不存在。非 Windows 分支调用未定义的 log.errorTimedRotatingFileHandlerGLOBAL_CONFIG_PATH 没有使用。

建议:启动成功日志应放在进入循环前;统一使用 logging;清除未使用导入和常量。

5.4 State.delete() 的全局保护属于过度且不透明的验证

位置:py-client/strategy/trend/state.py:76-82

任何调用者请求删除 ING/UNKNOWN 状态都会被静默拒绝,没有返回值、日志、强制删除入口或订单证据参数。它确实避免了一类误删,但也会阻止人工确认后的清理,并使调用者误以为删除成功。当前 RunOnce() 正因此错误清除了观察器。

建议:将“是否可清理”的业务判断放在显式对账流程中;delete() 返回布尔值或抛出明确异常;如需保护,提供带审计原因的显式强制路径。验证应基于订单证据,不应仅基于状态字符串。

5.5 对账存在重复落盘

位置:py-client/strategy/trend/state.py:84-108,110-137

reconcile() 先调用会自行 save()sync_positions(),完成订单对账和清理后又 save()。每次启动对账至少写两次同一状态文件,第一份还是未完成订单对账的中间状态。

建议:为 sync_positions() 增加不立即保存的内部版本,整个对账事务只在最终一致状态落盘一次。

6. 建议整改顺序

  1. 先恢复 IPO 的可执行性,并让现有 4 项 IPO 测试全部通过。
  2. 完成 UNKNOWN/ING 与开仓候选、订单簿之间的统一锁定,增加重启未成交端到端测试。
  3. 修复开仓数量和同轮资金预留,增加高价股、多信号资金边界测试。
  4. 重构 OrderBook 主键与活动索引,并限制撤单状态。
  5. 修复调度生命周期和启动脚本的进程范围。
  6. 最后清理重复 API 文件、死代码、重复落盘和无效日志。

7. 验收门槛

  • 全部 11 项现有测试通过,且不通过跳过/删除失败测试达成。
  • 新增IPO 首次申购、重复启动、券商已有委托、单只拒单不影响其他候选。
  • 新增:开仓未成交重启、订单查询暂时缺失、部分成交活动、部分成交撤单、完全成交。
  • 新增:buy_value 不足一手、多信号总金额超过现金、价格滑点场景。
  • 新增:同证券同方向两笔活动委托都能被发现和撤销。
  • 在模拟账户完成至少一次完整的下单、部分成交、撤单、重启对账闭环后,再考虑实盘。