12 KiB
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 条会使计划业务完全不执行或造成重复/超额下单的高风险路径:
- IPO 功能在启用时必然先抛出
TypeError,而且其后仍有路径拼接、锁判断和调度生命周期错误。 - 趋势策略的
UNKNOWN状态虽然不会被删除,但并未参与开仓过滤,无法兑现“禁止自动重复下单”。 - 多个开仓信号之间没有共享可用资金预算,且首笔开仓数量函数可能主动超过配置金额。
OrderBook会覆盖同证券同方向的多笔订单,并可能反复撤销已终结的历史订单。
测试失败并非测试环境问题,而是直接命中了当前生产函数的确定性参数错误。
3. P0:致命或可能造成错误交易
3.1 IPO 入口启用后必然报错,全部核心测试无法执行
位置:
py-client/strategy/ipo/boot.py:22-29py-client/libs/calc.py:5-7
AutoBuyIpo(now) 调用 trading_time() 时没有传入必需的 now 参数。只要 enable_auto_ipo=True,函数便在创建客户端、读取候选和检查重复委托之前抛出 TypeError。
复现结果:
test_local_record_prevents_duplicate_after_restart:ERRORtest_broker_order_prevents_duplicate:ERRORtest_one_rejection_does_not_stop_other_candidates:ERROR
影响:自动打新完全不可用;如果由主程序同步调用,异常还可能中止启动流程。
建议:传入 now or datetime.now(),并将交易日与交易时段判断统一成一个可测试入口。修复后必须重新运行现有 4 项 IPO 测试。
3.2 IPO 路径还有两层确定性错误:字符串路径相除、锁语义反向
位置:
py-client/strategy/ipo/boot.py:37-49py-client/config/__init__.py:28,109py-client/libs/lockfile.py:9-18
即使修复 3.1,代码仍执行:
Path(config.global_config.qmt_data_dir / f"{stock}.lock")
qmt_data_dir 在 GlobalConfig 中是 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-228py-client/strategy/trend/boot.py:136-164py-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:48py-client/etc/dev.yamlpy-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-70py-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.pyapi/qmt_api_rele.py- 工作区状态中的已删除
api/QMT_API.py
两个现存文件 SHA-256 完全一致,属于逐字节重复;原正式命名文件当前又处于删除状态。部署人员无法从启动脚本或文档可靠判断应加载哪个版本,后续修复也容易只改到其中一份。
建议:保留唯一正式入口,开发版本通过 Git 分支管理,不以 _new、_rele 复制整文件。
5.2 IPO 模块呈现“旧实现覆盖新设计”的死代码特征
位置:py-client/strategy/ipo/boot.py:5-19
json、Any、IPO_STRATEGY_NAME、IPO_REMARKS、IPO_SESSIONS、TRADING_CALENDAR_SYMBOL 均未使用;测试则期待交易日查询、券商对账、异常隔离、上下文管理器和返回成功数量,但当前函数都未实现。这不是单纯格式问题,而是实现与测试/设计发生大段脱节的信号。
建议:不要逐个删除常量掩盖问题;先恢复完整 IPO 流程,再移除确认无用的符号。
5.3 主程序存在不可达代码、拼写错误和未使用符号
位置:
py-client/main.py:5,15,100-101,115-116
StartTrend() 正常情况下永久循环,因此其后的“策略启动成功”日志不可达;即便未来返回,使用的 config.account_config.strateg 也不存在。非 Windows 分支调用未定义的 log.error。TimedRotatingFileHandler 和 GLOBAL_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. 建议整改顺序
- 先恢复 IPO 的可执行性,并让现有 4 项 IPO 测试全部通过。
- 完成
UNKNOWN/ING与开仓候选、订单簿之间的统一锁定,增加重启未成交端到端测试。 - 修复开仓数量和同轮资金预留,增加高价股、多信号资金边界测试。
- 重构
OrderBook主键与活动索引,并限制撤单状态。 - 修复调度生命周期和启动脚本的进程范围。
- 最后清理重复 API 文件、死代码、重复落盘和无效日志。
7. 验收门槛
- 全部 11 项现有测试通过,且不通过跳过/删除失败测试达成。
- 新增:IPO 首次申购、重复启动、券商已有委托、单只拒单不影响其他候选。
- 新增:开仓未成交重启、订单查询暂时缺失、部分成交活动、部分成交撤单、完全成交。
- 新增:
buy_value不足一手、多信号总金额超过现金、价格滑点场景。 - 新增:同证券同方向两笔活动委托都能被发现和撤销。
- 在模拟账户完成至少一次完整的下单、部分成交、撤单、重启对账闭环后,再考虑实盘。