This commit is contained in:
2026-09-05 23:36:25 +08:00
parent 2f93fe2ecb
commit b38ff12f2a
3 changed files with 112 additions and 22 deletions

View File

@@ -0,0 +1,68 @@
# IPO 策略审计
日期2026-09-05。范围当前 `py-client/strategy/ipo`,以及直接相关的 SDK、文件锁、调度入口和 `api/qmt_rest_new.py`。仅审计,不修改策略、不调用真实交易接口。
## 结论与分级
本次发现 **P00 项P12 项P23 项**。只列当前问题,不沿用历史报告的已修复项。
- P0需立即处理的全面故障或迫切严重风险。本次未确认此类问题。
- P1会影响申购防重或漏申购建议实盘前优先处理。
- P2特定部署或异常数据条件下影响可靠性安排修复。
## P1-1提交结果不确定时没有防重记录下一次任务可能再次申购
- 位置:`py-client/strategy/ipo/boot.py:47``:51``:59``py-client/libs/lockfile.py:9`
- 证据:先检查文件,再调用 `passorder()`,仅在正常返回后写锁;没有提交前记录、本地订单号或券商订单查询。异常只记录后继续。
- 触发:券商已收到申购,但响应超时或无法解码;或者提交后进程退出、写锁失败。下次 14:00 调度或重启后的任务仍看不到标记。
- 影响:再次发出申购请求;是否拒绝重复申购由柜台决定,不能认为必然重复成交。
- 验证:模拟“已提交但响应超时”,连续执行两次,提交调用为 2 次,锁记录为 0。
- 建议以账户、证券和申购批次生成稳定标识提交前记录待确认结果不确定时先查券商委托不直接重发。SDK 对下单 POST 本身没有自动重试,但不能解决跨任务重发。
## P1-2接口正常返回即永久锁定没有申购结果核对
- 位置:`boot.py:51``:66``api/qmt_rest_new.py:237` 起的 `PassorderHandler`
- 证据:策略不检查返回内容,正常返回就写入 `LOCK`。服务端在底层 `passorder()` 没有抛异常时返回 `status=success`,这不是后续券商成交或有效申购状态。任务不查委托、拒单或可申购额度变化。
- 触发:请求返回正常,但后续发生柜台拒单、数量不符合要求等;或者响应内容未能证明有效申购。
- 影响:标记一直存在,后续任务直接跳过,无法补救当天漏申购。
- 验证:模拟正常响应后第二次执行,提交次数仍为 1即便后续申购失败现有流程也无状态入口解除锁。
- 建议:区分“待确认”与“申购确认成功”;对明确拒绝可恢复尝试,对结果未知继续保留待确认。仅检查 HTTP 成功或服务端 success 字段不足以替代券商对账。
## P2-1锁键没有账户隔离且检查和创建不是原子操作
- 位置:`boot.py:47``libs/lockfile.py:9``:14``main.py``check_single_instance()` 与调度配置。
- 证据:路径仅为 `qmt_data_dir / 股票代码.lock`,不包含账户;`is_file()` 与普通 `write_text()` 分离,也没有互斥。
- 触发:不同账户共用同一数据目录,一个账户的标记使另一个账户跳过;多个项目副本或其他入口同时执行时,也可能都通过检查后提交。
- 影响:跨账户漏申购,或并发重复请求。
- 边界:主入口已有项目路径级单实例保护,调度器设置 `max_instances=1`,因此不把正常单入口运行报告为必然并发;这些保护不能覆盖不同项目路径或独立调用。
- 建议:锁键至少包含账户与证券,明确批次生命周期;如果支持多个进程共享目录,使用原子占位并结合待确认对账。
## P2-2异常 IPO 响应被 SDK 静默当作无候选
- 位置:`py-client/sdk/trade.py:47``:54``api/qmt_rest_new.py:278` 起的 `IpoDataHandler`
- 证据:`ipo_data()` 对非列表返回值直接返回空列表;服务端直接转发底层 IPO 查询结果。策略也不记录候选数量或响应结构异常。
- 触发:接口发生结构变化、返回对象或空值,而不是约定的 `list[dict]`
- 影响:查询异常与正常“今天无新股”无法区分,任务表面正常结束但可能遗漏申购。
- 建议:严格区分合法空列表与错误结构;错误结构记录或抛异常交给任务级边界,避免静默吞掉。
## P2-3候选字段转换不能保证代码及数值有效
- 位置:`boot.py:38``:45`
- 证据:`stock=None` 被转成字符串 `"None"`;代码直接用于文件路径,没有验证是否包含路径分隔符。`float('nan') <= 0` 为假,非有限价格可越过正数检查;`int()` 会截断非整数浮点额度。
- 影响:异常数据产生错误代码请求、非有限价格编码异常、错误数量或非预期锁路径。逐项捕获可隔离异常,但不能让错误数据变正确。
- 建议:对代码做最小格式约束,价格要求有限正数,数量要求正整数而非静默截断。不要把 API 数据直接视为安全路径片段。
- 边界:该风险依赖异常接口数据,不断言当前接口实际返回这些值。
## 待确认行为(不计入问题数量)
- 仅在 10:00、14:00 调度14:00 后启动不补跑当天任务,短暂接口失败也没有同一时段内重试。是否需要启动补跑或有限重试取决于业务要求。
- `trading_time()` 只检查工作日及时间段,不检查交易所节假日;不是完整交易日历。本次不恢复历史上已明确不需要的交易日校验。
- `.BJ` 被主动跳过,仅请求 `STOCK`;视为当前范围选择,不将未参与北交所或债券申购直接判为缺陷。
- `maxPurchaseNum` 被直接用作委托数量,没有结合账户专属可申购额度进一步计算。其究竟是发行上限还是账户可用额度,需要接口实际契约确认,本次不猜测外部业务含义。
## 验证范围
- 两个 IPO 源文件语法检查通过。
- 使用提取的真实任务函数及内存模拟客户端、锁集合,验证响应超时重发和正常响应后跳过;四次模拟均执行客户端退出方法。
- 列表逐行解析、候选级异常隔离、任务级异常捕获、客户端上下文释放均已检查。
- 未执行真实申购、撤单、外部网络请求或交易所规则核验;未创建测试锁文件。模拟验证不代表端到端实盘验收。

