This commit is contained in:
2026-08-29 01:50:09 +08:00
parent 28e91366d6
commit 846d6c03e0
11 changed files with 490 additions and 15 deletions

246
docs/RE_AUDIT_2026-08-29.md Normal file
View File

@@ -0,0 +1,246 @@
# 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-130`
- `py-client/strategy/ipo/boot.py:4-22`
现状:`main()` 在启动趋势策略前固定调用 `AutoBuyIpo()`。该流程没有配置开关、交易日期校验、执行记录或订单对账,会对接口返回的每只新股直接按最大额度申购。程序同日重启时会再次提交;申购接口异常还会阻止趋势策略启动。函数创建的 HTTP Client 也没有关闭。
影响同日重启可能重复申报IPO 服务短暂失败可能导致主策略完全无法启动。
解决方案:
1. 增加明确的 `enable_auto_ipo` 配置,默认关闭。
2.`账户 + 交易日 + 证券代码` 建立本地幂等记录。
3. 提交前查询当日委托或成交,已有申购记录时跳过。
4. 仅在交易日和申购允许时段执行。
5. 单只申购失败不能中止其他标的,也不能阻止趋势策略启动。
6. 使用上下文管理器或 `finally` 关闭 Client。
验收标准同一账户同一交易日无论重启多少次每只证券最多提交一次IPO 接口失败时趋势策略仍可启动。
### 2.2 重启对账会删除尚未形成持仓的待成交开仓状态
位置:
- `py-client/strategy/trend/state.py:116-141`
- `py-client/strategy/trend/boot.py:153-160`
现状:`State.reconcile()` 先以真实持仓构造 `active_codes`,随后删除所有不在持仓中的状态。新开仓委托从提交到成交之间通常还没有真实持仓,因此重启时其 `ING` 状态会先被删除,根本没有机会再与真实委托进行对账。运行期间也会按同样逻辑删除“不在持仓”的状态。
影响:程序在下单后、成交前重启,可能丢失本地订单状态;后续信号可能再次提交同一证券的买单。
解决方案:
1. 删除状态前先按本地订单号匹配真实活动委托和成交。
2. `ING` 且存在活动委托的状态必须保留,即使当前没有持仓。
3. 只有确认订单终结且不存在持仓时,才能删除状态。
4. 运行期清理必须同时参考 `OrderBook`,不能只参考 `position_codes`
5. 对无法确认的订单转为 `UNKNOWN` 并禁止自动重复下单,等待下一轮对账或人工处理。
验收标准:下单未成交时强制退出并重启,状态和订单锁仍然存在,不会再次开仓。
### 2.3 任意成交记录都会把部分成交误判为全部完成
位置:`py-client/strategy/trend/state.py:179-206`
现状:`_reconcile_leg()` 只要在成交记录备注中找到本地订单号,就立即返回 `STATUS_OK`,没有比较委托数量、累计成交数量和剩余数量。
影响:部分成交后本地状态被视为完成,但真实剩余委托仍可能继续成交;补仓次数、成本和后续交易判断可能与真实账户不一致。
解决方案:
1. 以真实委托状态作为主要状态来源。
2. 汇总同一系统委托号的累计成交数量。
3. `累计成交量 >= 委托量` 才标记 `OK`
4. 部分成交且委托仍活动时保持 `ING`
5. 部分成交后撤单应记录为部分完成状态;若不增加持久化字段,至少保持 `UNKNOWN` 并阻止自动重复下单。
验收标准0%、部分成交、全部成交、部分成交后撤单四种情况均能得到不同且安全的状态结果。
### 2.5 下单成功但状态文件保存失败后仍继续运行
位置:`py-client/strategy/trend/open.py:52-70`
现状:真实订单提交成功后才写入状态文件;`save()` 抛出 `OSError` 时只记录日志,随后仍清除观察器并当作开仓成功继续运行。
影响:真实订单存在,但本地状态文件没有订单号。程序再次崩溃或重启后更容易重复下单。
解决方案:
1. 保存失败时保留内存中的 pending 订单锁,不能清除观察状态。
2. 将证券标记为不可继续自动交易,直到持久化成功或真实订单对账完成。
3. 重试状态落盘,并产生高优先级告警。
4. 补仓路径的 `state.save()` 也应采用同一故障策略。
验收标准:模拟状态目录只读时,真实订单提交后该证券不会再次下单,并能明确告警和恢复。
## 三、P1订单状态和配置语义错误
### 3.1 委托缓存会覆盖同证券同方向的多笔订单
位置:`py-client/strategy/trend/order.py:70-79`
现状:`refresh()` 使用 `BUY-证券代码``SELL-证券代码` 作为 `data` 字典键。同一证券同方向存在多笔委托时,后面的记录会覆盖前面的记录。
影响:过期撤单、状态展示和订单对账只能看到其中一笔;被覆盖的活动订单可能继续成交。
解决方案:
1. 以真实 `order_sys_id` 作为订单字典主键。
2. 单独维护 `(side, code) -> set[order_sys_id]` 的活动索引。
3. `busy()` 只需判断活动索引是否非空。
4. 刷新时对缺失系统委托号的记录单独告警,不能互相覆盖。
验收标准:同证券同方向同时存在两笔委托时,两笔都能被查询、对账和撤销。
### 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-21`
- `py-client/config/__init__.py:48-50`
- `py-client/strategy/trend/open.py:18-40`
- `py-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 非法稳定返回 400QMT 撤单异常返回 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:14`
- `api/QMT_API.py:50`
- `api/QMT_API.py:1305-1310`
- `api/QMT_API.py:1551`
现状:服务端令牌写死在源码并明文打印;服务监听全部网卡,持有同一令牌的调用方可访问交易接口和关停接口。
影响源码、Git 历史或日志泄露即可获得交易与关停权限,且无法区分不同客户端。
解决方案:
1. 令牌只从环境变量或受控配置读取,缺失时拒绝启动。
2. 立即更换当前已进入源码和日志的令牌。
3. 日志只记录“令牌已配置”,不得输出原文。
4. 关停接口使用独立管理令牌,或仅允许环回地址调用。
5. 在主机防火墙限制允许访问的客户端地址。
验收标准:仓库和日志中不存在真实令牌;普通交易客户端不能调用关停接口。
## 五、P2资源生命周期
### 5.1 长生命周期 Client 缺少统一关闭路径
位置:
- `py-client/main.py:61-79`
- `py-client/strategy/ipo/boot.py:4-22`
- `py-client/strategy/trend/boot.py:67-119`
现状:等待 API、IPO 和趋势策略分别创建 Client。正常无限循环时问题不明显但启动异常、策略异常退出或人工终止时没有统一 `finally` 关闭IPO Client 从不关闭。
影响:测试、重启或异常恢复过程中可能遗留连接和文件描述符,增加故障定位难度。
解决方案:主流程统一持有一个 Client或各子流程使用上下文管理器无限循环使用 `try/finally`,退出时关闭连接并保存必要状态。
## 六、建议整改顺序
1. 为自动 IPO 增加开关、幂等记录和异常隔离。
2. 修复待成交订单状态清理和重启对账顺序。
3. 修复部分成交状态判断。
4. 建立开仓与补仓共享资金预算。
5. 处理下单成功但状态落盘失败的安全状态。
6. 重构订单缓存主键和过期撤单过滤。
7. 统一配置字段的真实业务语义。
8. 修复服务端假启动、股票池加载与令牌安全。
## 七、验证记录与限制
- `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` 中的人工排除项没有计入本报告问题数量和整改顺序。

