# 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_restart`:ERROR - `test_broker_order_prevents_duplicate`:ERROR - `test_one_rejection_does_not_stop_other_candidates`:ERROR 影响:自动打新完全不可用;如果由主程序同步调用,异常还可能中止启动流程。 建议:传入 `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,代码仍执行: ```python 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-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` `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. 建议整改顺序 1. 先恢复 IPO 的可执行性,并让现有 4 项 IPO 测试全部通过。 2. 完成 `UNKNOWN/ING` 与开仓候选、订单簿之间的统一锁定,增加重启未成交端到端测试。 3. 修复开仓数量和同轮资金预留,增加高价股、多信号资金边界测试。 4. 重构 `OrderBook` 主键与活动索引,并限制撤单状态。 5. 修复调度生命周期和启动脚本的进程范围。 6. 最后清理重复 API 文件、死代码、重复落盘和无效日志。 ## 7. 验收门槛 - 全部 11 项现有测试通过,且不通过跳过/删除失败测试达成。 - 新增:IPO 首次申购、重复启动、券商已有委托、单只拒单不影响其他候选。 - 新增:开仓未成交重启、订单查询暂时缺失、部分成交活动、部分成交撤单、完全成交。 - 新增:`buy_value` 不足一手、多信号总金额超过现金、价格滑点场景。 - 新增:同证券同方向两笔活动委托都能被发现和撤销。 - 在模拟账户完成至少一次完整的下单、部分成交、撤单、重启对账闭环后,再考虑实盘。