This commit is contained in:
2026-09-05 13:53:26 +08:00
parent 63ffc1329b
commit 58e1e3e291
7 changed files with 343 additions and 317 deletions

View File

@@ -1,109 +1,164 @@
# IPO / Trend 当前代码审计
日期2026-09-05。范围`py-client/strategy/{ipo,trend}`,以及直接依赖的 SDK、公共库、配置与 `api/qmt_rest_new.py` 下单/撤单处理器。以下路径除特别注明外均相对于 `py-client/`
> 后续实现更新:已按要求精简为 JSON v3订单终态后一次记账见 [trend-state.md](trend-state.md)。T12 的持仓缺席自动删除已移除T13 的提交前写盘失败已增加占位回滚并验证。下方对应复现保留为 v2 历史证据。其余未修复项仍需单独处理v3 不再保存子单历史及成交增量,旧版本迁移与清仓后重新开仓的操作边界见新说明
本报告取代上一轮 Trend 审计的当前结论。仅清理测试文件、更新文档;未实施策略修复。采用静态阅读与隔离的内存断言,不连接柜台、不提交真实订单。风险等级按状态丢失、重复委托、漏单等后果划分;未验证真实 QMT 的返回值与回报时序
审计日期2026-09-05本轮重新读取当前工作树重点复核 JSON v2 状态机。范围为 `py-client/strategy/{ipo,trend}` 及直接依赖,不包含 ZT 策略。以下路径相对于 `py-client/`,服务端路径从仓库根目录起算
## IPO高风险
本轮只更新审计文档,未修改策略源码。已清理的 tests/*.py 不重建;采用临时目录和 mock 进行隔离复现,没有调用真实柜台。本文取代上一轮联合审计的当前结论;状态格式说明见 [trend-state.md](trend-state.md)。
### I1. 未确认受理就写入永久申购标记 (忽略)
## 结论
位置:`strategy/ipo/boot.py:53``libs/lockfile.py:14`;服务端 `api/qmt_rest_new.py:246`
新的 pending 与业务账本分离、补仓首次成交计数、预开仓数量落盘、完整订单快照对账均已接入。但尚不能据此认定状态恢复与异常生命周期完整:空持仓快照仍可清空历史补仓记录;确定未提交/未受理的订单可能永久占用 pending撤单异常仍可阻断整轮状态对账
`client.passorder()` 返回值被忽略,随后无条件写锁、记录申购日志。服务端只要底层调用未抛异常就返回 success并直接把返回值字符串化未验证引用是否有效。因此本地标记只能证明调用返回不能证明委托有效。遇到失败业务响应或无效引用后续轮次仍因锁文件存在而跳过导致漏申购
优先处理 T12、T13、T14、T15。下文沿用原编号新发现从 T12 起编号,不把已解决的问题重复列为当前缺陷
建议:依据明确的柜台受理契约验证结果;无法确认的结果保留待核查记录,按委托/成交查询确认后再标记完成。
## 新发现Trend
### I2. 检查锁、下单、写锁不是原子操作,且没有券商对账(忽略)
### T12【高】一次空持仓快照会清除已完成账本恢复后补仓次数归零
位置:`strategy/trend/state.py:212``state.py:124`;入口 `boot.py:182`
证据:对所有“曾出现于持仓、当前不在持仓、无 pending”的证券立即删除 items 与 seen_positions。持仓下次恢复时按首次接管重新建立底仓added_num 从零开始。SDK 的组合解析还会将缺失 positions 字段视为空字典。
复现 R1已有底仓并完成一次补仓 → reconcile([], []) → 原记录被删除 → 持仓恢复 → added_num 从 1 变成 0。
影响:历史补仓量价丢失,亏损补仓档位可能重新使用。这与已修复的“空订单快照丢 pending”是不同路径。当前文档已声明依赖持仓快照完整但代码没有验证此条件。
建议:持仓缺席进入核查流程,不立即删除账本;结合卖出成交、明确清仓或可靠的持仓二次查询确认后结束证券生命周期。缺失字段不能自动视为有效空账户。
### T13【高】提交前写盘失败会留下实际上未发送的 pending
位置:`strategy/trend/state.py:79``order.py:114`
证据begin 先修改 items、pending、索引和 dirty再调用 save。save 抛错后没有回滚place 因异常没有调用柜台。后续 reconcile 的 save 可以把这个未发送记录写入磁盘。
复现 R2模拟 Path.write_text 抛出磁盘错误;柜台调用次数为 0但 busy(A) 为真;磁盘恢复后执行 reconcile 并重载文件busy(A) 仍为真。
影响该证券持续被阻止买入后续没有真实订单可供终态对账180 秒日志也不会解除锁定。
建议:将提交前登记视为内存/磁盘事务,明确写盘失败时回滚本次占位,或记录“确定未发送”并走安全撤销流程;与请求发出后结果不确定的情形区分。
### T14【高】服务端明确 HTTP 400 拒绝也被永久保留为待确认
位置:`strategy/trend/order.py:127``sdk/client.py:67`、服务端 `api/qmt_rest_new.py:235`
证据:服务端参数解析失败时在实际 passorder 调用前抛 HTTP 400SDK 转为 APIErrorplace 仅记录并返回 False未调用 state.reject。当前 reject 分支只识别响应字典中的 failed/rejected而本服务端下单处理器正常只返回 success失败主要通过 HTTP 错误表达。
复现 R3mock APIError(400),清除 SimpleCache 并重新加载 State 后,证券仍 busy。
影响:确定未受理的请求无法自动恢复;纠正参数后仍不能下单。
建议:针对已确认的服务端契约区分“明确未受理”和“不确定”。明确的参数拒绝可结束 pending网络超时、502 等不能一概按失败释放。
### T15【高】撤单异常仍会阻断完整状态对账及整轮持仓管理
位置:`strategy/trend/order.py:86``boot.py:132``boot.py:182`
证据refresh 在遍历订单中直接调用 cancel_by_id一次异常会使 refresh 退出RunOnce 在组合刷新异常分支直接 return已经拿到的其他订单成交快照也不会进入 reconcile。启动阶段同类异常会导致启动失败。
复现 R5快照包含超时委托撤单接口抛错state.reconcile 调用次数为 0。
影响单个撤单故障可以持续阻断全部证券的成交更新、止盈和补仓。T6 的逐持仓隔离在这个阶段尚未执行。
建议:先完成快照采集及状态对账;撤单采用逐订单异常边界,失败保持 busy并记录业务失败结果不阻断其他订单和证券。
## 新状态机的条件性风险与恢复边界
### T16【中条件性】结束 pending 后不能处理迟到的成交金额修正
位置:`strategy/trend/state.py:107``state.py:197`
pending 结束即删除子单数据和已记账基准,后续只遍历 pending不再处理该订单。复现 R4100 股终态成交金额 1000 元完成后,再收到金额 1050 元的同订单快照base_cost 仍为 10 元。
是否实际触发取决于柜台终态金额是否可能修正,本轮没有实盘证据。若上游保证终态价格最终不变,可将其作为明确契约;否则应短期保留已完成订单的记账基准并支持差额修正。
### 迁移与核查:已知限制,不认定为新回归
位置:`strategy/trend/state.py:233`
- 旧 ING 的 expected_qty 迁移为 0完成条件要求大于 0因此必须人工核实后补齐当前没有专门的核查/解除接口。
- 旧文件可能只保存最后一次补仓量价,新累计 added_amount 不能凭空恢复全部历史;迁移有备份和告警,但数值不能直接解释为完整历史成本。
- pending 超时仅告警,未实现按订单号主动远程查询。提交意图落盘后、请求真正发出前崩溃,也需要人工核实;这与 T13 已知写盘失败未回滚不同。
- 终态识别要求系统订单号、正确的本地标识、可汇总申报量和成交价格。如果拒单没有系统订单号,或 QMT 取消态数量语义不同于模型推定pending 可能无法自动结束。需要以真实回报核对,不能仅凭 mock 宣布兼容。
- 仅提供单进程互斥,不支持同账户同状态文件的多进程并发写入。
## 仍存在的既有问题
### T4【高】开仓和补仓缺少统一预算
位置:`strategy/trend/boot.py:191``open.py:51``positions.py:41`
两条线程各自使用资金,开仓每个信号仍按完整 buy_value 计算。补仓的不确定委托只预留本轮估算金额,下轮又按新的 available 开始,未统一扣除未确认订单的潜在占款。市场价格变化也可能超出快照估算。
建议:账户级共享预算,明确券商已冻结资金和本地尚未反映的预留,避免漏计或重复扣减。
### T5【高】自动撤单不区分策略归属
位置:`strategy/trend/order.py:74`
超时撤单遍历全账户订单,没有本地订单前缀/策略归属限制。R5 同时确认手工来源订单会被传给撤单接口。建议防重参考全账户,自动撤单仅限明确归属本策略的订单。
### T10【中策略取舍】止盈门槛会阻断已激活网格回撤
位置:`strategy/trend/positions.py:108``libs/grid_take_profit.py`
低于 minimum_profit 即返回,不再观察峰值回撤;进程重启峰值也丢失。若要求激活后持续追踪,应保存激活/峰值;若最低利润是硬性卖出门槛,应明确此行为。
### T11【中】信号仅启动加载
位置:`strategy/trend/boot.py:69``libs/signal.py:17`
首次请求失败会得到空结果,此后不刷新;运行中的新增/撤销不生效。建议按业务时效定期刷新,并区分失败与正常空信号。
## IPO 当前问题
### I1【高】下单结果未检查就写完成锁
位置:`strategy/ipo/boot.py:53`
passorder 返回值被忽略随后无条件写锁。R6 使用 failed 业务响应,仍生成 A.SH.lock。当前服务端通常将失败表达为 HTTP 异常,该路径会被捕获;但 success 仅说明底层调用未抛异常,服务端未验证字符串化的订单引用,客户端也没有确认受理。
建议依据真实受理契约核查结果;不确定时保存待核查记录,不直接标记完成。
### I2【高】检查锁—下单—写锁不原子无券商对账
位置:`strategy/ipo/boot.py:49``libs/lockfile.py:9`
两个调用可同时看到锁不存在并分别提交;下单受理但响应超时写锁失败,也不会留下标记,下轮再次提交。函数没有查询已有券商委托,也没有传入稳定的本地订单标识。重复请求是否由柜台拒绝不在本次静态审计可证明范围内
并发调用可同时下单受理超时/写锁失败会使后续重新提交。建议账户/发行事件级原子占位与幂等标识,不确定结果先核查
建议:按账户发行事件生成幂等标识,提交前原子占位;不确定结果先查询再决定是否重试。
### I3. 锁文件未隔离账户和发行事件(忽略)
### I3【高共享目录场景】标记不区分账户发行事件
位置:`strategy/ipo/boot.py:49`
路径仅为全局数据目录下的 `{stock}.lock`,既没有账户也没有日期/发行标识。多个账户共享目录时,先运行的账户会阻止另一账户申购;代码复用或残留标记也无法区分新的发行事件。
只有证券代码作为文件名;多账户共享目录时相互阻止申购,旧标记也无法区分新的发行事件。建议加入账户与发行标识。
建议:至少加入账户与发行事件标识;不要仅依靠无期限的证券代码标记。
## IPO中风险
### I4. 证券代码和数值校验不完整(忽略)
### I4【中】数据校验不完整
位置:`strategy/ipo/boot.py:40`
`str(None)` 会变成非空的 `None`;价格 `NaN`/正无穷不会被 `<= 0` 拦截;`int(100.9)` 会截断为 100。证券代码直接用于文件名,路径字符可导致越界路径或非法文件名。单条异常隔离能保住后续候选,但不能保证本条参数正确
str(None) 不是空代码;非有限价格可绕过 <=0int 浮点数量会截断;证券代码直接进入路径。建议有限正价格、严格整数数量、代码格式与路径字符限制
建议:证券代码采用明确格式;价格必须为有限正数,数量必须为正整数且满足实际申购规则;构造路径前排除路径分隔符
IPO 已正确逐条处理 list[dict],使用 with Client候选异常不终止后续候选本轮未发现这些路径退化
已确认有效IPO 按 `list[dict]` 逐条读取Client 使用上下文关闭;候选级异常不会终止后续候选。函数实际返回 `None`,文档中“返回成功数量”和“券商对账”的说明与实现不一致。
## 已确认有效修复
## Trend严重风险
- T1 原空订单快照丢 pending已修复本地待确认记录独立持久化缺席不删除。T12 是持仓账本清理的另一条路径。
- T2 原过滤取消子单导致误判:已修复;启动和每轮对账均使用 portfolio.orders 完整数据。
- T3 原受理后才写订单关联:已修复;当前提交前保存计划与本地 ID。提交前失败的恢复问题另列 T13。
- T6 下单网络/解码异常与逐证券异常边界已接入。
- T7 统一 finally先关闭线程池再关闭 Client初始化异常可释放资源。
- T8 excluded_codes 在开仓与持仓管理均检查。
- T9 缺失金额导致均价低估:当前保留未定价子单,延后数量/金额记账,不按零金额算均价。
- busy_keys 与 SimpleCache 都在 busy/place 验证;补仓首次成交计一次,已完成首次接管规则与 expected_qty 持久化。
### T1. 快照缺席或撤单过滤会不可逆地丢失未决状态
## 保留配置与性能观察
位置:`strategy/trend/order.py:60``strategy/trend/state.py:144``strategy/trend/boot.py:176`
此前要求跳过的行为仍保留:大盘恒允许、不足一手强制 100 股、现金安全线仅限开仓、固定亏损档位与未生效的两个风控配置。时间退出条件仍为 >=15:00交易时间只判断工作日/时段
`refresh()` 覆盖 `data`;短暂空快照没有保留本地 pending。更直接的触发是超时订单调用撤单后立即 `continue`,无论撤单业务结果如何,都不再进入对账数据。`reconcile()` 找不到订单即把 ING 清为空字符串,无持仓还会删除记录;之后只处理 ING 的逻辑无法回写迟到的成交
状态查找使用字典/集合,无变化不写盘;但每个新买单都在 OrderBook.mutex 内序列化整个状态文件并同步写盘,同时持有 State 锁。批量下单的耗时随账本大小和磁盘延迟增长;“最高性能”没有基准测试支持。可先测每轮耗时、文件大小、持锁时间,再考虑批量意图预留与一次落盘;不可为减少写入而取消提交前持久化保障
`busy_keys` 已保留这些活动订单,能阻止同方向再次提交,但它没有传给状态对账,不能解决此问题。建议保留待确认状态,并将完整订单快照与展示列表分开;只有明确终态才能结束对账。
## 本轮验证
## Trend高风险
6 组隔离复现已执行R1 空持仓重置次数、R2 未发送订单残留、R3 HTTP 400 锁定、R4 终态后金额修正忽略、R5 撤单异常中断对账/误触手工委托、R6 IPO failed 响应写锁。
### T2. 部分成交与取消拆单被过滤成全部完成
位置:`strategy/trend/order.py:69``strategy/trend/state.py:153``strategy/trend/state.py:213`
同一本地订单包含完成子单和取消子单时,取消子单被过滤;剩余全是 56状态变为 OK并增加补仓次数。建议用完整快照判定终态单独累计实际成交部分成交的记账与是否完整完成应分别处理。
### T3. 下单受理后、状态落盘前崩溃会丢失订单关联
位置:`strategy/trend/open.py:89``strategy/trend/positions.py:181`
先调用 place 成功,再保存状态。响应丢失、进程退出或写盘失败可能使底仓/补仓缺少对应记录。重启后即使券商活动订单暂时阻止重复提交,补仓次数与成交关联仍可能无法恢复。建议提交前持久化意图,保留可核查订单标识;不要求必须采用 SQLite。
### T4. 开仓没有账户余额预算,且与补仓并发 (忽略)
位置:`strategy/trend/boot.py:185``strategy/trend/open.py:49``strategy/trend/positions.py:42`
开仓循环每次使用完整 buy_value没有扣减余额补仓线程独立使用全部可用资金。多个候选或开仓与补仓同时触发时计划金额可超出账户资金。建议统一预留预算受理失败时按确定性结果释放。
### T5. Trend 会撤销账户内其他来源的超时订单 (忽略)
位置:`strategy/trend/boot.py:57``strategy/trend/order.py:67`
组合接口返回账户订单refresh 对全部满足状态和时间条件的订单调用 cancel_by_id没有检查本地订单前缀或策略归属。若该账户同时有手工、IPO 或其他策略的可撤委托,也会被处理。建议防重可以参考全账户委托,但自动撤单只作用于明确归属本策略的订单。
## Trend中风险及需要确认的行为
- **T6 异常隔离不足(已处理)**:下单入口现捕获 APIError、httpx.RequestError 和 SDK 解码抛出的 ValueError记录异常后返回 False结果不确定时保留缓存防重不自动重试。持仓循环增加逐证券 Exception 边界,记录证券代码和堆栈后继续下一只。已通过隔离断言验证连接错误、读取超时、解码错误,以及首只证券异常后第二只仍被处理;未新增 tests 文件。
- **T7 初始化资源释放(已处理)**StartTrend 在 Client 创建后使用统一 try/finally线程池单独持有引用退出时先等待已创建的线程池结束再关闭 Client即使线程池关闭抛异常内层 finally 仍关闭 Client。已通过 7 个隔离场景验证组合查询、撤单刷新、状态读取、信号加载、Runtime 构建失败,以及正常退出和线程池关闭异常。未连接柜台或新增测试文件。
- **T8 排除名单只约束持仓管理(已处理)**excluded_codes 同时禁止新开仓open_signal 在每条信号处理前检查并记录跳过原因,与持仓管理一致。已通过隔离验证:高于昨收直接开仓、反弹确认开仓两条路径均跳过排除证券,后续允许证券仍正常处理。未新增测试文件或调用真实交易接口。
- **T9 成交价缺失时均价可能被低估 (忽略)**`state.py:225` 对所有成交计入数量,却只对有金额/价格的记录计入金额;例如两笔各 100 股,只有一笔有 1000 元金额,会得到 5 元均价。应标记数据不完整并补查,不把未知金额当作零。当前交易决策主要使用券商 position.open_price本项直接影响本地记录。
- **T10 峰值止盈有门槛限制且不持久化 (忽略)**`positions.py:105` 在低于最低利润时直接返回。已达峰值后跳跌到门槛以下不会触发回撤卖出;进程重启也丢失历史峰值。若目标是“激活后持续追踪”,需保留激活状态和峰值;若门槛是最低可接受卖价,应明确记录这一取舍。
- **T11 信号仅启动读取 (忽略)**`boot.py:67` 不在循环内刷新;首次读取失败会使该信号整次运行缺席,后续更新不会生效。建议按业务需要定时刷新。
## 当前保留的风控设置
以下行为仍存在;按此前明确跳过的决定列为保留风险,不视为本次自动修复授权:
- `libs/market.py:34` 始终允许开仓/补仓,市场失败或下跌不阻断。
- `libs/calc.py:10` 不足一手预算仍返回 100 股,可超过单笔额度。
- `boot.py:147` 现金安全线只限制开仓,未扣除补仓安全储备。
- `positions.py:16` 固定亏损档位,最低盈利按股价计算,配置的 loss_trigger_pct/min_profit_pct 不参与 Trend。
另:`boot.py:90` 当前仍为 `>= 15:00:00`,与此前“大于 15:00”约定不一致。IPO 与 Trend 使用的 trading_time 仅校验工作日和时段,不包含节假日日历;本次未扩展该功能。
## 已确认修复与验证边界
- Trend 的 busy() 和 place() 均检查 busy_keys + SimpleCache。集合在撤单前收集故请求撤单仍保留防重refresh 不会续期 SimpleCache。
- Trend 调用现有 passorderSDK 参数对应一致。
- 未知信号配置通过 continue 跳过,不再解引用 None。
- 旧测试共 6 个 `.py` 文件按要求删除,可从 Git 恢复;不将旧测试失败统计当成本轮结果,也不重新建立 tests 文件。
- 本次只进行语法与隔离行为检查,未验证真实柜台受理、撤单终态和回报字段。服务端直接字符串化底层 passorder 返回值Trend 以有效 order_ref 判断成功;二者能否匹配真实 QMT 必须以实际回报核验,不能仅凭静态代码断言。
优先处理 T1/T2 对账数据问题、I1/I2 申购确认与幂等、T5 撤单归属,再评估资金预算与其他保留风险。
R4 属于有条件的上游契约风险;其余复现验证代码在所述输入/故障下的行为,不等价于断言柜台必然出现该故障。没有新增 tests 文件或真实网络交易请求。