View File

@@ -1,4 +1,28 @@
### 2.4 开仓和补仓没有共享同一个本轮资金预算
位置:
- `py-client/strategy/trend/boot.py:133-140`
- `py-client/strategy/trend/boot.py:182-187`
- `py-client/strategy/trend/open.py:15-53`
- `py-client/strategy/trend/positions.py:44-84`
现状:开仓前只检查一次现金比例,`open_signal()` 可对多个信号分别按 `buy_value` 下单,但不扣减本轮可用资金。随后 `manage_positions()` 又以最初的 `assets.available` 作为补仓预算。
影响:同一轮多笔开仓与多笔补仓的总金额可能超过真实可用资金,导致集中拒单或资金计划失控。
解决方案:
1. `RunOnce()` 创建唯一的本轮 `remaining_cash`
2. 开仓、补仓共享该预算对象。
3. 每笔订单提交成功后立即预留预计金额。
4. 预算应保留 `min_cash_ratio` 对应的安全现金,不能把全部 available 用完。
5. 下单结果未知时也应暂时占用预算,直到对账明确失败。
验收标准:模拟多个开仓和补仓信号时,全部订单预计金额与保留现金之和不超过本轮资产快照的可用资金。
### 2.8 服务端与客户端可能把失败下单当成成功
位置:

View File

@@ -49,6 +49,7 @@ class AccountConfig:
grid_step_pct: float = 1
min_profit_pct: float = 0
enable_loss_add_position: bool = False
enable_auto_ipo: bool = True
signal_allow: list[str] = field(default_factory=list)
excluded_codes: list[str] = field(default_factory=list)

View File

@@ -7,5 +7,6 @@ grid_step_pct: 1
min_profit_pct: 2
strategy: trend
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -1,5 +1,17 @@
from .calc import calc_buy_volume, trading_time
from .lockfile import is_lock, write_lockfile
from .market import market_allow_open, status
from .signal import SignalItem, SignalResult, fetch_signal, init_signals
__all__ = ["calc_buy_volume", "trading_time", "market_allow_open", "status", "SignalItem", "SignalResult", "fetch_signal", "init_signals"]
__all__ = [
"calc_buy_volume",
"trading_time",
"is_lock",
"write_lockfile",
"market_allow_open",
"status",
"SignalItem",
"SignalResult",
"fetch_signal",
"init_signals",
]

View File

@@ -0,0 +1,18 @@
"""简单的文件锁标记工具。"""
from __future__ import annotations
from os import PathLike
from pathlib import Path
def is_lock(file_path: str | PathLike[str]) -> bool:
"""判断指定的锁文件是否存在。"""
return Path(file_path).is_file()
def write_lockfile(file_path: str | PathLike[str]) -> None:
"""创建锁文件;父目录不存在时自动创建。"""
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("LOCK", encoding="utf-8")

View File

@@ -124,7 +124,7 @@ def main() -> int:
configure_logging(config.global_config.qmt_data_dir)
wait_for_qmt_api()
# 自动打新
# 自动打新与主策略隔离;申购服务失败不能阻止趋势策略启动。
AutoBuyIpo()
STRATEGIES[config.account_config.strategy].start_strategy()

View File

@@ -0,0 +1,3 @@
from .boot import AutoBuyIpo
__all__ = ["AutoBuyIpo"]

View File

