diff --git a/docs/AUDIT_AND_REMEDIATION.md b/docs/AUDIT_AND_REMEDIATION.md deleted file mode 100644 index 507fdc2..0000000 --- a/docs/AUDIT_AND_REMEDIATION.md +++ /dev/null @@ -1,372 +0,0 @@ -# big-qmt 项目审计与整改建议 - -- 审计日期:2026-08-28 -- 审计范围:服务端 `api/`、客户端 `py-client/` -- 审计方式:静态代码检查、调用链核对、只读语法编译 -- 当前状态:仅供人工确认,尚未实施代码整改 - -## 一、总体结论 - -当前版本不建议直接进入实盘运行。 - -服务端存在文件编码导致的启动级错误,且所有 QMT 调用和大对象序列化都在 Tornado 主线程同步执行。客户端的持仓管理、止盈、补仓、撤单和下单确认链存在多处必现错误或状态不一致风险。 - -建议按以下顺序处理: - -1. 恢复服务端和客户端的基本可运行性。 -2. 修复交易安全相关的订单确认、撤单和持仓数据模型。 -3. 为止盈、补仓、订单状态机建立测试。 -4. 在确认 QMT 线程约束后优化服务端响应速度。 -5. 最后进行结构简化和重复代码清理。 - ---- - -## 二、P0:启动及交易安全问题 - -### 2.1 服务端文件编码不一致,程序无法正常编译 - -位置:`api/QMT_API.py:1` - -现状: - -```python -# -*- coding: gbk -*- -``` - -文件实际内容包含 UTF-8 字节,只读编译时报错: - -```text -SyntaxError: 'gbk' codec can't decode byte ... -``` - -影响:服务端可能在载入阶段直接退出,所有 API 不可用。 - -解决方案: - -如果运行环境强制要求 GBK,则必须把整个文件真实转换为 GBK,不能只修改声明。 - -验收标准: - -- 中文日志和错误响应无乱码。 - -### 2.2 客户端持仓对象被错误当成字典和二元组使用 - -位置:`py-client/strategy/trend/positions.py:32-40` - -现状:`client.positions()` 返回 `list[Position]`,但代码同时使用: - -```python -for idx, pos in positions: -code = pos["stock_code"] -avg_price = pos.get("avg_price", 0) -``` - -行情结果同样是 `Tick` dataclass,却使用字典的 `.get()`。 - -影响:进入持仓管理后必然抛出 `TypeError` 或 `AttributeError`,止盈和补仓完全无法执行。 - -解决方案: - -全项目统一使用 SDK dataclass + __slots__,不再混用原始字典。 - -验收标准: - -- 使用真实 `Position`、`Tick` 对象执行一轮不抛异常。 - -### 2.3 `handle_profit` 调用参数和函数签名不一致 - -位置: - -- 调用:`py-client/strategy/trend/positions.py:58` -- 定义:`py-client/strategy/trend/positions.py:73` - -影响:修复持仓遍历后,下一步仍会立即触发 `TypeError`。 - -解决方案: -删除未使用的 `open_price`、`strategy_name` 或把它们纳入统一模型。 - -推荐接口: - -```python -def handle_profit( - runtime: Runtime, - position: Position, - tick: Tick, - pnl_rate: float, -) -> ProfitDecision: - ... -``` - -验收标准: - -- 静态类型检查能够发现参数数量错误。 -- ARMED、RAISED、STEADY、RETREAT 四种状态都有测试。 - -### 2.4 补仓流程存在多处必现错误 - -位置:`py-client/strategy/trend/positions.py:118-159` - -问题包括: - -- `StateItem` 被当成字典调用 `.get()`。 -- `orders.busy()` 多传入一个 `run` 参数。 -- 某些分支只返回 `False`,调用方却解包两个值。 -- 使用不存在的 `run.state.STATUS_ING`。 -- `state.added_num = +1` 每次都赋值为 1,并非累加。 -- `LOSS_TIERS[added_num]` 可能数组越界。 -- 下单后没有扣减本轮剩余预算,多持仓可能超额补仓。 - -解决方案: - -1. 所有 `StateItem` 字段改为属性访问。 -2. `orders.busy(code, "BUY")` 使用正确签名。 -3. 所有返回分支统一返回结构,推荐使用 dataclass + __slots__: - -```python -@dataclass(frozen=True) -class TradeDecision: - submitted: bool - message: str = "" - reserved_cash: float = 0.0 -``` - -4. 使用模块常量 `STATUS_ING`,或把状态定义成 `Enum`。 -5. 补仓次数使用 `state.added_num += 1`。 -6. 当 `added_num >= len(LOSS_TIERS)` 时明确禁止继续补仓。 -7. `RunOnce` 创建本轮 `remaining_cash`,每次成功提交补仓后立即扣减。 - -验收标准: - -- 第 0、1、2 次补仓边界均有测试。 -- 超过最大补仓次数不会抛异常或继续下单。 - -### 2.5 止盈跟踪器每轮重建,无法形成跨轮回撤 - -位置:`py-client/strategy/trend/positions.py:31` - -影响:每轮都会清空最高盈利网格,止盈状态无法从 ARMED/RAISED 演进至 RETREAT。 - -解决方案: - -1. `GridTrailingTracker` 应作为 `Runtime` 字段,在策略启动时只创建一次。 -2. 检查是否有定时清理的功能 - -验收标准: - -- 连续输入 2.1%、3.1%、2.9% 能产生 ARMED、RAISED、RETREAT。 -- 相同股票不同账户的峰值互不污染。 -- 清仓后重新建仓不会继承旧峰值。 - -### 2.6 “取消过期订单”只查询可撤状态,没有执行撤单 - -位置: - -- 客户端:`py-client/strategy/trend/order.py:74-88` -- 服务端:`api/QMT_API.py:962-969` - -影响:过期订单一直保留,订单锁可能长期阻止新交易。 - -解决方案: - -方案 A,推荐:新增按真实委托号撤单接口。 - -```text -POST /api/order/cancel_by_id -body: {order_id, account_type} -``` - -服务端先执行 `can_cancel_order()`,可撤时调用真正的 `cancel()`,并返回撤单请求结果。 - - -验收标准: - -- 暂时不做验证,后期验证 - -### 2.7 低现金资金闸同时跳过卖出管理 - -位置:`py-client/strategy/trend/boot.py:126-134` - -影响:可用资金不足时直接结束整轮流程,持仓止盈和风险退出也被禁止。 - -解决方案: - -把“是否允许新开仓/补仓”和“是否允许卖出”拆成不同条件。 - -验收标准: - -- 可用现金低于阈值时不开仓、可补仓。 -- 同一情况下满足止盈条件的持仓仍然能够提交卖单。 - - -### 2.9 客户端订单标签未真正传给 QMT - -位置: - -- 客户端发送:`py-client/sdk/trade.py:10-18` -- 服务端丢弃:`api/QMT_API.py:663` - -现状:客户端发送 `strategyName`,服务端调用 `passorder()` 时却硬编码为 `qmt`。 - -解决方案: - -1. 统一:strategy_name 为信号的key,m_strRemark为本地业务订单号。 -2. 同时修改QMT_API.py - -验收标准: - -- 下单后在 QMT 委托明细中可以看到客户端标签。 -- 能从本地订单 ID 追踪到真实委托号和最终成交。 - ---- - -## 三、P1:服务端响应速度整改 - - -验收标准: - -- 一轮策略账户查询由三次以上 QMT 调用下降为一次快照调用。 -- 下单后下一次快照不会返回过期的订单状态。 - -### 3.3 大量使用 `dir()` 和 `getattr()` 反射序列化 - -位置: - -- `api/QMT_API.py:920-930` -- `api/QMT_API.py:943-950` -- `api/QMT_API.py:979-1031` -- `api/QMT_API.py:1454-1473` - -影响:对每个对象遍历全部属性、捕获异常并转字符串,CPU 开销大,返回字段也不稳定。 - -解决方案: - -1. 为订单、成交、资产、持仓等类型定义固定字段映射。 -2. 只返回客户端实际使用的字段。 -3. 使用统一的轻量转换函数,不在每个 Handler 复制反射循环。 -4. 对未知扩展类型单独保留调试接口,不进入高频生产路径。 - -验收标准: - -- 高频订单查询不再调用 `dir()`。 -- 返回 JSON 字段固定并有接口契约测试。 -- 相同数据量下序列化 CPU 时间明显下降。 - - -### 3.5 回调同步写 JSON 文件 - -位置:`api/QMT_API.py:1475-1516` - -影响:目录创建、反射序列化和格式化写盘可能阻塞 QMT 回调线程。 - -解决方案: - -3. 生产环境关闭 `indent=4`。 -4. 使用临时文件替换,避免半写文件。 - -验收标准: - -- 回调函数本身在毫秒级返回。 -- 磁盘慢或不可写时不会阻塞交易回调。 -- 写入失败可监控且不会静默丢失。 - -### 3.6 客户端 HTTP 没有连接池 - -位置:`py-client/sdk/client.py:28-47` - -影响:每次 `urlopen()` 都可能新建连接,高频轮询产生额外 TCP 开销。 - -解决方案: - -1. 改用支持连接池的 HTTP 客户端,如 `httpx.Client` 或 `requests.Session`。 -2. 整个策略生命周期复用一个 Client。 -3. 设置连接、读取和总超时,不只设置单一 timeout。 -4. 只对幂等查询配置有限重试;下单和撤单不能自动盲重试。 - -验收标准: - -- 连续请求复用 TCP 连接。 -- 查询超时能重试,下单超时进入“结果未知、需对账”状态而不是重复下单。 - ---- - -## 四、P1:客户端其他逻辑与可靠性问题 - -### 4.1 新开仓订单锁可能在同一轮失效 - -位置: - -- `py-client/strategy/trend/open.py:24` -- `py-client/strategy/trend/order.py:61-65` -- `py-client/strategy/trend/order.py:99-101` - -现状:开仓检查 `busy()`,该方法只查看 `data`;新下单后只把键加入 `index`,没有加入 `data`。 - -解决方案: - -1. 统一锁判断,只保留一个权威接口。 -2. 下单成功后立即插入本地 pending `OrderItem`。 -3. 信号进入处理前按证券代码去重。 -4. 每轮刷新券商订单后用真实订单覆盖本地 pending 状态。 - -验收标准:同一轮两个来源返回同一证券信号时最多提交一笔买单。 - -### 4.2 `Runtime` 文档和字段不一致 - -位置:`py-client/strategy/trend/runtime.py` - -现状:文档描述 `peak_grids`,实际 dataclass 没有该字段;持仓代码仍可能访问它。 - -解决方案: - -2. 如果统一使用 `GridTrailingTracker`,删除 `peak_grids` 及所有引用。 -3. 不应同时保留两套止盈峰值实现。 - -验收标准:项目中只有一种网格峰值状态来源。 - -### 4.3 `ping_api_host()` 参数无效且吞掉退出信号 - -位置:`py-client/main.py:59-76` - -问题: - -- `rpc_host` 参数没有使用。 -- `connect_timeout` 参数没有使用。 -- 使用裸 `except:`,会捕获 `KeyboardInterrupt` 和 `SystemExit`。 -- 无限重试没有最大日志节流或取消事件。 - -解决方案: - -1. 函数重命名为 `wait_for_qmt_api()`,删除无用参数。 -2. 仅捕获网络类异常和 `APIError`。 -3. 允许 `KeyboardInterrupt` 正常终止。 -4. 使用 `threading.Event.wait()` 或可取消等待。 - -验收标准:API 不可用时可以通过 Ctrl+C 立即退出。 - - - -### 4.5 状态文件缺少完整对账和生命周期 - -位置:`py-client/strategy/trend/state.py` - -解决方案: - -1. 启动时用真实持仓、订单和成交三方对账。 -2. `ING` 状态必须根据真实订单结果转为 `OK`、`FAILED`、`CANCELED` 或 `UNKNOWN`。 -3. 已清仓证券应从状态中删除,并清除观察器和止盈峰值。 -4. 状态文件不增加版本号,不增加新字段。 - -验收标准:程序在下单后崩溃并重启,能够从券商真实状态恢复,而不会重复下单。 - -### 4.6 日志调用格式错误且异常上下文不足 - -位置:`py-client/` - -现状:不符合 logging 格式化规则。 - -解决方案: - -1. 统一优化日志打印同时输出至文本文件(每天一个文件)。 - -验收标准:日志输出期间不出现 logging 自身的格式化异常。 - diff --git a/docs/CODE_AUDIT_2026-08-29.md b/docs/CODE_AUDIT_2026-08-29.md deleted file mode 100644 index 56cf82c..0000000 --- a/docs/CODE_AUDIT_2026-08-29.md +++ /dev/null @@ -1,204 +0,0 @@ -# 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` 不足一手、多信号总金额超过现金、价格滑点场景。 -- 新增:同证券同方向两笔活动委托都能被发现和撤销。 -- 在模拟账户完成至少一次完整的下单、部分成交、撤单、重启对账闭环后,再考虑实盘。 diff --git a/docs/CODE_REAUDIT_2026-08-29_V2.md b/docs/CODE_REAUDIT_2026-08-29_V2.md deleted file mode 100644 index c729b62..0000000 --- a/docs/CODE_REAUDIT_2026-08-29_V2.md +++ /dev/null @@ -1,176 +0,0 @@ -# big-qmt 手动修改后代码复审(2026-08-29 V2) - -## 1. 范围与验证 - -- 基于当前未提交工作区重新审计,不直接继承上一版报告结论。 -- 审计范围:`api/`、`py-client/`、启动脚本、配置和测试。 -- 明确排除:`docs/todo.md`;未读取、未引用其内容。 -- `python -B -m unittest discover -s tests -v`:11 项中 7 项通过、4 项错误。 -- 全部 Python 文件 AST 解析通过。 -- 未连接真实 QMT,未执行真实申购、买卖、撤单和成交回报测试。 - -## 2. 总体结论 - -当前版本仍不建议直接实盘运行。 - -手动修改已经修复了 IPO 路径中的三个表面问题:`trading_time()` 现在传入时间、数据目录能够正确转成 `Path`、首次申购的本地锁判断方向已经改正;APScheduler 也能在趋势策略永久循环之外每日 10:00 触发任务。 - -但核心安全闭环仍未完成:IPO 会在没有确认券商受理的情况下写入“已申购”锁,也没有使用券商委托/成交记录防重;趋势策略的 `UNKNOWN` 状态仍不能阻止再次开仓;开仓资金仍可能超预算。测试失败数量还从上一轮的 3 项变成了 4 项。 - -## 3. P0:致命或可能导致错误交易 - -### 3.1 IPO 不校验下单结果就写入成功锁,可能永久漏申购 - -位置:`py-client/strategy/ipo/boot.py:37-51` - -`client.passorder()` 的返回值被完全忽略,随后无条件执行 `write_lockfile(lp)`。如果服务端以正常 HTTP 响应返回 `{"status": "failed"}`、空订单号或其他业务拒绝结果,本地仍会记录为已申购,后续每天都会跳过该证券。 - -影响:券商实际没有接受订单,但本地永久认为已经提交,造成漏申购。 - -建议:统一校验 `status == "success"` 且 `order_ref` 有效;只有明确受理后才能写锁。未知响应应告警并保持可对账状态,不能直接标记成功。 - - - -### 3.3 单只 IPO 异常会中止当天全部后续候选 - -位置:`py-client/strategy/ipo/boot.py:37-51` - -候选循环内部没有单只证券级异常隔离。第一只证券的字段缺失、下单拒绝抛异常或锁文件写入失败,都会直接退出 `AutoBuyIpo()`;后面的候选不会再尝试。APScheduler 会记录任务异常,但当天 10:00 不会自动重新执行整个任务。 - -影响:一只异常证券导致当天其他所有新股漏申购。 - -建议:每只候选独立 `try/except` 并记录证券代码;失败继续处理下一只。任务结束后汇总成功、跳过、失败数量。 - -### 3.4 趋势策略 `UNKNOWN` 状态仍不能阻止自动重复开仓 - -位置: - -- `py-client/strategy/trend/state.py:83-89,184-237` -- `py-client/strategy/trend/boot.py:145-164` -- `py-client/strategy/trend/open.py:17-70` - -`State.delete()` 会保留 `ING/UNKNOWN`,但 `RunOnce()` 生成开仓候选时只排除真实持仓;`open_signal()` 只检查 `OrderBook.busy()`。如果启动对账将订单标记成 `UNKNOWN`,同时券商活动委托列表暂时没有该订单,状态文件虽然存在,下一轮信号仍可再次下单。 - -此外,运行期调用 `delete()` 后不检查是否真的删除,就清除两个观察器,造成未决状态与观察状态不一致。 - -影响:订单结果无法确认时可能重复买入,未达到“UNKNOWN 禁止自动重复下单”的目标。 - -建议:开仓候选同时排除 `ING/UNKNOWN` 状态代码和活动买单代码;`delete()` 返回实际删除结果;每轮用最新订单簿重新对账状态。 - - - -## 4. P1:重要业务逻辑问题 - -### 4.1 IPO 函数契约退化,现有 4 项测试全部报错 - -位置: - -- `py-client/strategy/ipo/boot.py:22` -- `py-client/tests/test_ipo.py:52-136` - -`AutoBuyIpo(now: datetime | None = None)` 被改成无参数函数,测试无法注入确定时间,4 项测试全部以 `TypeError: AutoBuyIpo() takes 0 positional arguments but 1 was given` 结束。APScheduler 并不要求删除可选参数;保留可选 `now` 同样可以无参调度。 - -测试当前也明确要求但实现未满足:返回成功数量、关闭客户端、本地重启防重、券商记录防重、单只拒绝不影响其他候选。 - -建议:恢复可选 `now` 参数,内部使用 `current = now or datetime.now()`;不要修改测试来掩盖业务契约缺失。 - - -### 4.3 IPO 客户端从不关闭 - -位置:`py-client/strategy/ipo/boot.py:31-51` - -每日任务创建新的 `httpx.Client`,成功、跳过和异常路径都没有调用 `close()`。长期运行会累积未及时释放的连接池资源。 - -建议:使用 `with Client(...) as client:`,现有 `Client` 已实现上下文管理器。 - -### 4.4 IPO 返回类型与真实返回值不一致 - -位置:`py-client/strategy/ipo/boot.py:22-51` - -函数标注返回 `int`,只有禁用和非交易时间返回 0;正常处理完候选后隐式返回 `None`,也没有统计提交成功数量。 - -影响:日志、监控和测试无法知道任务到底提交了多少只证券。 - -建议:维护 `success_count` 并在所有出口返回整数。 - -### 4.5 同证券同方向的多笔趋势委托仍会互相覆盖 - -位置:`py-client/strategy/trend/order.py:70-79,121-125` - -订单缓存仍以 `BUY-code` / `SELL-code` 为唯一键。同证券同方向多笔委托只保留最后一笔,其他活动订单无法撤销或对账。 - -建议:系统委托号作为主键,方向与证券组合作为一对多活动索引。 - -### 4.6 过期撤单仍会处理已终结历史订单 - -位置:`py-client/strategy/trend/order.py:81-93` - -撤单条件没有检查 `order.status in BUSY_STATUSES`。接口若返回历史订单,程序会对已成交、已撤、已失败订单反复发送撤单请求。 - -建议:仅处理活动状态,并为已发送撤单请求增加冷却或本地状态。 - -### 4.7 下单成功但状态落盘失败的安全策略不统一 - -位置: - -- `py-client/strategy/trend/open.py:59-70` -- `py-client/strategy/trend/positions.py:175-183` - -开仓保存失败只记日志并继续;补仓保存失败则异常退出本轮。两者都可能已经存在真实订单,却缺少可恢复的本地状态。 - -建议:落盘失败后将证券加入内存熔断集合、保留订单锁并持续重试;在完成真实订单对账前禁止该证券继续自动交易。 - - -### 5.3 主程序仍有不可达代码、拼写错误和无效符号 - -位置: - -- `py-client/main.py:5,15,100-101,127-128` - -`StartTrend()` 正常情况下永久循环,其后的日志不可达;即便返回,`config.account_config.strateg` 也不存在。非 Windows 分支使用未定义的 `log.error`。`TimedRotatingFileHandler`、`GLOBAL_CONFIG_PATH` 未使用。 - -建议:启动成功日志放在进入永久循环前;统一使用 `logging`;删除无效导入和常量。 - -### 5.4 `State.delete()` 的保护过宽且静默 - -位置:`py-client/strategy/trend/state.py:83-89` - -任何 `ING/UNKNOWN` 都会让删除静默失效,没有返回值、日志、订单证据或人工强制清理入口。这是基于状态字符串的全局拦截,不是真实订单对账,并已造成调用方误清观察器。 - -建议:把清理许可放入显式对账决策;`delete()` 返回布尔值或明确拒绝原因;人工确认终结后应有可审计的清理路径。 - -### 5.5 状态对账重复写盘并暴露中间状态 - -位置:`py-client/strategy/trend/state.py:91-117,119-146` - -`reconcile()` 调用会自行保存的 `sync_positions()`,完成订单对账后再次保存。一次启动对账至少写盘两次,第一次还是未完成订单状态恢复的中间结果。 - -建议:内部同步只改内存,完整对账结束后一次性原子落盘。 - -### 5.6 调度器配置包含当前进程内不必要的重复任务替换 - -位置:`py-client/main.py:116-123` - -调度器每次启动都是新实例,只添加一次固定 ID 任务,因此 `replace_existing=True` 在当前结构下没有实际作用。它无害,但属于多余防御参数,容易让人误以为使用了持久化任务仓库或存在重复注册路径。 - -建议:若没有持久化 job store 或重复注册,删除该参数;若未来启用持久化,再保留并补充任务版本策略。 - -## 6. 本轮已确认改善 - -- APScheduler 后台调度不会被 `StartTrend()` 的永久循环阻塞。 -- `timezone="Asia/Shanghai"` 明确了每日 10:00 的业务时区。 -- `coalesce=True` 和 `max_instances=1` 能防止任务积压补跑和并发重叠。 -- `dev.yaml` 已显式配置 `signal_allow`,趋势信号不再因默认空列表而全部禁用。 -- IPO 本地路径拼接和首次锁判断方向已经修正。 -- 趋势部分成交判定的四类状态逻辑仍保留。 - -## 7. 建议整改顺序与验收门槛 - -1. 恢复 IPO 可选时间参数、成功计数和上下文管理器,让现有 4 项 IPO 测试先全部通过。 -2. 增加真实交易日、券商委托/成交防重、下单结果校验和单只异常隔离。 -3. 让 `ING/UNKNOWN` 真正参与趋势开仓过滤,并补充重启未成交端到端测试。 -4. 修复单笔与同轮开仓资金预算。 -5. 重构订单簿一对多索引并限制撤单状态。 -6. 清理启动脚本、重复 API 文件和死代码。 - -最低验收要求:现有 11 项测试全部通过;新增 IPO 返回失败但不写锁、锁丢失但券商已有订单、第一只拒绝而第二只成功、节假日不申购;新增趋势 `UNKNOWN` 无持仓且无活动委托时仍不重复开仓;最后在模拟账户完成下单、部分成交、撤单、重启对账闭环。 diff --git a/docs/CODE_REAUDIT_2026-08-29_V3.md b/docs/CODE_REAUDIT_2026-08-29_V3.md deleted file mode 100644 index 1ccc7d2..0000000 --- a/docs/CODE_REAUDIT_2026-08-29_V3.md +++ /dev/null @@ -1,249 +0,0 @@ -# big-qmt 手动修改后代码复审(2026-08-29 V3) - -## 1. 审计范围与验证 - -- 基于当前未提交工作区重新审计,不直接复制上一版报告。 -- 范围:`api/`、`py-client/`、配置、启动脚本和测试。 -- 明确排除:`docs/todo.md`;本轮未读取、未引用其内容。 -- 全套测试:`python -B -m unittest discover -s tests -v`。 -- 测试结果:12 项中 8 项通过、4 项错误;错误全部位于 IPO 测试。 -- 全部 Python 文件 AST 解析通过。 -- 未连接真实 QMT,未执行真实委托、成交和撤单。 - -## 2. 总体结论 - -当前版本仍不建议直接实盘运行。 - -本轮有两项明确改善:趋势策略已在每轮重新对账,`ING/UNKNOWN` 同时参与候选过滤和下单前检查;订单方向锁也已经改成带秒级时间戳的字典并自动清理。新增的 `UNKNOWN` 无持仓防重复测试通过。 - -但订单锁的新过期规则会在真实委托仍活动时强制解锁,券商刷新还会整体覆盖本地下单锁;卖出订单没有持久化状态兜底,因此存在重复卖出风险。IPO 仍然存在“业务失败响应也写成功锁”、券商侧防重缺失和函数契约错误。资金预算、订单覆盖及启动脚本风险也尚未解决。 - -## 3. P0:致命或可能造成错误交易 - -### 3.1 活动委托达到 180 秒会被强制解锁,可能重复下单 - -位置:`py-client/strategy/trend/order.py:47-52,59-89,138-146` - -业务锁是否有效只由创建时间和 `lock_timeout_sec` 决定,不再参考订单是否仍属于 `BUSY_STATUSES`。`refresh()` 明明从券商读到活动订单,仍会把创建时间超过 180 秒的锁立即删除。 - -定向复现:状态为 `50` 的活动卖单,创建 181 秒后,`busy("A", "SELL")` 返回 `False`。 - -开仓和补仓还有 `State` 的 `ING/UNKNOWN` 兜底,但止盈卖单没有写入持久化状态。活动卖单超时或撤单失败后,下一轮止盈判断可能再次提交同一证券卖单。 - -建议:本地“提交防抖锁”可以超时,但券商明确返回活动状态时必须继续视为 busy;将两者拆成 `local_locks` 和 `active_order_index`,`busy()` 对二者取并集。卖出订单也应进入可恢复状态或订单索引。 - -### 3.2 刷新券商订单会整体覆盖刚提交的本地锁 - -位置:`py-client/strategy/trend/order.py:72-89,132-135` - -`refresh()` 使用新字典直接替换 `self.lock`。如果下单接口成功后,券商订单明细存在短暂可见性延迟,下一轮刷新会删除刚写入的本地锁。对于没有状态兜底的卖单,这同样可能导致重复委托。 - -建议:刷新时合并尚未超过防抖时限的本地锁;只有券商明确返回终结状态,或本地锁超时且券商持续不可见,才能移除,并记录告警。 - -### 3.3 IPO 不检查业务返回结果就写成功锁 - -位置:`py-client/strategy/ipo/boot.py:37-55` - -当前 `try` 只能捕获 Python 异常。`client.passorder()` 返回 `{"status": "failed"}`、空订单号或其他业务拒绝响应时不会抛异常,代码仍进入 `else` 并写锁文件。 - -影响:券商没有接受申购,本地却永久标记为已申购,造成漏申购。 - -建议:捕获异常之外,还必须校验返回值为字典、`status == "success"` 且 `order_ref` 有效;满足全部条件后才能写锁。 - -### 3.4 IPO 没有券商委托/成交防重 - -位置:`py-client/strategy/ipo/boot.py:31-57` - -当前只检查本地空文件,不查询当日券商委托和成交。下单成功后、写锁前崩溃,或锁目录被清理/切换时,10:00 与 14:00 两次任务可能对同一证券再次提交申购。 - -建议:以账户、交易日、证券代码和 IPO 备注核对 `trade_detail_data("order")` 与 `deals()`;本地锁只能作为快速缓存,不能作为唯一事实来源。 - -### 3.5 趋势开仓仍可能超过单笔预算和账户可用现金 - -位置: - -- `py-client/libs/calc.py:10-12` -- `py-client/strategy/trend/boot.py:127-185` -- `py-client/strategy/trend/open.py:41-74` - -`calc_buy_volume()` 在预算不足一手时仍强制返回 100 股。趋势开仓只检查一次现金比例,没有校验单笔预计金额,也没有在同一轮多个信号之间预留资金。 - -影响:高价股会超过 `buy_value`;多个信号可能同时使用同一份可用现金,发送总额超限的委托。 - -建议:预算不足一手时返回 0;开仓循环维护共享 `remaining_cash`,成功提交后立即扣减预留金额,并保留滑点余量。 - -## 4. P1:重要业务逻辑错误 - -### 4.1 IPO 的 4 项现有测试全部无法进入业务逻辑 - -位置: - -- `py-client/strategy/ipo/boot.py:22` -- `py-client/tests/test_ipo.py:52-136` - -`AutoBuyIpo()` 删除了原有可选时间参数,测试传入固定时间时全部报:`TypeError: AutoBuyIpo() takes 0 positional arguments but 1 was given`。APScheduler 无参调用与保留可选 `now` 并不冲突。 - -测试还要求但当前实现没有满足:成功数量返回、上下文关闭、重启防重、券商记录防重、单只拒绝不影响其他候选。 - -建议:恢复 `now: datetime | None = None`,内部使用 `now or datetime.now()`;修复生产逻辑后让测试通过,不要删除失败测试。 - -### 4.2 IPO 正常完成时返回 `None`,与 `-> int` 不一致 - -位置:`py-client/strategy/ipo/boot.py:22-57` - -只有禁用和非交易时间返回 0;完成候选循环后没有返回值,也没有统计成功数。 - -建议:维护 `success_count`,所有出口都返回整数。 - -### 4.3 IPO 资源关闭仍不具备异常安全性 - -位置:`py-client/strategy/ipo/boot.py:31-57` - -`client.close()` 只在整个流程正常走到末尾时执行。`ipo_data()`、候选字段读取、锁写入等任何异常都会跳过关闭。单只下单异常虽然被捕获,但仅记录普通 info,丢失堆栈和具体原因。 - -建议:使用 `with Client(...) as client:`;单只异常用 `logging.exception()` 并继续其他候选。 - -### 4.4 IPO 只判断工作日,不判断真实交易日 - -位置: - -- `py-client/strategy/ipo/boot.py:27` -- `py-client/libs/calc.py:5-7` - -法定节假日或临时休市仍会进入申购。模块中的 `TRADING_CALENDAR_SYMBOL` 未使用。 - -建议:用 SDK `trading_dates()` 确认当天交易日;查询失败时安全跳过。 - -### 4.5 同证券同方向多笔委托仍会互相覆盖 - -位置:`py-client/strategy/trend/order.py:74-89,132-135` - -`data` 和 `lock` 都以 `SIDE-code` 为唯一键。两笔相同证券、相同方向的订单只保留最后一笔。 - -定向复现:传入两笔 `SELL-A`,刷新后 `len(data) == 1`。 - -影响:被覆盖订单无法被撤销、展示或独立对账。 - -建议:订单数据以系统委托号为主键,另建 `(side, code) -> set[order_id]` 活动索引。 - -### 4.6 撤单状态集合与活动状态集合不一致 - -位置:`py-client/strategy/trend/order.py:15,97-104` - -`BUSY_STATUSES` 包含 `48` 和 `55`,撤单逻辑只处理 `49`~`52`。如果 `48/55` 也是可继续成交且可撤的状态,它们永远不会被超时撤销;同时其锁仍可能在 180 秒后被删除。 - -建议:建立单一、有文档依据的状态映射,分别定义“活动”“可撤”“终结”,不要在不同函数中散落不一致的魔法集合。 - -### 4.7 撤单请求会每轮重复发送 - -位置:`py-client/strategy/trend/order.py:91-104` - -默认 10 秒即触发撤单,主循环每 30 秒刷新一次;只要券商仍返回相同活动状态,每轮都会再次调用 `cancel_by_id()`,没有撤单中、本地冷却或最大重试次数。 - -建议:记录最近撤单请求时间和结果;处于撤单中的订单采用退避重试,并设置最大次数和告警。 - -### 4.8 状态保存失败后的安全策略仍不统一 - -位置: - -- `py-client/strategy/trend/open.py:59-74` -- `py-client/strategy/trend/positions.py:175-183` - -开仓保存失败只记录日志并继续;补仓保存失败抛出异常。两种情况都可能已经有真实订单,但恢复数据未落盘。 - -建议:落盘失败后保留内存订单锁并熔断该证券,持续重试保存;完成券商对账前禁止后续自动交易。 - -### 4.9 启动脚本会杀死机器上的全部 Python 进程 - -位置:`run.bat:1-7` - -`taskkill /IM python.exe /F` 不区分 PID 或项目;脚本又没有先切换到 `py-client`,`python main.py` 依赖调用目录。 - -建议:仅管理本项目 PID;使用 `%~dp0` 构造绝对路径;生产启动不要无条件 `git pull`。 - -## 5. P2:冗余、过度验证与维护风险 - -### 5.1 每轮重复查询两次委托明细 - -位置: - -- `py-client/strategy/trend/boot.py:121-125,147-153` -- `py-client/strategy/trend/order.py:72-93` - -`cancel_expired()` 内部先调用一次 `trade_detail_data("order")`,随后 `RunOnce()` 为状态对账再次调用同一接口。30 秒一轮时属于稳定的重复网络请求,两次快照还可能不一致。 - -建议:每轮只获取一次原始订单快照,同时传给订单簿刷新、撤单判断和状态对账。 - -### 5.2 状态对账重复落盘 - -位置:`py-client/strategy/trend/state.py:107-133,135-162` - -`reconcile()` 先调用会保存的 `sync_positions()`,结束时再次保存。每轮至少写两次状态文件,第一份还是未完成订单对账的中间状态。 - -建议:内部同步只更新内存,完整对账成功后一次原子保存。 - -### 5.3 `State.delete()` 保护仍然过宽且静默 - -位置:`py-client/strategy/trend/state.py:93-105` - -虽然已经返回布尔值,但任何 `ING/UNKNOWN` 都只按状态字符串拒绝删除,没有订单证据、日志或人工确认后的强制清理入口。当前内部清理也没有使用返回值记录拒绝原因。 - -建议:把删除许可作为明确的对账结果;为人工确认提供带原因的审计接口。 - -### 5.4 两份 API 文件逐字节重复,正式入口处于删除状态 - -位置: - -- `api/qmt_api_new.py` -- `api/qmt_api_rele.py` -- 当前删除的 `api/QMT_API.py` - -两个新文件 SHA-256 完全相同,正式加载哪个文件不明确。 - -建议:只保留一个正式入口,版本差异交由 Git 管理。 - -### 5.5 IPO 模块存在未使用的旧设计残留 - -位置:`py-client/strategy/ipo/boot.py:5-19` - -`json`、`Any`、`IPO_STRATEGY_NAME`、`IPO_REMARKS`、`IPO_SESSIONS`、`TRADING_CALENDAR_SYMBOL` 当前均未使用。 - -建议:先完成券商对账和交易日逻辑,再删除确认无用的符号。 - -### 5.6 主程序仍有不可达代码和拼写错误 - -位置:`py-client/main.py:13-14,126-127` - -`StartTrend()` 正常情况下永久循环,后面的日志不可达;即使返回,`config.account_config.strateg` 也不存在。`GLOBAL_CONFIG_PATH` 未使用。 - -建议:启动成功日志放在进入循环前;删除不可达代码和无效常量。 - -### 5.7 `replace_existing=True` 在当前内存调度器中是多余防御 - -位置:`py-client/main.py:115-122` - -每次进程启动都创建全新内存调度器,并只注册一次固定任务,不存在同一调度器重复注册路径。该参数无害,但容易暗示存在持久化 job store。 - -建议:没有持久化任务仓库时可删除;未来启用持久化后再明确任务替换策略。 - -## 6. 本轮已确认改善 - -- APScheduler 在北京时间每日 10:00、14:00 触发,不受趋势永久循环阻塞。 -- IPO 单只 `passorder()` 抛异常时不再写锁,并会继续处理后续候选。 -- `dev.yaml` 已显式启用信号列表。 -- 趋势每轮重新读取委托和成交进行状态对账;对账失败时本轮停止自动交易。 -- `ING/UNKNOWN` 已同时进入候选过滤和提交前检查。 -- `State.delete()` 已返回真实删除结果。 -- 趋势新增的 `UNKNOWN` 防重复测试通过;趋势测试共 8 项全部通过。 -- 订单方向锁已经改为 `dict[str, float]`,过期清理本身可工作。 - -## 7. 建议整改顺序与验收门槛 - -1. 先拆分“本地防抖锁”和“券商活动订单索引”,确保活动委托绝不因时间到期而变成不忙。 -2. 修复 IPO 返回值校验、券商防重、可选时间参数、资源关闭和成功计数,使现有 4 项 IPO 测试通过。 -3. 修复开仓单笔和同轮资金预算。 -4. 将订单簿改为系统委托号主键,统一活动/可撤/终结状态表和撤单冷却。 -5. 消除重复网络查询和重复状态写盘。 -6. 最后清理启动脚本、重复 API 文件和死代码。 - -最低验收要求:现有 12 项测试全部通过;新增“活动卖单超过锁超时仍 busy”“券商刷新延迟不清除本地锁”“两笔同方向订单均可撤销”“IPO 业务失败响应不写锁”“锁丢失但券商已有 IPO 委托不重复申购”;最后在模拟账户完成买入、部分成交、撤单、卖出、重启对账闭环。 diff --git a/docs/RE_AUDIT_2026-08-29.md b/docs/RE_AUDIT_2026-08-29.md deleted file mode 100644 index 59d846b..0000000 --- a/docs/RE_AUDIT_2026-08-29.md +++ /dev/null @@ -1,223 +0,0 @@ -# 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.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/state.md b/docs/state.md new file mode 100644 index 0000000..6fd3d98 --- /dev/null +++ b/docs/state.md @@ -0,0 +1,99 @@ +# 趋势策略状态机审计 + +审计日期:2026-08-31 + +## 结论 + +当前状态机的职责明确为:按账户和策略持久化每只证券的底仓信息与**补仓业务数据**。补仓委托提交成功后立即写入状态机是必要且正确的;30 秒轮询中的对账用于用券商快照校正订单状态和接管持仓,不能替代补仓次数等业务数据的记录。 + +状态机主链路可运行,但补仓单被撤销、拒绝或部分成交时,没有完整的状态回写规则;这会使 `added_status` 和 `added_num` 与实际成交结果不一致。该问题应在实盘前明确处理。 + +## 数据模型与持久化 + +状态文件路径为:`{qmt_data_dir}/{strategy}_{account_id}_state.json`。 + +每个证券对应一个 `StateItem`: + +| 数据 | 含义 | 写入来源 | +| --- | --- | --- | +| `base_qty`、`base_cost`、`base_status` | 首次接管时的底仓数量、成本及状态 | `sync_positions()` | +| `added_num` | 已提交的补仓次数 | 补仓委托提交成功后立即递增 | +| `added_order_id`、`added_qty`、`added_cost`、`added_status` | 最近一笔补仓委托及状态 | 补仓提交后写入;30 秒对账尝试校正状态 | + +`State.save()` 先写临时文件,再以 `replace()` 原子替换正式 JSON;能避免半写入文件,但不构成“券商下单 + 本地持久化”的跨系统事务。 + +## 运行流程 + +```text +启动 + ├─ 刷新券商订单 → OrderBook.data + ├─ 查询持仓 + └─ State.reconcile(持仓, 订单快照) + +每 30 秒的 RunOnce + ├─ 刷新订单;失败则本轮退出 + ├─ 查询资产、市场、持仓 + ├─ State.reconcile(持仓, OrderBook.data) + ├─ 开仓:OrderBook 方向锁防重;不写 State + └─ 补仓:下单成功 → 立即写 StateItem 并持久化 +``` + +`OrderBook` 与 `State` 的边界如下: + +- `OrderBook.lock`:进程内的同方向未决委托锁,防止同轮或相邻轮重复提交。 +- `OrderBook.data`:本轮的进行中或完成订单快照,提供给 `State.reconcile()`。 +- `State`:跨进程保存持仓补仓层级及最近补仓记录;它不是开仓防重的唯一来源。 + +## 对账和清理规则 + +`State.reconcile()` 在每轮执行以下操作: + +1. 对尚未接管的真实持仓创建状态,记录为已完成底仓。 +2. 以 `local_order_id` 匹配本地订单号;若拆单全部为状态 `56`,将底仓或补仓状态更新为 `OK`,否则保持 `ING`。 +3. 删除不再存在真实持仓的证券状态。 +4. 保存状态文件。 + +这意味着:持仓是状态项是否保留的最终依据;补仓计数是业务状态,不会由当前持仓反推或重置。 + +## 发现的问题 + +### P1:撤销、拒绝和部分成交没有完整回写规则 + +`OrderBook.refresh()` 仅将进行中状态和完成状态 `56` 放入 `OrderBook.data`。撤销、拒绝等订单会被过滤;超时订单发出 `cancel_by_id()` 后也会直接跳过。`State.reconcile()` 因此找不到对应 `added_order_id`,只能保持原有 `added_status=ING`。 + +影响:状态文件可能长期显示“处理中”,而 `added_num` 已经递增。系统目前未定义以下情况是否消耗补仓档位: + +- 委托完全拒绝; +- 撤单且零成交; +- 部分成交后撤单。 + +建议:使对账可获得终态订单,或在订单簿中显式传递终态映射;为上述三种情况定义 `added_status`、`added_qty` 和 `added_num` 的最终规则,并增加回归测试。 + +### P1:下单成功但状态文件保存失败时,重启恢复会丢失补仓层级 + +补仓路径顺序是 `orders.place()` 成功后,再执行 `state.set()` 与 `state.save()`。若保存失败,当前进程仍有订单簿方向锁,但程序重启后状态文件不含本次补仓。`sync_positions()` 只能接管当前持仓,不能从持仓推导历史补仓次数,因此存在重复使用补仓档位的风险。 + +建议:捕获并明确处理 `state.save()` 失败;至少将其作为不可忽略的交易一致性故障告警。若要求重启后绝不重复补仓,需要可恢复的补仓事件记录或可查询的成交历史作为补偿来源。 + +### P2:每轮对账会执行两次状态文件写入 + +`reconcile()` 调用 `sync_positions()`,而 `sync_positions()` 无条件 `save()`;`reconcile()` 结束时又再次 `save()`。这不改变正确性,但每 30 秒至少两次磁盘原子替换。 + +建议:让 `sync_positions()` 返回是否发生变化,由 `reconcile()` 统一进行一次保存。 + +### P2:底仓订单字段与当前开仓流程不一致 + +开仓路径不再创建 `StateItem`,而首次出现真实持仓时由 `sync_positions()` 建立底仓状态。因此新开仓的 `base_order_id` 通常为空,底仓订单状态对账主要只适用于遗留/外部写入的数据。 + +建议:保留该字段前,应明确它是否仍承担审计用途;否则可在后续数据模型整理时移除无效的底仓订单状态分支。 + +## 测试覆盖 + +现有测试覆盖了拆单全成后状态改为 `OK`、补仓档位边界和订单簿方向锁,但没有覆盖: + +- 补仓单撤销、拒绝、部分成交后的状态和次数; +- `state.save()` 在下单成功后失败的恢复处理; +- 启动恢复后的补仓层级保持; +- 状态项随平仓删除的边界。 + +在 `py-client` 目录运行 `python -m compileall -q .` 通过。现有全量单测仍有趋势替身接口不匹配及 IPO 调用契约问题,无法作为状态机全绿的证明。 diff --git a/docs/todo.md b/docs/todo.md index 9b6a228..676bc46 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,5 +1,34 @@ +### 4.1 发出撤单请求后立即丢弃订单和方向锁,未确认撤单成功 + +位置: + +- `py-client/strategy/trend/order.py:63-72` +- `py-client/strategy/trend/order.py:85-88` +- `py-client/sdk/trade.py:75` +- `api/qmt_api_new.py:1001-1021` + +`OrderBook.refresh()` 对过期订单调用 `cancel_by_id()` 后立即 `continue`,因此该订单不会进入新 `data` 和 `lock`。服务端对于“不可撤”或撤单返回 `False` 的情况仍返回 HTTP 200,只在 JSON 中给出 `status=failed`;客户端既不解析该业务状态,订单簿也不复查券商状态。 + +影响:原订单可能仍可成交,但本地方向锁已经释放;同一股票、同一方向可以再次下单,形成重复仓位或超额卖出风险。撤单已受理但尚未确认时也存在相同窗口。 + +建议:撤单请求后保留订单和锁,直到下一次券商快照确认订单进入终态;客户端应将 `status=failed` 转成明确业务失败。为“不可撤”“撤单返回失败”“撤单请求异常”“撤单已受理但状态未更新”分别增加测试。 + +### 4.2 API 认证令牌硬编码、提交到仓库并写入日志 + +位置: + +- `api/qmt_api_new.py:14,50,1526,1529` +- `api/qmt_api_rele.py:14,50,1548,1551` +- `py-client/etc/_global.yaml:2` + +两份服务端文件包含相同的固定令牌,客户端配置也把该令牌提交到版本库。服务监听 `0.0.0.0`,启动时还会把完整令牌写入日志。 + +影响:任何能读取仓库或日志的人都可获得交易 API 凭据;在端口可达的网络范围内,可调用下单、撤单和关闭服务等接口。令牌已进入版本历史时,仅删除当前文本并不能完成处置。 + +建议:立即轮换现有令牌;从环境变量或受控密钥存储读取;配置库只保留占位符;停止记录令牌;默认绑定回环地址,确需远程访问时增加网络访问控制和 TLS。检查 Git 历史及已分发日志中的泄露范围。 + ### 4.2 IPO 没有校验真实交易日,只判断周一至周五和盘中时间 位置: diff --git a/docs/zt.md b/docs/zt.md new file mode 100644 index 0000000..6e82dd9 --- /dev/null +++ b/docs/zt.md @@ -0,0 +1,17 @@ +# ZT 日内做 T 策略 + +启用时在账户 YAML 中设置: + +```yaml +strategy: zt +signal_allow: ["dcm"] +zt_sell_ratio: 0.5 +zt_buy_fall_pct: 1.0 +zt_max_price: 200 +``` + +- 仅 `dcm` 信号可建立底仓;建仓使用反弹确认,跳过价格高于 `zt_max_price` 的股票。 +- 对 dcm 底仓,盈利网格出现回撤时卖出 `zt_sell_ratio` 对应的可用整手;不卖出超过记录底仓的数量。 +- 卖单全部成交后,价格较卖出价回落 `zt_buy_fall_pct`,并经反弹确认,买回同等数量。 +- 每只股票每日只做一轮;14:50 后不再开新卖单,已卖未买的仓位强制按市价买回,避免隔夜净减仓。 +- 仅支持标准 A 股的先卖后买,不把当日新买入股票作为可卖库存。 diff --git a/py-client/__pycache__/main.cpython-311.pyc b/py-client/__pycache__/main.cpython-311.pyc index 8b399cf..b99eac3 100644 Binary files a/py-client/__pycache__/main.cpython-311.pyc and b/py-client/__pycache__/main.cpython-311.pyc differ diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index ea0d05e..43e68f4 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -52,6 +52,9 @@ class AccountConfig: enable_auto_ipo: bool = True signal_allow: list[str] = field(default_factory=list) excluded_codes: list[str] = field(default_factory=list) + zt_sell_ratio: float = 0.5 + zt_buy_fall_pct: float = 1.0 + zt_max_price: float = 200.0 # 当前账户启用的策略名称,例如 trend。 strategy: str = "" @@ -129,12 +132,18 @@ def load( account_config = AccountConfig(**_yaml(root / account_file)) if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0: raise ValueError("buy_value、grid_step_pct 必须大于 0") + if not 0 < account_config.zt_sell_ratio <= 1: + raise ValueError("zt_sell_ratio 必须在 (0, 1] 区间") + if account_config.zt_buy_fall_pct <= 0 or account_config.zt_max_price <= 0: + raise ValueError("zt_buy_fall_pct、zt_max_price 必须大于 0") if not account_config.strategy.strip(): raise ValueError("strategy 不能为空") # host_key 统一为小写,避免不同模块比较时受大小写影响。 account_config.host_key = account_config.host_key.lower() account_config.strategy = account_config.strategy.lower() + if account_config.strategy == "zt" and account_config.signal_allow != ["dcm"]: + raise ValueError("zt 策略的 signal_allow 必须且只能为 [\"dcm\"]") return global_config, account_config diff --git a/py-client/config/__pycache__/__init__.cpython-311.pyc b/py-client/config/__pycache__/__init__.cpython-311.pyc index a2f2a65..309fe19 100644 Binary files a/py-client/config/__pycache__/__init__.cpython-311.pyc and b/py-client/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/main.py b/py-client/main.py index 13f9efe..580eb84 100644 --- a/py-client/main.py +++ b/py-client/main.py @@ -23,6 +23,7 @@ logging.basicConfig( from sdk import APIError, Client from strategy.trend.boot import StartTrend +from strategy.zt.boot import StartZT from strategy.ipo import AutoBuyIpo @dataclass(frozen=True, slots=True) @@ -33,6 +34,7 @@ class StrategyDefinition: STRATEGIES = { "trend": StrategyDefinition("Trend", StartTrend), + "zt": StrategyDefinition("ZT", StartZT), } def require_windows() -> bool: diff --git a/py-client/strategy/ipo/boot.py b/py-client/strategy/ipo/boot.py index c6062e8..7709367 100644 --- a/py-client/strategy/ipo/boot.py +++ b/py-client/strategy/ipo/boot.py @@ -36,6 +36,8 @@ def AutoBuyIpo() -> int: result = client.ipo_data("STOCK") for stock in result: + if ".BJ" in stock: + continue lp = Path(config.global_config.qmt_data_dir)/f"{stock}.lock" if not is_lock(lp): ipo_price = result[stock]['issuePrice'] # 发行价 diff --git a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc index 400ac1a..1aa5c8f 100644 Binary files a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc index 029722e..d2e6070 100644 Binary files a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc index d1bf66a..18fed44 100644 Binary files a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc index 1161cf6..a9499a4 100644 Binary files a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc differ diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py index fc42ffe..529cbe0 100644 --- a/py-client/strategy/trend/boot.py +++ b/py-client/strategy/trend/boot.py @@ -10,7 +10,9 @@ import time from datetime import datetime import config -from libs import init_signals, market_allow_open, trading_time +from libs.calc import trading_time +from libs.market import market_allow_open +from libs.signal import init_signals, SignalItem from sdk import Client from libs.grid_take_profit import GridTrailingTracker from .state import State @@ -113,16 +115,18 @@ def StartTrend() -> None: time.sleep(max(0.0, 30.0 - elapsed)) -def RunOnce(run: Runtime, signals) -> None: +def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: """按固定步骤执行一轮趋势策略, ``RunOnce``。""" if not trading_time(datetime.now()): return - # 1. 取消超过有效期仍未完成的委托订单。 + # 1. 刷新订单数据,清理过期订单。 try: run.orders.refresh(run.client) except Exception: logging.exception("取消过期订单失败") + return + # 2. 验证可用资金;低于资金安全线时禁止开新仓。 try: @@ -144,37 +148,25 @@ def RunOnce(run: Runtime, signals) -> None: logging.exception("获取持仓失败") return - # 每轮使用最新委托和成交恢复状态。查询或落盘失败时禁止继续开仓, - # 避免在订单结果不明确的情况下提交重复买单。 - previous_state_codes = set(run.state.codes) + # 5. 更新状态机 try: - broker_orders = run.client.trade_detail_data("order") - run.state.reconcile(positions, broker_orders) + run.state.reconcile(positions, run.orders.data) except Exception: logging.exception("订单状态对账失败,本轮禁止自动交易") return - removed_codes = previous_state_codes - set(run.state.codes) - for code in removed_codes: - run.open_watch.forget(code) - run.add_watch.forget(code) - - # 5. 验证有效开仓信号:排除已有持仓和未决订单。 - position_code_set = set(position_codes) - allow_open = [] - seen_codes = position_code_set | set(run.state.unresolved_codes) + # 6. 验证有效开仓信号:排除已有持仓和未决订单。 + allow_open: list[SignalItem] = [] + allow_codes: list[str] = [] for signal in signals: - if signal.code not in seen_codes: + if signal.code not in position_codes: allow_open.append(signal) - seen_codes.add(signal.code) + allow_codes.append(signal.code) # 6. 获取持仓和待开仓证券的实时行情 tick。 - all_codes = list(position_codes) - all_codes.extend( - signal.code for signal in allow_open if signal.code not in position_code_set - ) + all_codes = allow_codes + position_codes try: - ticks = run.client.full_tick(list(dict.fromkeys(all_codes))) + ticks = run.client.full_tick(all_codes) except Exception: logging.exception("获取行情失败") return diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index 973d0f3..bc19391 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -7,18 +7,14 @@ from datetime import datetime from libs import calc_buy_volume from sdk import OP_BUY - +from .runtime import Runtime from .order import PlaceOrderRequest from .state import STATUS_ING, StateItem -def open_signal(run, ticks, open_signals) -> None: +def open_signal(run:Runtime, ticks, open_signals) -> None: """逐个验证开仓信号并提交买入委托。""" for item in open_signals: - # 候选生成后状态仍可能发生变化,提交前再次阻止未决订单重复开仓。 - if run.state.has_unresolved_order(item.code): - continue - # 1. 验证信号配置允许开仓的时间区间。 signal_config = run.global_cfg.signals.get(item.signal_key) if signal_config is None or not check_timezone(signal_config.timezone): @@ -34,44 +30,44 @@ def open_signal(run, ticks, open_signals) -> None: if price <= 0: continue - # 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。 - if not run.open_watch.triggered("开仓", item.code, price): - continue - # 5. 根据单笔买入金额计算整手开仓数量。 volume = calc_buy_volume(price, run.account_cfg.buy_value) if volume <= 0: continue - # 6. 生成本地订单号并按最新价提交开仓委托。 - order_id = run.orders.new_order_id("base") - request = PlaceOrderRequest( - run.client, - OP_BUY, - item.code, - volume, - order_id, - item.signal_key, - ) - if not run.orders.place(request): + # 当前价高于昨收价可开仓 + if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close: + try: + do_open(run,item.code,volume,item.signal_key) + logging.info("[开仓] %s 买入 %d 股", item.code, volume) + except Exception as e: + logging.exception("[开仓] %s 失败: %s", item.code,e) + continue + + # 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。 + if not run.open_watch.triggered("开仓", item.code, price): continue - # 7. 保存底仓订单、数量、成本和处理中状态。 - run.state.set( - StateItem( - code=item.code, - base_order_id=order_id, - base_qty=volume, - base_cost=price, - base_status=STATUS_ING, - ) - ) try: - run.state.save() - except OSError: - logging.exception("[状态] %s 开仓状态保存失败", item.code) - run.open_watch.forget(item.code) - logging.info("[ZT][开仓] %s 买入 %d 股", item.code, volume) + do_open(run,item.code,volume,item.signal_key) + logging.info("[开仓] %s 买入 %d 股", item.code, volume) + except Exception as e: + logging.exception("[开仓] %s 失败: %s", item.code,e) + + +def do_open(run:Runtime,code:str,volume:int,signal_key:str)->None: + """生成本地订单号并按最新价提交开仓委托。""" + order_id = run.orders.new_order_id("base") + request = PlaceOrderRequest( + run.client, + OP_BUY, + code, + volume, + order_id, + signal_key, + ) + if not run.orders.place(request): + raise RuntimeError("订单提交失败") def check_timezone(timezone: str, now: datetime | None = None) -> bool: diff --git a/py-client/strategy/zt/__init__.py b/py-client/strategy/zt/__init__.py new file mode 100644 index 0000000..1eea586 --- /dev/null +++ b/py-client/strategy/zt/__init__.py @@ -0,0 +1,5 @@ +"""日内做 T 策略。""" + +from .boot import StartZT + +__all__ = ["StartZT"] diff --git a/py-client/strategy/zt/__pycache__/__init__.cpython-311.pyc b/py-client/strategy/zt/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..9adba5c Binary files /dev/null and b/py-client/strategy/zt/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/strategy/zt/__pycache__/boot.cpython-311.pyc b/py-client/strategy/zt/__pycache__/boot.cpython-311.pyc new file mode 100644 index 0000000..0fb171f Binary files /dev/null and b/py-client/strategy/zt/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/zt/__pycache__/open.cpython-311.pyc b/py-client/strategy/zt/__pycache__/open.cpython-311.pyc new file mode 100644 index 0000000..0dfea26 Binary files /dev/null and b/py-client/strategy/zt/__pycache__/open.cpython-311.pyc differ diff --git a/py-client/strategy/zt/__pycache__/positions.cpython-311.pyc b/py-client/strategy/zt/__pycache__/positions.cpython-311.pyc new file mode 100644 index 0000000..941d0df Binary files /dev/null and b/py-client/strategy/zt/__pycache__/positions.cpython-311.pyc differ diff --git a/py-client/strategy/zt/__pycache__/runtime.cpython-311.pyc b/py-client/strategy/zt/__pycache__/runtime.cpython-311.pyc new file mode 100644 index 0000000..bcde782 Binary files /dev/null and b/py-client/strategy/zt/__pycache__/runtime.cpython-311.pyc differ diff --git a/py-client/strategy/zt/__pycache__/state.cpython-311.pyc b/py-client/strategy/zt/__pycache__/state.cpython-311.pyc new file mode 100644 index 0000000..ffe2e43 Binary files /dev/null and b/py-client/strategy/zt/__pycache__/state.cpython-311.pyc differ diff --git a/py-client/strategy/zt/boot.py b/py-client/strategy/zt/boot.py new file mode 100644 index 0000000..01d2733 --- /dev/null +++ b/py-client/strategy/zt/boot.py @@ -0,0 +1,75 @@ +"""日内做 T 策略启动器。""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime, time as clock_time + +import config +from libs.calc import trading_time +from libs.grid_take_profit import GridTrailingTracker +from libs.market import market_allow_open +from libs.signal import init_signals +from sdk import Client +from strategy.trend.order import OrderBook +from strategy.trend.watch import DipWatch + +from .open import open_base +from .positions import manage_positions +from .runtime import Runtime +from .state import TState + + +def StartZT() -> None: + client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT) + orders = OrderBook() + orders.refresh(client) + _, positions = client.positions() + state = TState.for_strategy(config.global_config.qmt_data_dir, config.account_config.strategy, config.account_config.account_id) + state.reconcile(positions, orders.data, datetime.now().date().isoformat()) + run = Runtime(client, config.global_config, config.account_config, state, orders, DipWatch(), GridTrailingTracker(config.account_config.grid_step_pct)) + while True: + started = time.monotonic() + try: + RunOnce(run) + except Exception: + logging.exception("ZT 策略本轮失败") + time.sleep(max(0.0, 30.0 - (time.monotonic() - started))) + + +def RunOnce(run: Runtime) -> None: + if not trading_time(datetime.now()): + return + try: + run.orders.refresh(run.client) + assets = run.client.assets() + position_codes, positions = run.client.positions() + except Exception: + logging.exception("[ZT] 刷新账户或订单失败") + return + today = datetime.now().date().isoformat() + try: + run.state.reconcile(positions, run.orders.data, today) + except Exception: + logging.exception("[ZT] 状态对账失败") + return + signals = init_signals(run.global_cfg, run.account_cfg.signal_allow) + candidate_codes = [item.code for item in signals if item.code not in position_codes] + codes = list(dict.fromkeys(position_codes + candidate_codes)) + try: + ticks = run.client.full_tick(codes) + except Exception: + logging.exception("[ZT] 获取行情失败") + return + market_ok = market_allow_open(run.global_cfg.api_host) + if market_ok and assets.available >= assets.total * run.account_cfg.min_cash_ratio: + open_base(run, ticks, signals) + manage_positions( + run, + ticks, + positions, + assets.available, + today, + force_buy_back=datetime.now().time() >= clock_time(14, 50), + ) diff --git a/py-client/strategy/zt/open.py b/py-client/strategy/zt/open.py new file mode 100644 index 0000000..5749f7c --- /dev/null +++ b/py-client/strategy/zt/open.py @@ -0,0 +1,27 @@ +"""使用 dcm 信号建立做 T 底仓。""" + +from __future__ import annotations + +import logging + +from libs.calc import calc_buy_volume +from sdk import OP_BUY +from strategy.trend.order import PlaceOrderRequest + + +def open_base(run, ticks, signals) -> None: + """仅处理 dcm 信号,使用趋势策略同款反弹确认建立底仓。""" + for signal in signals: + if signal.signal_key != "dcm" or run.orders.busy(signal.code, "BUY"): + continue + tick = ticks.get(signal.code) + price = tick.last_price if tick else 0.0 + if price <= 0 or price > run.account_cfg.zt_max_price: + continue + volume = calc_buy_volume(price, run.account_cfg.buy_value) + if volume <= 0 or not run.buy_watch.triggered("ZT 建仓", signal.code, price): + continue + request = PlaceOrderRequest(run.client, OP_BUY, signal.code, volume, run.orders.new_order_id("base"), run.account_cfg.strategy) + if run.orders.place(request): + run.buy_watch.forget(signal.code) + logging.info("[ZT 建仓] %s 买入 %d 股", signal.code, volume) diff --git a/py-client/strategy/zt/positions.py b/py-client/strategy/zt/positions.py new file mode 100644 index 0000000..b6adc4a --- /dev/null +++ b/py-client/strategy/zt/positions.py @@ -0,0 +1,71 @@ +"""日内先卖后买的做 T 规则。""" + +from __future__ import annotations + +import logging + +from libs.grid_take_profit import GridState +from sdk import OP_BUY, OP_SELL, PositionItem +from strategy.trend.order import PlaceOrderRequest + +from .state import BUYING, READY, SELLING, SOLD + + +def manage_positions(run, ticks, positions: list[PositionItem], available: float, today: str, force_buy_back: bool = False) -> None: + for position in positions: + code = position.stock_code + tick = ticks.get(code) + if not code or code in run.account_cfg.excluded_codes or tick is None: + continue + price = tick.last_price + if price <= 0 or price > run.account_cfg.zt_max_price: + continue + try: + state = run.state.get(code) + except KeyError: + continue + if state.phase == READY and not force_buy_back: + _try_sell(run, state, position, price, today) + elif state.phase == SOLD: + _try_buy_back(run, state, price, available, today, force_buy_back) + + +def _try_sell(run, state, position: PositionItem, price: float, today: str) -> None: + if state.base_cost <= 0 or run.orders.busy(state.code, "SELL"): + return + pnl_rate = (price - state.base_cost) / state.base_cost * 100 + observation = run.sell_tracker.observe(f"{run.account_cfg.account_id}:{state.code}", pnl_rate) + if observation.state != GridState.RETREAT: + return + volume = min(position.can_use_volume, int(state.base_qty * run.account_cfg.zt_sell_ratio) // 100 * 100) + if volume <= 0: + return + order_id = run.orders.new_order_id("t-sell") + request = PlaceOrderRequest(run.client, OP_SELL, state.code, volume, order_id, run.account_cfg.strategy) + if not run.orders.place(request): + return + state.trade_date, state.phase = today, SELLING + state.sell_order_id, state.sell_qty, state.sell_price = order_id, volume, price + run.state.set(state) + run.state.save() + logging.info("[ZT 卖出] %s %d 股,网格回撤触发", state.code, volume) + + +def _try_buy_back(run, state, price: float, available: float, today: str, force: bool) -> None: + target = state.sell_price * (1 - run.account_cfg.zt_buy_fall_pct / 100) + if (not force and price > target) or run.orders.busy(state.code, "BUY"): + return + if state.sell_qty <= 0 or price * state.sell_qty > available: + return + if not force and not run.buy_watch.triggered("ZT 买回", state.code, price): + return + order_id = run.orders.new_order_id("t-buy") + request = PlaceOrderRequest(run.client, OP_BUY, state.code, state.sell_qty, order_id, run.account_cfg.strategy) + if not run.orders.place(request): + return + state.trade_date, state.phase, state.buy_order_id = today, BUYING, order_id + run.state.set(state) + run.state.save() + run.buy_watch.forget(state.code) + reason = "尾盘强制买回" if force else f"回撤 {run.account_cfg.zt_buy_fall_pct:.2f}% 后反弹确认" + logging.info("[ZT 买回] %s %d 股,%s", state.code, state.sell_qty, reason) diff --git a/py-client/strategy/zt/runtime.py b/py-client/strategy/zt/runtime.py new file mode 100644 index 0000000..cc86012 --- /dev/null +++ b/py-client/strategy/zt/runtime.py @@ -0,0 +1,22 @@ +"""做 T 策略的运行期依赖。""" + +from dataclasses import dataclass + +from config import AccountConfig, GlobalConfig +from libs.grid_take_profit import GridTrailingTracker +from sdk import Client +from strategy.trend.order import OrderBook +from strategy.trend.watch import DipWatch + +from .state import TState + + +@dataclass(slots=True) +class Runtime: + client: Client + global_cfg: GlobalConfig + account_cfg: AccountConfig + state: TState + orders: OrderBook + buy_watch: DipWatch + sell_tracker: GridTrailingTracker diff --git a/py-client/strategy/zt/state.py b/py-client/strategy/zt/state.py new file mode 100644 index 0000000..84de7b1 --- /dev/null +++ b/py-client/strategy/zt/state.py @@ -0,0 +1,103 @@ +"""做 T 策略的底仓与日内轮次状态。""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from threading import Lock +from typing import Iterable + +from sdk import OrderItem, PositionItem + +READY = "READY" +SELLING = "SELLING" +SOLD = "SOLD" +BUYING = "BUYING" +DONE = "DONE" + + +@dataclass(slots=True) +class TStateItem: + code: str + base_qty: int = 0 + base_cost: float = 0.0 + trade_date: str = "" + phase: str = READY + sell_order_id: str = "" + sell_qty: int = 0 + sell_price: float = 0.0 + buy_order_id: str = "" + + +class TState: + """持久化 dcm 底仓和每只证券每日一次的做 T 进度。""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.lock = Lock() + self.items = self._load() + + @classmethod + def for_strategy(cls, data_dir: str | Path, strategy: str, account_id: str) -> "TState": + return cls(Path(data_dir) / f"{strategy}_{account_id}_state.json") + + def get(self, code: str) -> TStateItem: + with self.lock: + return self.items[code] + + def set(self, item: TStateItem) -> None: + with self.lock: + self.items[item.code] = item + + def reconcile(self, positions: Iterable[PositionItem], orders: list[OrderItem], today: str) -> None: + position_list = [item for item in positions if item.stock_code and item.volume > 0] + position_codes = {item.stock_code for item in position_list} + by_local_id: dict[str, list[OrderItem]] = {} + for order in orders: + if order.local_order_id: + by_local_id.setdefault(order.local_order_id, []).append(order) + + for position in position_list: + if position.stock_code not in self.items and position.open_price > 0: + self.set(TStateItem(position.stock_code, position.volume, position.open_price)) + + for code in list(self.items): + item = self.get(code) + if code not in position_codes: + with self.lock: + self.items.pop(code, None) + continue + if item.trade_date and item.trade_date != today and item.phase in {DONE, READY}: + item.trade_date, item.phase = "", READY + item.sell_order_id = item.buy_order_id = "" + item.sell_qty = 0 + item.sell_price = 0.0 + if item.phase == SELLING and _completed(by_local_id.get(item.sell_order_id)): + item.phase = SOLD + elif item.phase == BUYING and _completed(by_local_id.get(item.buy_order_id)): + item.phase = DONE + self.set(item) + self.save() + + def save(self) -> None: + with self.lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + temporary.write_text(json.dumps({key: asdict(value) for key, value in self.items.items()}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(self.path) + + def _load(self) -> dict[str, TStateItem]: + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"[ZT 状态] 读取失败: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("[ZT 状态] 根节点必须是对象") + return {code: TStateItem(**value) for code, value in raw.items()} + + +def _completed(orders: list[OrderItem] | None) -> bool: + return bool(orders) and all(order.status == "56" for order in orders) diff --git a/py-client/tests/__pycache__/test_zt.cpython-311.pyc b/py-client/tests/__pycache__/test_zt.cpython-311.pyc new file mode 100644 index 0000000..ba3496f Binary files /dev/null and b/py-client/tests/__pycache__/test_zt.cpython-311.pyc differ diff --git a/py-client/tests/test_zt.py b/py-client/tests/test_zt.py new file mode 100644 index 0000000..f6018b0 --- /dev/null +++ b/py-client/tests/test_zt.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from tempfile import TemporaryDirectory + +from sdk import OrderItem, PositionItem +from strategy.zt.state import BUYING, DONE, SELLING, SOLD, TState, TStateItem + + +class ZTStateTests(unittest.TestCase): + def test_reconcile_marks_sell_and_buy_orders_completed(self): + with TemporaryDirectory() as directory: + state = TState.for_strategy(directory, "zt", "A") + state.set(TStateItem("A", 1000, 10, "2026-08-31", SELLING, "sell-1", 500, 11)) + position = PositionItem(stock_code="A", volume=500, open_price=10) + state.reconcile([position], [OrderItem("1", "A", "SELL", "", "56", datetime.now(), 500, "sell-1")], "2026-08-31") + self.assertEqual(state.get("A").phase, SOLD) + + item = state.get("A") + item.phase, item.buy_order_id = BUYING, "buy-1" + state.set(item) + state.reconcile([position], [OrderItem("2", "A", "BUY", "", "56", datetime.now(), 500, "buy-1")], "2026-08-31") + self.assertEqual(state.get("A").phase, DONE) + + def test_new_position_becomes_dcm_base_state(self): + with TemporaryDirectory() as directory: + state = TState.for_strategy(directory, "zt", "A") + state.reconcile([PositionItem(stock_code="A", volume=800, open_price=12.5)], [], "2026-08-31") + item = state.get("A") + self.assertEqual((item.base_qty, item.base_cost), (800, 12.5)) + + +if __name__ == "__main__": + unittest.main()