From 846d6c03e05738b83cd303f0f6a4b24e71ded5ca Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 29 Aug 2026 01:50:09 +0800 Subject: [PATCH] feat ipo --- docs/RE_AUDIT_2026-08-29.md | 246 ++++++++++++++++++ docs/todo.md | 24 ++ py-client/config/__init__.py | 1 + py-client/etc/dev.yaml | 1 + py-client/libs/__init__.py | 14 +- py-client/libs/lockfile.py | 18 ++ py-client/main.py | 2 +- py-client/strategy/ipo/__init__.py | 3 + .../ipo/__pycache__/boot.cpython-311.pyc | Bin 0 -> 1033 bytes py-client/strategy/ipo/boot.py | 56 +++- py-client/tests/test_ipo.py | 140 ++++++++++ 11 files changed, 490 insertions(+), 15 deletions(-) create mode 100644 docs/RE_AUDIT_2026-08-29.md create mode 100644 py-client/libs/lockfile.py create mode 100644 py-client/strategy/ipo/__init__.py create mode 100644 py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc create mode 100644 py-client/tests/test_ipo.py diff --git a/docs/RE_AUDIT_2026-08-29.md b/docs/RE_AUDIT_2026-08-29.md new file mode 100644 index 0000000..209bbc1 --- /dev/null +++ b/docs/RE_AUDIT_2026-08-29.md @@ -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 非法稳定返回 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: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` 中的人工排除项没有计入本报告问题数量和整改顺序。 diff --git a/docs/todo.md b/docs/todo.md index 8ebfdfb..52a79c8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 服务端与客户端可能把失败下单当成成功 位置: diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index 5f99fd1..ea0d05e 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -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) diff --git a/py-client/etc/dev.yaml b/py-client/etc/dev.yaml index 6d9f9e8..59933ba 100644 --- a/py-client/etc/dev.yaml +++ b/py-client/etc/dev.yaml @@ -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" diff --git a/py-client/libs/__init__.py b/py-client/libs/__init__.py index aeba29b..c899b38 100644 --- a/py-client/libs/__init__.py +++ b/py-client/libs/__init__.py @@ -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", +] diff --git a/py-client/libs/lockfile.py b/py-client/libs/lockfile.py new file mode 100644 index 0000000..10d4cb6 --- /dev/null +++ b/py-client/libs/lockfile.py @@ -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") diff --git a/py-client/main.py b/py-client/main.py index 3070605..b45f3f9 100644 --- a/py-client/main.py +++ b/py-client/main.py @@ -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() diff --git a/py-client/strategy/ipo/__init__.py b/py-client/strategy/ipo/__init__.py new file mode 100644 index 0000000..6de282d --- /dev/null +++ b/py-client/strategy/ipo/__init__.py @@ -0,0 +1,3 @@ +from .boot import AutoBuyIpo + +__all__ = ["AutoBuyIpo"] diff --git a/py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc b/py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a702f891ca53b727c1d8cdd7f4993d12f83e76e GIT binary patch literal 1033 zcmZ`%O>5Lp6g@AKWHMtj&~|7${m?2@4fwGVKahe}i$(eY9T7?;q?1^u%}nA;A`Z;J zATEjsA{6byMcq`S3W9F@7gYuf54sYx-A!p%UCE1`X%#&Qm-F7e_vGZg`$koh0I=}+ z`CLT+zVk%`!XDW^2H+)3n8ZebOcBDlU<*@%DVgG9aVlmClhEW%-k=dC57~LE=#Md( zT7E!_xrQP~0zeGFb~oXwKG{$DjV}V2Yw|f>9pOKD@t=9AvAYrjR+}0Xl(8%~atC3O z6#}d_{ZvSqT*IA<6`@SZLZ(@29^jSOU%A+RT}3QoIo@nL08=arWzm$%F;jjQUy*^; zrXzxGgOn zHXgnHx%hG8(|b*3amUsDl4~)^^PSv0lOH&CP_S6srF;8sB&vEoHGFHfq!*2Xr74u3 z7L#*M@os*Wsk62-W7zuMvgLlk*JljR(gSL<1Yh~iyj5f^H?r9YJv%abef(CIDS6k? zO~W@>!Zke4p{7MOiOIQWH27qAf$dZ7nk9H`WQ8gKJbrck9J`0z(&^tF(+!W)dDoeq zaUB1QTVly8f$v-mN+YgAdjarK@Kyj+v97kSE2)~&TT`?KIUbRot+<$ycOa(Z>TtaU zsq{1biN19Ei#kwK2dcwiYiAuvDH(+YDNJ`Som(7TK2=MfT}XtPBg@jOzO@tQYnclR zsZdFMQ@Xw?U17Q-Z0ii$k4B^?>^@mnk;rTzo=EQSP~Q*nhWIug3QeHg_LwHJm}kyM zhfiApM1S-aw2$*10K3ZNf@21@HAwpb@JaOs0n}rJkPsbff4(8qYV_2_Hj=F4KOxfp E1^X578UO$Q literal 0 HcmV?d00001 diff --git a/py-client/strategy/ipo/boot.py b/py-client/strategy/ipo/boot.py index 04e7f8f..6e55ba7 100644 --- a/py-client/strategy/ipo/boot.py +++ b/py-client/strategy/ipo/boot.py @@ -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="新股申购", - ) \ No newline at end of file + 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) + diff --git a/py-client/tests/test_ipo.py b/py-client/tests/test_ipo.py new file mode 100644 index 0000000..6593e35 --- /dev/null +++ b/py-client/tests/test_ipo.py @@ -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()