feat client.
This commit is contained in:
@@ -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 自身的格式化异常。
|
|
||||||
|
|
||||||
@@ -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` 不足一手、多信号总金额超过现金、价格滑点场景。
|
|
||||||
- 新增:同证券同方向两笔活动委托都能被发现和撤销。
|
|
||||||
- 在模拟账户完成至少一次完整的下单、部分成交、撤单、重启对账闭环后,再考虑实盘。
|
|
||||||
@@ -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` 无持仓且无活动委托时仍不重复开仓;最后在模拟账户完成下单、部分成交、撤单、重启对账闭环。
|
|
||||||
@@ -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 委托不重复申购”;最后在模拟账户完成买入、部分成交、撤单、卖出、重启对账闭环。
|
|
||||||
@@ -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` 中的人工排除项没有计入本报告问题数量和整改顺序。
|
|
||||||
99
docs/state.md
Normal file
99
docs/state.md
Normal file
@@ -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 调用契约问题,无法作为状态机全绿的证明。
|
||||||
29
docs/todo.md
29
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 没有校验真实交易日,只判断周一至周五和盘中时间
|
### 4.2 IPO 没有校验真实交易日,只判断周一至周五和盘中时间
|
||||||
|
|
||||||
位置:
|
位置:
|
||||||
|
|||||||
17
docs/zt.md
Normal file
17
docs/zt.md
Normal file
@@ -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 股的先卖后买,不把当日新买入股票作为可卖库存。
|
||||||
Binary file not shown.
@@ -52,6 +52,9 @@ class AccountConfig:
|
|||||||
enable_auto_ipo: bool = True
|
enable_auto_ipo: bool = True
|
||||||
signal_allow: list[str] = field(default_factory=list)
|
signal_allow: list[str] = field(default_factory=list)
|
||||||
excluded_codes: 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。
|
# 当前账户启用的策略名称,例如 trend。
|
||||||
strategy: str = ""
|
strategy: str = ""
|
||||||
@@ -129,12 +132,18 @@ def load(
|
|||||||
account_config = AccountConfig(**_yaml(root / account_file))
|
account_config = AccountConfig(**_yaml(root / account_file))
|
||||||
if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0:
|
if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0:
|
||||||
raise ValueError("buy_value、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():
|
if not account_config.strategy.strip():
|
||||||
raise ValueError("strategy 不能为空")
|
raise ValueError("strategy 不能为空")
|
||||||
|
|
||||||
# host_key 统一为小写,避免不同模块比较时受大小写影响。
|
# host_key 统一为小写,避免不同模块比较时受大小写影响。
|
||||||
account_config.host_key = account_config.host_key.lower()
|
account_config.host_key = account_config.host_key.lower()
|
||||||
account_config.strategy = account_config.strategy.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
|
return global_config, account_config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -23,6 +23,7 @@ logging.basicConfig(
|
|||||||
|
|
||||||
from sdk import APIError, Client
|
from sdk import APIError, Client
|
||||||
from strategy.trend.boot import StartTrend
|
from strategy.trend.boot import StartTrend
|
||||||
|
from strategy.zt.boot import StartZT
|
||||||
from strategy.ipo import AutoBuyIpo
|
from strategy.ipo import AutoBuyIpo
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -33,6 +34,7 @@ class StrategyDefinition:
|
|||||||
|
|
||||||
STRATEGIES = {
|
STRATEGIES = {
|
||||||
"trend": StrategyDefinition("Trend", StartTrend),
|
"trend": StrategyDefinition("Trend", StartTrend),
|
||||||
|
"zt": StrategyDefinition("ZT", StartZT),
|
||||||
}
|
}
|
||||||
|
|
||||||
def require_windows() -> bool:
|
def require_windows() -> bool:
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ def AutoBuyIpo() -> int:
|
|||||||
|
|
||||||
result = client.ipo_data("STOCK")
|
result = client.ipo_data("STOCK")
|
||||||
for stock in result:
|
for stock in result:
|
||||||
|
if ".BJ" in stock:
|
||||||
|
continue
|
||||||
lp = Path(config.global_config.qmt_data_dir)/f"{stock}.lock"
|
lp = Path(config.global_config.qmt_data_dir)/f"{stock}.lock"
|
||||||
if not is_lock(lp):
|
if not is_lock(lp):
|
||||||
ipo_price = result[stock]['issuePrice'] # 发行价
|
ipo_price = result[stock]['issuePrice'] # 发行价
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,7 +10,9 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import config
|
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 sdk import Client
|
||||||
from libs.grid_take_profit import GridTrailingTracker
|
from libs.grid_take_profit import GridTrailingTracker
|
||||||
from .state import State
|
from .state import State
|
||||||
@@ -113,16 +115,18 @@ def StartTrend() -> None:
|
|||||||
time.sleep(max(0.0, 30.0 - elapsed))
|
time.sleep(max(0.0, 30.0 - elapsed))
|
||||||
|
|
||||||
|
|
||||||
def RunOnce(run: Runtime, signals) -> None:
|
def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
|
||||||
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
|
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
|
||||||
if not trading_time(datetime.now()):
|
if not trading_time(datetime.now()):
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. 取消超过有效期仍未完成的委托订单。
|
# 1. 刷新订单数据,清理过期订单。
|
||||||
try:
|
try:
|
||||||
run.orders.refresh(run.client)
|
run.orders.refresh(run.client)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("取消过期订单失败")
|
logging.exception("取消过期订单失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
|
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
|
||||||
try:
|
try:
|
||||||
@@ -144,37 +148,25 @@ def RunOnce(run: Runtime, signals) -> None:
|
|||||||
logging.exception("获取持仓失败")
|
logging.exception("获取持仓失败")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 每轮使用最新委托和成交恢复状态。查询或落盘失败时禁止继续开仓,
|
# 5. 更新状态机
|
||||||
# 避免在订单结果不明确的情况下提交重复买单。
|
|
||||||
previous_state_codes = set(run.state.codes)
|
|
||||||
try:
|
try:
|
||||||
broker_orders = run.client.trade_detail_data("order")
|
run.state.reconcile(positions, run.orders.data)
|
||||||
run.state.reconcile(positions, broker_orders)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("订单状态对账失败,本轮禁止自动交易")
|
logging.exception("订单状态对账失败,本轮禁止自动交易")
|
||||||
return
|
return
|
||||||
|
|
||||||
removed_codes = previous_state_codes - set(run.state.codes)
|
# 6. 验证有效开仓信号:排除已有持仓和未决订单。
|
||||||
for code in removed_codes:
|
allow_open: list[SignalItem] = []
|
||||||
run.open_watch.forget(code)
|
allow_codes: list[str] = []
|
||||||
run.add_watch.forget(code)
|
|
||||||
|
|
||||||
# 5. 验证有效开仓信号:排除已有持仓和未决订单。
|
|
||||||
position_code_set = set(position_codes)
|
|
||||||
allow_open = []
|
|
||||||
seen_codes = position_code_set | set(run.state.unresolved_codes)
|
|
||||||
for signal in signals:
|
for signal in signals:
|
||||||
if signal.code not in seen_codes:
|
if signal.code not in position_codes:
|
||||||
allow_open.append(signal)
|
allow_open.append(signal)
|
||||||
seen_codes.add(signal.code)
|
allow_codes.append(signal.code)
|
||||||
|
|
||||||
# 6. 获取持仓和待开仓证券的实时行情 tick。
|
# 6. 获取持仓和待开仓证券的实时行情 tick。
|
||||||
all_codes = list(position_codes)
|
all_codes = allow_codes + position_codes
|
||||||
all_codes.extend(
|
|
||||||
signal.code for signal in allow_open if signal.code not in position_code_set
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
ticks = run.client.full_tick(list(dict.fromkeys(all_codes)))
|
ticks = run.client.full_tick(all_codes)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("获取行情失败")
|
logging.exception("获取行情失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,18 +7,14 @@ from datetime import datetime
|
|||||||
|
|
||||||
from libs import calc_buy_volume
|
from libs import calc_buy_volume
|
||||||
from sdk import OP_BUY
|
from sdk import OP_BUY
|
||||||
|
from .runtime import Runtime
|
||||||
from .order import PlaceOrderRequest
|
from .order import PlaceOrderRequest
|
||||||
from .state import STATUS_ING, StateItem
|
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:
|
for item in open_signals:
|
||||||
# 候选生成后状态仍可能发生变化,提交前再次阻止未决订单重复开仓。
|
|
||||||
if run.state.has_unresolved_order(item.code):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 1. 验证信号配置允许开仓的时间区间。
|
# 1. 验证信号配置允许开仓的时间区间。
|
||||||
signal_config = run.global_cfg.signals.get(item.signal_key)
|
signal_config = run.global_cfg.signals.get(item.signal_key)
|
||||||
if signal_config is None or not check_timezone(signal_config.timezone):
|
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:
|
if price <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
|
|
||||||
if not run.open_watch.triggered("开仓", item.code, price):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 5. 根据单笔买入金额计算整手开仓数量。
|
# 5. 根据单笔买入金额计算整手开仓数量。
|
||||||
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
||||||
if volume <= 0:
|
if volume <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 6. 生成本地订单号并按最新价提交开仓委托。
|
# 当前价高于昨收价可开仓
|
||||||
order_id = run.orders.new_order_id("base")
|
if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close:
|
||||||
request = PlaceOrderRequest(
|
try:
|
||||||
run.client,
|
do_open(run,item.code,volume,item.signal_key)
|
||||||
OP_BUY,
|
logging.info("[开仓] %s 买入 %d 股", item.code, volume)
|
||||||
item.code,
|
except Exception as e:
|
||||||
volume,
|
logging.exception("[开仓] %s 失败: %s", item.code,e)
|
||||||
order_id,
|
continue
|
||||||
item.signal_key,
|
|
||||||
)
|
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
|
||||||
if not run.orders.place(request):
|
if not run.open_watch.triggered("开仓", item.code, price):
|
||||||
continue
|
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:
|
try:
|
||||||
run.state.save()
|
do_open(run,item.code,volume,item.signal_key)
|
||||||
except OSError:
|
logging.info("[开仓] %s 买入 %d 股", item.code, volume)
|
||||||
logging.exception("[状态] %s 开仓状态保存失败", item.code)
|
except Exception as e:
|
||||||
run.open_watch.forget(item.code)
|
logging.exception("[开仓] %s 失败: %s", item.code,e)
|
||||||
logging.info("[ZT][开仓] %s 买入 %d 股", item.code, volume)
|
|
||||||
|
|
||||||
|
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:
|
def check_timezone(timezone: str, now: datetime | None = None) -> bool:
|
||||||
|
|||||||
5
py-client/strategy/zt/__init__.py
Normal file
5
py-client/strategy/zt/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""日内做 T 策略。"""
|
||||||
|
|
||||||
|
from .boot import StartZT
|
||||||
|
|
||||||
|
__all__ = ["StartZT"]
|
||||||
BIN
py-client/strategy/zt/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/strategy/zt/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/zt/__pycache__/boot.cpython-311.pyc
Normal file
BIN
py-client/strategy/zt/__pycache__/boot.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/zt/__pycache__/open.cpython-311.pyc
Normal file
BIN
py-client/strategy/zt/__pycache__/open.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/zt/__pycache__/positions.cpython-311.pyc
Normal file
BIN
py-client/strategy/zt/__pycache__/positions.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/zt/__pycache__/runtime.cpython-311.pyc
Normal file
BIN
py-client/strategy/zt/__pycache__/runtime.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/zt/__pycache__/state.cpython-311.pyc
Normal file
BIN
py-client/strategy/zt/__pycache__/state.cpython-311.pyc
Normal file
Binary file not shown.
75
py-client/strategy/zt/boot.py
Normal file
75
py-client/strategy/zt/boot.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
27
py-client/strategy/zt/open.py
Normal file
27
py-client/strategy/zt/open.py
Normal file
@@ -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)
|
||||||
71
py-client/strategy/zt/positions.py
Normal file
71
py-client/strategy/zt/positions.py
Normal file
@@ -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)
|
||||||
22
py-client/strategy/zt/runtime.py
Normal file
22
py-client/strategy/zt/runtime.py
Normal file
@@ -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
|
||||||
103
py-client/strategy/zt/state.py
Normal file
103
py-client/strategy/zt/state.py
Normal file
@@ -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)
|
||||||
BIN
py-client/tests/__pycache__/test_zt.cpython-311.pyc
Normal file
BIN
py-client/tests/__pycache__/test_zt.cpython-311.pyc
Normal file
Binary file not shown.
35
py-client/tests/test_zt.py
Normal file
35
py-client/tests/test_zt.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user