View File

@@ -1,22 +1,27 @@
# Trend 当前工作树重新审计 # Trend 当前工作树重新审计
审计日期2026-09-05。以当前磁盘文件(包含未提交的手动修改)为准,不继承旧报告的问题状态。仅审计 Trend 及直接依赖;不修改策略代码,不连接交易接口。 审计日期2026-09-05。审计基线:`2f93fe2`,本轮读取时工作树干净。以当前磁盘文件为准,仅审计 Trend 及直接依赖;不修改策略代码,不连接交易接口。
## 结论 ## 结论
当前仍有 4 项高风险、3 项中风险问题。不建议仅凭语法检查通过直接运行实盘。本报告只保留当前存在的问题与待确认行为,问题编号保留以便引用 当前发现 **P00 项P14 项P23 项**。本报告只保留当前存在的问题与待确认行为,不保留已修复问题清单。不建议仅凭语法检查通过直接运行实盘
- **P0紧急**:无需特定边界条件即可造成全面故障或迫切严重损害,需要立即处理。本轮未发现符合此定义的问题。
- **P1优先修复**:在明确场景下影响资金约束、订单权限或交易决策,建议实盘前处理。
- **P2常规修复**:异常场景下影响局部执行或可靠性,需要安排修复。
工作树中 `strategy/trend/state.py` 已删除,`Runtime` 也已移除 `state` 字段;`docs/arch/state.py` 是归档,不是当前运行模块。因此此前围绕 `merged_order()` 的结论不作为当前策略结论。 工作树中 `strategy/trend/state.py` 已删除,`Runtime` 也已移除 `state` 字段;`docs/arch/state.py` 是归档,不是当前运行模块。因此此前围绕 `merged_order()` 的结论不作为当前策略结论。
## H5止盈委托刚受理就清理峰值未成交持仓失去回撤基准 ## P1-1清仓后没有清理峰值新持仓会继承旧回撤基准
- 位置:`positions.py:136``libs/grid_take_profit.py``clear(self, position_key)` - 位置:`positions.py:105``:182``boot.py:122` 起的快照处理;`libs/grid_take_profit.py:71`
- 证据:`orders.place()` 返回成功后立即执行 `runtime.profit_tracker.clear(key)`,没有等待成交或持仓消失 - 证据:峰值键只有账户和证券代码;当前 Trend 没有调用 `clear()`没有在持仓消失时清理对应键
- 影响:卖单未成交、部分成交或随后被超时撤销时,剩余持仓的旧峰值已丢失;下轮会从较低盈利重新建立基准,原回撤信号可能不再触发 - 触发:同一进程内清仓后重新买入同一证券
- 验证:真实网格跟踪器记录 12% 峰值10% 时模拟提交成功;同一持仓下一次 9.5% 观察返回 `ARMED` 而不是沿用旧峰值的 `RETREAT`。没有真实交易 - 影响:新持仓达到最低收益门槛后,可能按上一笔持仓的高峰立即触发止盈,而不是建立自己的峰值;补仓改变成本时也未明确重置基准
- 建议:仅在后续快照确认持仓消失时清理;撤单或部分成交继续保留剩余持仓的峰值,成本变化时另行明确重置规则 - 验证:真实网格组件以同一键记录 12% 峰值,再输入新持仓的 9% 盈利,返回 `RETREAT`;代码中没有清仓清理步骤隔离两次持仓
- 建议:根据后续快照确认持仓消失后清理;撤单或部分成交继续保留峰值,成本变化另行明确重置规则。
## H2自动撤单未限定为本策略订单 ## P1-2自动撤单未限定为本策略订单
- 位置:`order.py:60``:68``:78``boot.py:57``sdk/portfolio.py:9` - 位置:`order.py:60``:68``:78``boot.py:57``sdk/portfolio.py:9`
- 证据:将组合接口的订单列表直接传入 `refresh()`,只检查状态与时间,没有检查本地订单号前缀、策略归属或排除名单。 - 证据:将组合接口的订单列表直接传入 `refresh()`,只检查状态与时间,没有检查本地订单号前缀、策略归属或排除名单。
@@ -24,7 +29,7 @@
- 影响:启动及每轮刷新都可能撤销非 Trend 订单,包括排除证券的订单。 - 影响:启动及每轮刷新都可能撤销非 Trend 订单,包括排除证券的订单。
- 建议:账户级活动订单可用于防重,但主动撤单必须单独限定所有权。若业务确实授权管理整个账户,需明确记录该权限。 - 建议:账户级活动订单可用于防重,但主动撤单必须单独限定所有权。若业务确实授权管理整个账户,需明确记录该权限。
## H3大盘过滤实际始终放行 ## P1-3大盘过滤实际始终放行
- 位置:`py-client/libs/market.py``market_allow_open()``boot.py:151``positions.py:67` - 位置:`py-client/libs/market.py``market_allow_open()``boot.py:151``positions.py:67`
- 证据:真实状态比较被注释,函数直接 `return True` - 证据:真实状态比较被注释,函数直接 `return True`
@@ -32,7 +37,7 @@
- 建议:恢复真实状态判断,或以明确、可见的配置表示主动关闭过滤。 - 建议:恢复真实状态判断,或以明确、可见的配置表示主动关闭过滤。
- 验证:将本地测试状态设为 `DOWN`,仍返回 `True`;未请求网络。 - 验证:将本地测试状态设为 `DOWN`,仍返回 `True`;未请求网络。
## H4买入没有统一资金预算最小整手还可能突破单笔额度 ## P1-4买入没有统一资金预算最小整手还可能突破单笔额度
- 位置:`boot.py:146``:176``open.py:51``positions.py:160``libs/calc.py:10` - 位置:`boot.py:146``:176``open.py:51``positions.py:160``libs/calc.py:10`
- 证据:开仓只依据本轮起始资金比例决定是否启动,不逐单扣减预算;补仓线程独立使用相同快照资金。`calc_buy_volume()``max(1, ...)` 强制至少买一手。 - 证据:开仓只依据本轮起始资金比例决定是否启动,不逐单扣减预算;补仓线程独立使用相同快照资金。`calc_buy_volume()``max(1, ...)` 强制至少买一手。
@@ -40,14 +45,14 @@
- 示例:价格 100 元、单笔额度 5,000 元,计算出 100 股,即 10,000 元。 - 示例:价格 100 元、单笔额度 5,000 元,计算出 100 股,即 10,000 元。
- 建议:开仓和补仓共享本轮可预留预算;不足一手返回零;预留考虑手续费和行情变化,保留最小现金要求。 - 建议:开仓和补仓共享本轮可预留预算;不足一手返回零;预留考虑手续费和行情变化,保留最小现金要求。
## M2:撤单异常会中断整轮刷新和交易管理 ## P2-1:撤单异常会中断整轮刷新和交易管理
- 位置:`order.py:78``:86``boot.py:122` 起的快照处理。 - 位置:`order.py:78``:86``boot.py:122` 起的快照处理。
- 证据:撤单在遍历中直接调用,未逐单隔离;`busy_keys``data` 在全部遍历结束后才赋值。 - 证据:撤单在遍历中直接调用,未逐单隔离;`busy_keys``data` 在全部遍历结束后才赋值。
- 影响:某笔撤单失败即退出 `refresh()`,快照不更新;常规轮次返回而跳过全部交易管理,初始化阶段则退出启动(客户端会关闭)。持续失败的订单可能持续阻断后续轮次。 - 影响:某笔撤单失败即退出 `refresh()`,快照不更新;常规轮次返回而跳过全部交易管理,初始化阶段则退出启动(客户端会关闭)。持续失败的订单可能持续阻断后续轮次。
- 建议:撤单逐项记录异常,同时发布完整活动订单快照;撤单失败的订单继续作为在途订单防重。 - 建议:撤单逐项记录异常,同时发布完整活动订单快照;撤单失败的订单继续作为在途订单防重。
## M3:信号前置处理没有逐项异常隔离 ## P2-2:信号前置处理没有逐项异常隔离
- 位置:`open.py:16``:55` - 位置:`open.py:16``:55`
- 证据:异常捕获仅包围 `do_open()`;配置、价格、数量计算、观察器调用不在逐候选保护边界中。 - 证据:异常捕获仅包围 `do_open()`;配置、价格、数量计算、观察器调用不在逐候选保护边界中。
@@ -55,12 +60,12 @@
- 影响:该候选之后的所有开仓信号本轮不再处理;工作线程最外层只能记录整个任务失败。 - 影响:该候选之后的所有开仓信号本轮不再处理;工作线程最外层只能记录整个任务失败。
- 建议:把一个候选的完整处理放入同一异常边界,记录证券与信号键后继续;保持必要校验即可。 - 建议:把一个候选的完整处理放入同一异常边界,记录证券与信号键后继续;保持必要校验即可。
## M4:补仓请求结果不确定时未保留资金预算 ## P2-3:补仓请求结果不确定时未保留资金预算
- 位置:`positions.py:175``:75``order.py:119` 起的异常处理。 - 位置:`positions.py:175``:75``order.py:119` 起的异常处理。
- 证据:`place()` 对请求超时等异常返回 `False`,保留证券方向缓存,但 `handle_loss()` 返回的 `reserved_cash` 默认为零。 - 证据:`place()` 对请求超时等异常返回 `False`,保留证券方向缓存,但 `handle_loss()` 返回的 `reserved_cash` 默认为零。
- 触发:券商已受理补仓但响应丢失,本轮继续处理后续证券。 - 触发:券商已受理补仓但响应丢失,本轮继续处理后续证券。
- 影响:方向缓存只能防同证券重复下单,不能阻止其他证券重复使用这笔可能已消耗的资金。该问题即使只启用持仓管理、不开新仓也存在,与 H4 的跨线程预算问题不同。 - 影响:方向缓存只能防同证券重复下单,不能阻止其他证券重复使用这笔可能已消耗的资金。该问题即使只启用持仓管理、不开新仓也存在,与 P1-4 的跨线程预算问题不同。
- 验证:模拟 `place()` 返回 `False`,补仓结果 `reserved_cash == 0.0` - 验证:模拟 `place()` 返回 `False`,补仓结果 `reserved_cash == 0.0`
- 建议:区分明确未提交与结果不确定;不确定时保留本轮预计金额,或停止本轮后续买入并刷新资金。 - 建议:区分明确未提交与结果不确定;不确定时保留本轮预计金额,或停止本轮后续买入并刷新资金。
@@ -77,8 +82,8 @@
- 开仓与持仓管理都检查排除名单;未知信号配置有独立日志后跳过。 - 开仓与持仓管理都检查排除名单;未知信号配置有独立日志后跳过。
- `place()` 捕获 API、HTTP 请求和解码类异常;持仓管理有逐证券异常边界。 - `place()` 捕获 API、HTTP 请求和解码类异常;持仓管理有逐证券异常边界。
- 启动生命周期有 `finally`,等待线程池后关闭客户端。 - 启动生命周期有 `finally`,等待线程池后关闭客户端。
- 当前 7 个 Trend 源文件全部重新通过 AST 语法检查。本轮使用真实网格组件和模拟下单,验证成功提交后的峰值清理及下一轮重新激活行为 - 当前 7 个 Trend 源文件全部重新通过 AST 语法检查。本轮真实网格组件的最小测试验证同一键沿用旧峰值;重新执行资金数量计算、大盘状态判断,分别得到 10,000 元买入金额和 DOWN 状态仍放行
- 资金预算、大盘过滤、撤单和信号异常边界本轮已重新读取;预算超额及失败补仓零预算预留的实现与此前最小测试对应代码一致。 - 资金预算、大盘过滤、撤单和信号异常边界本轮已重新读取;失败补仓零预算预留的实现与此前最小测试对应代码一致。
- 未执行真实下单、撤单、完整 SDK 联调或券商回报测试;语法通过不代表策略可运行。归档文件和历史审计测试统计不计入当前验证。 - 未执行真实下单、撤单、完整 SDK 联调或券商回报测试;语法通过不代表策略可运行。归档文件和历史审计测试统计不计入当前验证。
优先顺序:先处理撤单权限、市场过滤、资金预算及止盈峰值生命周期,再完善异常边界。 优先顺序:先处理撤单权限、市场过滤、资金预算及止盈峰值生命周期,再完善异常边界。