@@ -1,7 +1,33 @@
from sdk import Client
import config
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
from __future__ import annotations
import json
import logging
from datetime import datetime, time
from pathlib import Path
from typing import Any
import config
from sdk import Client
from libs.calc import trading_time
from libs.lockfile import is_lock,write_lockfile
IPO_STRATEGY_NAME = "IPO_SUBSCRIBE"
IPO_REMARKS = {IPO_STRATEGY_NAME, "新股申购"}
IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0)))
TRADING_CALENDAR_SYMBOL = "000001.SH"
def AutoBuyIpo(now: datetime | None = None) -> int:
"""安全执行一次新股申购,返回成功提交的证券数量。"""
if not config.account_config.enable_auto_ipo:
logging.info("[IPO] 自动申购未启用")
return 0
if not trading_time():
logging.info("[IPO] 非交易时间")
return 0
def AutoBuyIpo() -> None:
client = Client(
config.global_config.qmt_base_url,
config.global_config.qmt_token,
@@ -10,13 +36,17 @@ def AutoBuyIpo() -> None:
result = client.ipo_data("STOCK")
for stock in result:
ipo_price = result[stock]['issuePrice'] # 发行价
maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度
client.passorder(
op_type=23,
stock=stock,
volume=maxPurchaseNum,
pr_type=11,
price=ipo_price,
strategy_name="新股申购",
)
lp = Path(config.global_config.qmt_data_dir/f"{stock}.lock")
if is_lock(lp):
ipo_price = result[stock]['issuePrice'] # 发行价
maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度
client.passorder(
op_type=23,
stock=stock,
volume=maxPurchaseNum,
pr_type=11,
price=ipo_price,
strategy_name="新股申购",
)
write_lockfile(lp)

140
py-client/tests/test_ipo.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import unittest
from datetime import datetime
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from strategy.ipo.boot import AutoBuyIpo
RUN_TIME = datetime(2026, 8, 28, 10, 0)
class FakeClient:
def __init__(self, candidates=None, orders=None, deals=None, fail_codes=None):
self.candidates = candidates or {}
self.orders = orders or []
self.deal_rows = deals or []
self.fail_codes = set(fail_codes or [])
self.submissions = []
self.closed = False
def __enter__(self):
return self
def __exit__(self, *_args):
self.closed = True
def trading_dates(self, *_args):
return ["20260828"]
def trade_detail_data(self, datatype):
self.assert_order_type = datatype
return self.orders
def deals(self):
return self.deal_rows
def ipo_data(self, ipo_type):
self.assert_ipo_type = ipo_type
return self.candidates
def passorder(self, **kwargs):
code = kwargs["stock"]
self.submissions.append(kwargs)
if code in self.fail_codes:
raise RuntimeError("simulated rejection")
return {"status": "success", "order_ref": f"ref-{code}"}
class AutoBuyIpoTests(unittest.TestCase):
def _configs(self, directory, enabled=True):
return (
SimpleNamespace(
qmt_base_url="http://qmt",
qmt_token="token",
qmt_data_dir=directory,
),
SimpleNamespace(account_id="account-A", enable_auto_ipo=enabled),
)
def test_disabled_does_not_create_client(self):
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory, enabled=False)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client") as client_factory,
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 0)
client_factory.assert_not_called()
def test_local_record_prevents_duplicate_after_restart(self):
candidates = {
"688001.SH": {"issuePrice": 10, "maxPurchaseNum": 1000},
}
first = FakeClient(candidates=candidates)
second = FakeClient(candidates=candidates)
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client", side_effect=[first, second]),
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 1)
self.assertEqual(AutoBuyIpo(RUN_TIME), 0)
self.assertEqual(len(first.submissions), 1)
self.assertEqual(second.submissions, [])
self.assertTrue(first.closed)
self.assertTrue(second.closed)
def test_broker_order_prevents_duplicate(self):
candidates = {
"688001.SH": {"issuePrice": 10, "maxPurchaseNum": 1000},
}
client = FakeClient(
candidates=candidates,
orders=[{
"m_strInstrumentID": "688001",
"m_strInsertDate": "20260828",
"m_strRemark": "IPO_SUBSCRIBE",
}],
)
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client", return_value=client),
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 0)
self.assertEqual(client.submissions, [])
def test_one_rejection_does_not_stop_other_candidates(self):
candidates = {
"688001.SH": {"issuePrice": 10, "maxPurchaseNum": 1000},
"688002.SH": {"issuePrice": 20, "maxPurchaseNum": 500},
}
client = FakeClient(candidates=candidates, fail_codes={"688001.SH"})
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client", return_value=client),
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 1)
self.assertEqual(
[item["stock"] for item in client.submissions],
["688001.SH", "688002.SH"],
)
self.assertTrue(client.closed)
if __name__ == "__main__":
unittest.main()