View File

@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from datetime import datetime, time from datetime import datetime, time
from pathlib import Path from pathlib import Path
from typing import Any
import config import config
from sdk import Client from sdk import Client
@@ -14,14 +16,14 @@ from libs.lockfile import is_lock,write_lockfile
IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0))) IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0)))
def AutoBuyIpo() -> None: def AutoBuyIpo():
"""安全执行一次新股申购。""" """安全执行一次新股申购,返回成功提交的证券数量"""
if not config.account_config.enable_auto_ipo: if not config.account_config.enable_auto_ipo:
logging.info("[IPO] 自动申购未启用") logging.info("[IPO] 自动申购未启用")
return return 0
if not trading_time(datetime.now()): if not trading_time(datetime.now()):
logging.info("[IPO] 非交易时间") logging.info("[IPO] 非交易时间")
return return 0
try: try:
with Client( with Client(
@@ -36,7 +38,7 @@ def AutoBuyIpo() -> None:
raise TypeError("IPO 数据项必须是字典") raise TypeError("IPO 数据项必须是字典")
stock = str(item.get("stock", "")).strip() stock = str(item.get("stock", "")).strip()
if not stock or stock.endswith(".BJ"): if not is_target_stock(stock):
continue continue
ipo_price = float(item["issuePrice"]) ipo_price = float(item["issuePrice"])
@@ -68,3 +70,18 @@ def AutoBuyIpo() -> None:
except Exception: except Exception:
logging.exception("[IPO] 自动申购任务失败") logging.exception("[IPO] 自动申购任务失败")
def is_target_stock(symbol: str) -> bool:
"""
判断是否为上证、深证、科创板的A股。
symbol格式示例: '600519.SH', '000001.SZ'
"""
# 提取纯数字代码
code = symbol.split(".")[0]
# 判断是否为合规板块
if code.startswith(('60', '688', '689')): # 沪市主板 + 科创板
return True
if code.startswith(('000', '001', '002', '003', '300', '301')): # 深市主板 + 创业板
return True
return False