This commit is contained in:
2026-09-15 20:02:05 +08:00
parent bfe89ba122
commit 04daeff141
37 changed files with 2674 additions and 1426 deletions

View File

@@ -1,5 +1,13 @@
# IPO、Trend、ZT 策略审计
> **2026-09-15 更新:本文的 ZT 部分已被重构取代。** ZT 改为正T/反T 轮次状态机,
> 持仓数量只以券商快照为准,本地不再重算持仓,因此"数量等式对账"这条路径整体
> 消失。下文 P1-07、P1-09、P2-01 的载体(`libs/state.py` 的 base_/added_ 分桶与
> `strategy/zt/positions.py` 的分支已随模块删除P2-07 所列的旧测试入口也已清理。
> 当前 ZT 设计见 [zt.md](zt.md)。
> **P1-08Trend 超时撤单覆盖手工单与其他策略单)仍未修复**:修复会改变 Trend 的
> 撤单范围ZT 侧已限定为 `zt-` 前缀Trend 侧待单独决定。
复审日期2026-09-13。基线`67b46ca0492cccdda8a41263b727a56df4a637c1` 加本次读取的工作区代码,包含手工修改及未提交文件。保留原文件名以便沿用引用。
范围三个策略及直接相关的委托簿、状态存储、SDK、服务端成交映射、网格和快照模块。本次只更新报告不修改策略、测试或生产数据不连接交易账户。

View File

@@ -1,40 +1,136 @@
# ZT 日内做 T 策略
启用时在账户 YAML 中设置:
正T先买后卖与反T先卖后买共用一套**轮次**状态机。持仓数量、可卖数量、
成本一律以券商快照为准;本地只记录"打算做什么、做到哪一步",不重算持仓。
## 启用
在账户 YAML 中设置:
```yaml
strategy: zt
signal_allow: ["dcm"]
zt_sell_ratio: 0.5
zt_buy_fall_pct: 1.0
zt_max_price: 200
signal_allow: ["dcm"] # zt 只接受 dcm
zt_open_hands: 1 # 建仓与正T买入的手数0 表示不启动策略
zt_sell_ratio: 0.5 # 反T卖出建仓数量的比例
zt_buy_fall_pct: 1.0 # 反T买回较卖出均价回落的百分比
zt_max_price: 200 # 高于此价不新开轮次
zt_t_band_pct: 1.0 # 中性带:建仓价 ±N% 内不动手
zt_max_hold_days: 5 # 单轮最长持有自然日,超期放弃
```
-`dcm` 信号可建立底仓;建仓使用反弹确认,跳过价格高于 `zt_max_price` 的股票。
- 对 dcm 底仓,盈利网格出现回撤时卖出 `zt_sell_ratio` 对应的可用整手;不卖出超过记录底仓的数量。
- 每笔卖出成交直接累计做 T 数量;活动委托结束后,价格较卖出均价回落 `zt_buy_fall_pct`,并经反弹确认,买回实际卖出数量。
- 每只股票每日只做一轮14:50 后不再开新卖单,已卖未买的仓位强制按市价买回,避免隔夜净减仓。
- 仅支持标准 A 股的先卖后买,不把当日新买入股票作为可卖库存。
## 只管自己建的仓
## SQLite 状态存储
**本策略不接管账户已有持仓。** 只有建仓腿由本策略成交、在轮次表里留下
`base_source=opened` 基准的证券才会被管理:
`libs/orderbook.py` 使用标准库 `sqlite3`,数据库路径为
`{qmt_data_dir}/zt_{account_id}_state.db`,每个账户/策略由单个实例串行更新。
启动时仅创建当前表结构和索引,不执行迁移或旧 JSON 导入。
- 账户里其它持仓原样保留,既不写基准、也不做 T日志里以
`[ZT跳过] 未接管持仓 N 只…``[ZT汇总] … 未接管=N …` 体现;
- 建仓只能由 `dcm` 信号触发(`signal_allow`),建仓成交均价即建仓价;
- 升级时如果状态文件里还留着旧版本的"接管"记录,启动会丢弃它们
(已结束的轮次删掉,未平轮次保留到敞口处理完),保证这条规则成立。
两表使用 SDK 同名字段;另有自增主键 `id`,成交表增加从 `remark` 提取的 `order_local_id`
## 两种 T 与 T+1 约束
| 表 | 数据模型 | 索引 |
| | 正T`LONG_T` | 反T`SHORT_T` |
| --- | --- | --- |
| `positions` | `PositionItem``stock_code``stock_name``direction``volume``open_price``open_cost``float_profit``market_value``stock_holder``frozen_volume``can_use_volume``on_road_volume``yesterday_volume``last_price``profit_rate``future_trade_type``expire_date` | `stock_code` 唯一索引 |
| `deals` | `DealItem``stock_code``order_sys_id``ref``order_ref``direction``offset_flag``price``volume``trade_amount``trade_date``trade_time``remark``close_profit`,以及 `order_local_id` | `order_sys_id` 唯一索引;`order_local_id``stock_code``trade_date` |
| 开仓腿 | 买入(低吸) | 卖出(高抛) |
| 平仓腿 | 卖出(高抛) | 买入(低吸) |
| 触发位置 | 现价 ≤ 建仓价×(1`zt_t_band_pct`%) | 现价 ≥ 建仓价×(1+`zt_t_band_pct`%) |
| 买入腿约束 | 可用资金 | — |
| 卖出腿约束 | **底仓可卖数量**`can_use_volume` | 底仓可卖数量 |
`load()` 只更新 `positions``deals``deals_sys_ids` 缓存,无返回值。
`sync_positions(list[PositionItem])` 保存完整持仓快照,同一证券更新时保留自增 ID。
`sync_deals(list[DealItem])` 按系统订单号去重后批量写入;同批重复记录仅写一次
`order_local_id``DealItem.local_order_id``remark` 首段),为空时拒绝写入。
成交日期规范为 `YYYY-MM-DD`,金额缺失时用成交价格乘数量补足。
A股 T+1 决定了正T 能否当天闭环当天买入的份额当天不可卖所以正T 的平仓腿
只能卖底仓原有可用量。卖不动就自然留成隔夜持仓,次日再卖——这正是"允许隔夜"
存在的原因
ZT 使用 `volume/open_price` 保存底仓数量与成本,做 T 轮次从成交历史恢复,
不再使用持仓表的旧状态、底仓订单或补仓字段。买卖方向由 `offset_flag` 计算,
本地订单号从 `remark` 提取。持仓与新增成交在同一事务提交,失败时回滚并恢复内存
## 触发条件
- **正T 开仓**:现价低于中性带下沿,且 `DipWatch` 反弹确认(不接下跌中的飞刀)
- **反T 开仓**:现价高于中性带上沿,且盈利网格出现回撤(`GridTrailingTracker`
`RETREAT`),不追最高点。
- **反T 平仓**:现价 ≤ 卖出均价×(1`zt_buy_fall_pct`%),且反弹确认。
- **正T 平仓**:现价 ≥ 买入均价×(1+`grid_step_pct`%)。
- 平仓腿不受中性带限制正T 开仓受大盘与资金闸门反T 开仓(减仓)不受资金限制。
## 数量
| 腿 | 计划量 | 上限 |
| --- | --- | --- |
| 建仓 / 正T 买入 | `zt_open_hands × 100` | 本轮剩余可用资金,向下取整手 |
| 正T 卖出 | 买入已成交量 | 底仓可卖量,向下取整手 |
| 反T 卖出 | `floor(建仓数量 × zt_sell_ratio / 100) × 100` | 底仓可卖量 |
| 反T 买回 | 卖出已成交量 | 本轮剩余可用资金 |
同一轮 tick 内串行处理所有证券,共享一份剩余资金,不会重复花同一笔钱。
资金买不起一手时返回 0不会强迫成交。
## 轮次与每日限额
状态机:`IDLE → OPENING开仓腿在途→ OPEN待平仓→ CLOSING平仓腿在途→ CLOSED`
- 一只证券任意时刻最多一个未平轮次。
- **每天最多一轮**:当天开过、或有任一条腿成交过,都不再开新轮。
- 允许隔夜:`OPEN` / `CLOSING` 可以跨日持有,不做尾盘强平。
- 超过 `zt_max_hold_days` 自然日仍未平仓 → 告警并把未平敞口**并回底仓数量**
`base_cost` 仍是建仓价),避免在裸敞口上继续开新轮。
## 建底仓与基准成本
- 底仓只能由 `dcm` 信号触发建仓,成交均价写入 `base_cost`
`base_source=opened`);账户已有持仓不会被接管,因此基准只有这一个来源。
- `base_cost` 不随做 T 买卖摊薄;超期放弃时未平敞口并回 `base_qty`
`base_cost` 保持不变。
## 日志
所有日志都带方括号标签,直接 grep 即可定位:
| 标签 | 内容 |
| --- | --- |
| `[ZT启动]` | 启动参数、状态文件路径、未平轮次明细、信号列表 |
| `[ZT成交]` | 逐笔成交入账(腿、数量、价格、编号、累计量、均价);被忽略的非本策略成交 |
| `[ZT状态]` | 轮次阶段流转 |
| `[ZT轮次]` | 一轮结束:结局、买卖均价、价差收益、基准、持有天数、备注 |
| `[ZT决策]` | **每只受管证券每轮一行**:现价、基准、偏离%、类型、阶段、敞口、可卖、持仓成本、最终动作或等待原因 |
| `[ZT下单]` | 实际提交的委托(数量、价格、目标价、订单号) |
| `[ZT跳过]` | 未接管的账户持仓、排除证券、行情无效 |
| `[ZT汇总]` | 本轮账户概览:持仓/管理/新委托数、总资产、可用、大盘与资金闸门、耗时 |
| `[ZT异常]` | 被捕获并降级的错误(含堆栈) |
排查常见问题:
```bash
grep '\[ZT决策\] 600000.SH' 20260915.log # 某只证券每轮为什么没动手
grep '\[ZT下单\]' 20260915.log # 今天实际报出去的委托
grep '\[ZT轮次\]' 20260915.log # 每轮的结果与价差收益
grep '\[ZT跳过\]' 20260915.log | tail -1 # 哪些账户持仓没被接管
grep '\[ZT异常\]' 20260915.log # 被降级的错误
```
## 状态文件
`{qmt_data_dir}/zt_{account_id}_rounds.json`,一只证券一条记录,整文件原子替换。
关键字段:`kind``phase``open_date``base_qty`/`base_cost`/`base_source`
两条腿各自的 `order_id`/`plan_qty`/`filled_qty`/`amount``seen_deal_ids`
`last_trade_date`
- 成交按 `order_sys_id` 幂等累计:重复同步不会重复计入,跨日重启靠
`seen_deal_ids` 记住已成交多少。
- 订单号统一 `zt-` 前缀(`zt-base-` / `zt-entry-` / `zt-exit-`。手工单、IPO 单
和其他策略单不带该前缀,一律不进入本策略轮次。
- 文件损坏时备份为 `.corrupt` 并由券商持仓重建,不阻断启动。
## 委托与容错
- 撤单只限 `zt-` 前缀,超时 300 秒;撤单不会丢轮次状态——余量会让轮次回到
`OPEN`,下一轮按新价重新判断。
- 防重以券商在途委托为唯一依据(同证券同方向有在途委托就不下单)。
- **先落盘意图再发请求**:请求未受理且无成交的轮次会被判为作废,已受理的委托
仍在途、成交照常累计,两种崩溃点都能自愈。
- 单证券异常只跳过该证券;账户快照、轮次推进、行情任一失败只跳过本轮,下轮重试。
## 迁移
旧账本 `zt_{account_id}_state.db``libs/state.py` 已删除,不再读写;旧库保留为
审计记录。不接管账户已有持仓:升级后只有本策略新建立的底仓会进入轮次,
老持仓保持不动。

View File

@@ -40,8 +40,9 @@ py -3.14 -m venv .venv
.venv/Scripts/python.exe -B benchmarks/hotpaths.py
```
25 项离线测试通过,包括原 18 项测试和新增的时间边界、缓存上限、可变订单、信号顺序、原生注解回归测试。
测试使用模拟客户端、临时 SQLite 数据库,不启动真实交易
130 项离线测试通过,覆盖 SDK 与 API 字段契约、委托簿、配置校验、IPO 申购状态机、
ZT 轮次状态机正T/反T、Trend 采集任务与 Python 3.14 回归
测试使用模拟客户端和临时目录,不启动真实交易、不访问真实接口。
同一 CPython 3.14.7、原算法与优化算法对比;每组重复 5 次取中位数:
@@ -61,5 +62,20 @@ py -3.14 -m venv .venv
修改前 18 项测试中 12 项失败,原因是模型仅有 `get_local_order_id` 属性,调用处却使用缺失的 `local_order_id`,存储层还将属性当方法调用。
本次增加同一属性的兼容别名,并统一存储层属性访问,保留原属性名和 API 数据字段;这些是使既有撤单、成交对账测试恢复的接口修复。
审查还发现既有 `strategy/zt/boot.py` 向做 T 的 `manage_positions``open_signal` 提交的参数与函数签名不匹配。
本次未改其调度和资金流程,因此 25 项测试通过不代表该既有做 T 启动路径已可用于实盘。
## ZT 做 T 策略2026-09 重构)
ZT 已从"本地 SQLite 重算持仓 + base/added 分桶归档"改为**正T/反T 轮次状态机**
持仓数量只以券商快照为准,本地只记录"意图与两条腿的状态"。设计说明见
[../docs/zt.md](../docs/zt.md)。
- 新增状态文件 `{qmt_data_dir}/zt_{account_id}_rounds.json`,旧的
`zt_{account_id}_state.db` 不再读写,保留作为审计记录。
- `libs/state.py``strategy/zt/{open,positions,profit}.py` 已删除。
- **不接管账户已有持仓**:只有本策略自己建仓(`base_source=opened`)的证券
才被管理,其它持仓原样保留、不做 T日志以 `[ZT跳过]`/`[ZT汇总] 未接管=N`
体现。升级时会丢弃状态文件里旧版本留下的"接管"记录。
- `zt_sell_ratio``zt_buy_fall_pct``zt_max_price` 现在真正生效;
新增 `zt_t_band_pct`(默认 1.0)与 `zt_max_hold_days`(默认 5
- ZT 日志带 `[ZT启动]/[ZT成交]/[ZT状态]/[ZT轮次]/[ZT决策]/[ZT下单]/[ZT跳过]/
[ZT汇总]/[ZT异常]` 标签,可直接 grep 定位问题;
其中 `[ZT决策]` 每只证券每轮一行,写明最终动作或等待原因。

View File

@@ -1,5 +1,5 @@
import socket
from dataclasses import dataclass, field
from dataclasses import dataclass, field, fields
from pathlib import Path
import yaml
@@ -43,9 +43,7 @@ class AccountConfig:
host_key: str = ""
buy_value: float = 0
min_cash_ratio: float = 0
loss_trigger_pct: float = 0
grid_step_pct: float = 1
min_profit_pct: float = 0
enable_loss_add_position: bool = False
enable_auto_ipo: bool = True
signal_allow: list[str] = field(default_factory=list)
@@ -55,6 +53,10 @@ class AccountConfig:
zt_sell_ratio: float = 0.5
zt_buy_fall_pct: float = 1.0
zt_max_price: float = 200.0
# 正T/反T 中性带:现价在建仓价 ±N% 内不动手,避免来回摩擦。
zt_t_band_pct: float = 1.0
# 单轮最长持有自然日;超期告警并放弃继续平仓,残量留作隔夜持仓。
zt_max_hold_days: int = 5
# 当前账户启用的策略名称,例如 trend。
strategy: str = ""
@@ -129,7 +131,7 @@ def load(
# 策略状态文件写入该目录,启动时提前确保目录存在。
Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True)
account_config = AccountConfig(**_yaml(root / account_file))
account_config = AccountConfig(**_account_values(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 type(account_config.zt_open_hands) is not int or account_config.zt_open_hands < 0:
@@ -138,6 +140,10 @@ def load(
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 account_config.zt_t_band_pct < 0:
raise ValueError("zt_t_band_pct 不能为负数")
if type(account_config.zt_max_hold_days) is not int or account_config.zt_max_hold_days <= 0:
raise ValueError("zt_max_hold_days 必须为正整数")
if not account_config.strategy.strip():
raise ValueError("strategy 不能为空")
@@ -156,3 +162,18 @@ def _yaml(path: Path) -> dict:
return yaml.safe_load(handle) or {}
except (OSError, yaml.YAMLError) as exc:
raise ValueError(f"读取或解析配置 {path} 失败: {exc}") from exc
def _account_values(path: Path) -> dict:
"""读取账户配置,并拒绝拼错或已废弃的字段。
以前未知字段会被 ``AccountConfig(**raw)`` 抛成 TypeError绕开 main()
的异常分支并以裸 traceback 退出;这里改成带文件名的 ValueError。
"""
raw = _yaml(path)
if not isinstance(raw, dict):
raise ValueError(f"账户配置 {path} 的根节点必须是对象")
unknown = sorted(set(raw) - {item.name for item in fields(AccountConfig)})
if unknown:
raise ValueError(f"账户配置 {path} 存在未知字段: {', '.join(unknown)}")
return raw

View File

@@ -1,10 +1,8 @@
account_id: 8886966846
account_id: 8886966846
host_key: cai_cai
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 86037237
account_id: 86037237
host_key: dev
buy_value: 10000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: zt
signal_allow: ["dcm"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8886120710
account_id: 8886120710
host_key: fu_xing
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8889975553
account_id: 8889975553
host_key: hu
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8889399698
account_id: 8889399698
host_key: liao
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8886508526
account_id: 8886508526
host_key: long
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 88017860
account_id: 88017860
host_key: test
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8886225815
account_id: 8886225815
host_key: tong_zhao
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8886441125
account_id: 8886441125
host_key: wen_ting
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8887377770
account_id: 8887377770
host_key: xiao_dong
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8889292292
account_id: 8889292292
host_key: yanweidong
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8891110937
account_id: 8891110937
host_key: yin_fei
buy_value: 10000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 9
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,10 +1,8 @@
account_id: 8889616198
account_id: 8889616198
host_key: zhang
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True

View File

@@ -1,303 +0,0 @@
"""SQLite 策略状态与成交存储;每个数据库仅使用一个写入者,不做数据迁移。"""
import math
import json
import logging as log
import sqlite3
from contextlib import closing
from dataclasses import asdict, dataclass, fields
from datetime import datetime
from pathlib import Path
from sdk import DealItem, PositionItem
FLAG_BUY = 48
FLAG_SELL = 49
UNATTRIBUTED_PREFIX = '__unattributed__:'
SCHEMA = """
-- 策略状态base_ 表示底仓added_ 表示补仓。
CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 状态记录主键
stock_code TEXT NOT NULL, -- 证券代码
status TEXT NOT NULL DEFAULT '', -- 策略状态,由策略定义取值
base_order_local_id TEXT NOT NULL DEFAULT '', -- 底仓本地委托编号
base_qty INTEGER NOT NULL DEFAULT 0 CHECK (base_qty >= 0), -- 底仓数量
base_price REAL NOT NULL DEFAULT 0, -- 底仓价格
base_created_at TEXT NOT NULL DEFAULT '', -- 底仓创建时间
added_order_local_id TEXT NOT NULL DEFAULT '', -- 补仓本地委托编号
added_qty INTEGER NOT NULL DEFAULT 0 CHECK (added_qty >= 0), -- 补仓数量
added_price REAL NOT NULL DEFAULT 0, -- 补仓价格
added_created_at TEXT NOT NULL DEFAULT '' -- 补仓创建时间
);
-- 每个证券仅保留一条策略状态。
CREATE UNIQUE INDEX IF NOT EXISTS idx_state_stock_code ON state (stock_code);
-- 成交记录独立保存,不随状态删除。
CREATE TABLE IF NOT EXISTS deals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_code TEXT NOT NULL,
order_sys_id TEXT NOT NULL CHECK (order_sys_id <> ''),
order_local_id TEXT NOT NULL CHECK (order_local_id <> ''),
ref INTEGER NOT NULL DEFAULT 0,
order_ref TEXT NOT NULL DEFAULT '',
direction INTEGER NOT NULL DEFAULT 0,
offset_flag INTEGER NOT NULL CHECK (offset_flag IN (48, 49)),
price REAL NOT NULL CHECK (price >= 0),
volume INTEGER NOT NULL CHECK (volume > 0),
trade_amount REAL NOT NULL CHECK (trade_amount > 0),
trade_date TEXT NOT NULL,
trade_time TEXT NOT NULL,
remark TEXT NOT NULL DEFAULT '',
close_profit REAL NOT NULL DEFAULT 0,
is_arch INTEGER DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_deals_order_sys_id ON deals (order_sys_id);
CREATE INDEX IF NOT EXISTS idx_deals_order_ref ON deals (order_local_id);
CREATE INDEX IF NOT EXISTS idx_deals_stock_code_date ON deals (stock_code);
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (trade_date);
"""
DELAS_INSERT_SQL = """
INSERT INTO deals (
stock_code, order_sys_id, order_local_id, ref, order_ref,
direction, offset_flag, price, volume, trade_amount,
trade_date, trade_time, remark, close_profit, is_arch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
"""
@dataclass(slots=True)
class StateItem:
"""策略状态字段;同步账户底仓时无法获知的委托编号留空。"""
stock_code: str = '' # 证券代码
status: str = '' # 策略状态
base_order_local_id: str = '' # 底仓本地委托编号
base_qty: int = 0 # 底仓数量
base_price: float = 0.0 # 底仓价格
base_created_at: str = '' # 底仓创建时间
added_order_local_id: str = '' # 补仓本地委托编号
added_qty: int = 0 # 补仓数量
added_price: float = 0.0 # 补仓价格
added_created_at: str = '' # 补仓创建时间
_STATE_COLUMNS = tuple(field.name for field in fields(StateItem))
_UPSERT_STATE = (
f"INSERT INTO state ({', '.join(_STATE_COLUMNS)}) "
f"VALUES ({', '.join(':' + key for key in _STATE_COLUMNS)}) "
"ON CONFLICT(stock_code) DO UPDATE SET "
+ ', '.join(f'{key} = excluded.{key}' for key in _STATE_COLUMNS if key != 'stock_code')
)
class State:
"""单写入者使用的 SQLite 存储;公开缓存仅在事务成功后替换。
sync_state 同步持仓基准sync_deals 保存成交archiving 记入增量。
同证券未归档成交全部为买入且数量等于当前总持仓时,视为已计入
快照,只标记归档;其他成交作为增量处理。本类不做表结构迁移。
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.state: dict[str, dict] = {}
self.deals: dict[str, dict] = {}
self.deals_sys_ids: set[str] = set()
self.blocked_codes: set[str] = set()
self.path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as db:
db.executescript(SCHEMA)
self.load()
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.path, timeout=30)
db.row_factory = sqlite3.Row
return db
@staticmethod
def _read_state(db: sqlite3.Connection) -> dict[str, dict]:
return {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM state')}
@staticmethod
def _read_deals(db: sqlite3.Connection) -> dict[str, dict]:
return {
row['order_sys_id']: dict(row)
for row in db.execute('SELECT * FROM deals ORDER BY id')
}
def get_by_code(self, code: str) -> dict:
"""返回缓存中的状态;不存在时返回空字典。"""
return self.state.get(code, {})
def load(self) -> None:
"""在同一个读事务内加载两张表,全部成功后再发布缓存。"""
with closing(self._connect()) as db, db:
db.execute('BEGIN')
state = self._read_state(db)
deals = self._read_deals(db)
self.state, self.deals, self.deals_sys_ids = state, deals, set(deals)
def load_state(self) -> None:
"""只刷新状态缓存。"""
with closing(self._connect()) as db, db:
db.execute('BEGIN')
state = self._read_state(db)
self.state = state
def load_deals(self) -> None:
"""只刷新成交缓存及其去重编号集合。"""
with closing(self._connect()) as db, db:
db.execute('BEGIN')
deals = self._read_deals(db)
self.deals, self.deals_sys_ids = deals, set(deals)
def sync_deals(self, deals: list[DealItem]) -> None:
"""按 order_sys_id 只追加成交时间超过 30 秒的新记录,保留全部历史记录。"""
now = datetime.now()
new_deals: dict[str, DealItem] = {}
for deal in deals:
if deal.order_sys_id in self.deals_sys_ids:
continue
date = (deal.trade_date or now.date().isoformat()).replace('-', '')
time = deal.trade_time.replace(':', '')
traded_at = datetime.strptime(f'{date} {time}', '%Y%m%d %H%M%S')
if (now - traded_at).total_seconds() <= 30:
continue
new_deals.setdefault(deal.order_sys_id, deal)
if not new_deals:
return
# 插入记录。
with closing(self._connect()) as db, db:
db.execute('BEGIN')
today = now.date().isoformat()
values = []
for deal in new_deals.values():
amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
if not math.isfinite(deal.price) or not math.isfinite(amount):
raise ValueError('Trade price and amount must be finite')
date = deal.trade_date or today
if len(date) == 8 and date.isdigit():
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
values.append((
deal.stock_code, deal.order_sys_id,
deal.get_local_order_id.strip(),
deal.ref, deal.order_ref, deal.direction, deal.offset_flag,
deal.price, deal.volume, amount, date, deal.trade_time,
deal.remark, deal.close_profit,
))
db.executemany(DELAS_INSERT_SQL, values)
cache_deals = self._read_deals(db)
self.deals, self.deals_sys_ids = cache_deals, set(cache_deals)
def sync_state(self, positions: list[PositionItem]) -> None:
"""同步完整持仓:新增底仓、保留已有状态、删除已清仓证券。
此接口不会标记成交。已有库存对应的卖出须先归档,再传入
清仓快照,以免删除归档所需的库存。
"""
created_at = datetime.now().isoformat(sep=' ', timespec='seconds')
holdings = {item.stock_code: item for item in positions if item.volume > 0}
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
existing = {row['stock_code'] for row in db.execute('SELECT stock_code FROM state')}
values = []
for code, item in holdings.items():
if code in existing:
continue
if not math.isfinite(item.open_price):
raise ValueError('Base price must be finite')
values.append((code, item.volume, item.open_price, created_at))
db.executemany(
'DELETE FROM state WHERE stock_code = ?',
[(code,) for code in existing if code not in holdings],
)
db.executemany(
'INSERT INTO state (stock_code, base_qty, base_price, base_created_at) '
'VALUES (?, ?, ?, ?)', values,
)
state = self._read_state(db)
self.state = state
def merge_deals(self) -> dict[str, dict]:
"""按本地委托编号汇总未归档成交,返回新字典,不修改原始记录。
数量、金额和平仓盈亏累加,价格为总金额除以总数量;
其余字段(包括成交编号和时间)保留同组首笔记录的值。
"""
merged: dict[str, dict] = {}
for deal in self.deals.values():
if deal['is_arch'] != 0:
continue
order_local_id = deal['order_local_id']
if order_local_id not in merged:
merged[order_local_id] = deal.copy()
else:
item = merged[order_local_id]
item['stock_code']=deal['stock_code']
item['volume'] += deal['volume']
item['trade_amount'] += deal['trade_amount']
item['close_profit'] += deal['close_profit']
for item in merged.values():
item['price'] = item['trade_amount'] / item['volume']
return merged
def archiving(self) -> None:
"""先合并缓存中的未归档成交再计算;单证券失败回滚并保留重试。"""
merged = self.merge_deals()
if not merged:
return
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
for order_local_id, deal in merged.items():
db.execute('SAVEPOINT archive_stock')
code = deal['stock_code']
try:
result = db.execute('SELECT * FROM state WHERE stock_code = ?', (code,)).fetchone()
state = dict(result) if result else asdict(StateItem(stock_code=code))
if deal['offset_flag'] == FLAG_BUY:
bucket = 'base' if deal['order_local_id'].startswith('zt-base-') else 'added'
state[f'{bucket}_price'] = deal['price']
state[f'{bucket}_qty'] = deal['volume']
state[f'{bucket}_order_local_id'] = deal['order_local_id']
state[f'{bucket}_created_at'] = f"{deal['trade_date']} {deal['trade_time']}".strip()
db.execute(_UPSERT_STATE, state)
elif deal['offset_flag'] == FLAG_SELL:
newState = StateItem(stock_code=code)
qty = deal['volume']
total = state['base_qty'] + state['added_qty']
if qty > total:
raise ValueError(f'Sell volume {qty} exceeds recorded holdings {total}')
elif qty == total:
# 清仓底仓与补仓
newState.status='CLEAR'
elif qty == state['added_qty']:
# 清仓补仓
newState.base_qty = state['base_qty']
newState.base_price = state['base_price']
newState.base_order_local_id = state['base_order_local_id']
newState.base_created_at = state['base_created_at']
elif qty == state['base_qty']:
# 清仓底仓
newState.status='CLEAR'
else:
raise ValueError(f'Sell volume {qty} holdings {total}')
if newState.status == 'CLEAR':
db.execute('DELETE FROM state WHERE stock_code = ?', (code,))
else:
# 保留原记录主键及策略状态,包括同批清仓后重新建仓。
db.execute(_UPSERT_STATE, asdict(newState))
db.execute('UPDATE deals SET is_arch = 1 WHERE order_local_id = ? ''AND is_arch = 0', (order_local_id,))
except (ValueError, sqlite3.IntegrityError) as exc:
db.execute('ROLLBACK TO archive_stock')
log.warning('[归档] %s 失败,保留未归档成交:%s', code, exc)
finally:
db.execute('RELEASE archive_stock')
state = self._read_state(db)
deals = self._read_deals(db)
self.state, self.deals, self.deals_sys_ids = state, deals, set(deals)

View File

@@ -1,29 +1,78 @@
"""ZT 启动与串行调度:成交同步、买回、卖出、建仓。"""
"""ZT 日内做 T正T/反T 一轮状态机,串行执行,允许隔夜。
日志标签(可直接 grep 定位问题):
[ZT启动] 启动参数、状态文件、未平轮次,以及"只管自建仓"的说明
[ZT成交] 成交入账、被忽略的非本策略成交
[ZT状态] 轮次阶段流转
[ZT轮次] 一轮结束(结局、买卖均价、价差收益、持有天数)
[ZT决策] 每只受管证券每轮的价、基准、偏离、敞口、可卖与最终动作
[ZT下单] 实际提交的委托
[ZT跳过] 未接管的账户持仓、排除证券、行情无效
[ZT汇总] 本轮账户与资金概览
[ZT异常] 被捕获并降级的错误
"""
import logging as log
import math
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from concurrent.futures import Future, ThreadPoolExecutor
import config
from libs.calc import trading_time
from .profit import ZTProfitTracker
from libs.grid_take_profit import GridState, GridTrailingTracker
from libs.market import market_allow_open
from libs.order import OrderBook
from libs.order import OrderBook, PlaceOrderRequest
from libs.overview import Overview
from libs.runtime import Runtime
from libs.signal import SignalItem, init_signals
from libs.state import State
from libs.watch import DipWatch
from sdk import Client, DealItem, PositionItem
from .open import open_signal
from .positions import manage_positions
from libs.snapshot import cache_portfolio
from libs.watch import DipWatch
from sdk import OP_BUY, OP_SELL, Client, PositionItem, Tick
from . import rules
from .ownership import owned_deals, owns_local_order_id
from .rounds import (
BASE_SOURCE_OPENED,
KIND_BASE,
KIND_LONG_T,
KIND_SHORT_T,
PHASE_CLOSED,
PHASE_CLOSING,
PHASE_OPEN,
Round,
RoundStore,
RoundStoreError,
advance,
apply_deals,
entry_side,
exit_side,
expire,
in_flight_order_ids,
is_owned_base,
start_round,
touch,
)
TICK_INTERVAL = 30
# 在途委托超过这个时长就撤单重估;撤单不会丢轮次状态,下一轮按新价重新判断。
CANCEL_TIMEOUT_SEC = 300
@dataclass(slots=True)
class Decision:
"""单只证券本轮的处理结果。"""
reserved: float = 0.0 # 本轮为该证券预留的资金(买入腿才有)
reason: str = ""
submitted: bool = False # 本轮是否真的提交了委托(卖出腿不预留资金)
def StartZT() -> None:
if config.account_config.zt_open_hands == 0:
log.info("[ZT] zt_open_hands=0不启动策略")
log.info("[ZT启动] zt_open_hands=0不启动策略")
return
client = Client(
@@ -31,189 +80,446 @@ def StartZT() -> None:
config.global_config.qmt_token,
config.HTTP_TIMEOUT,
)
executor = None
try:
state = State(Path(config.global_config.qmt_data_dir) / f'zt_{config.account_config.account_id}_state.db')
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="zt")
try:
store = _open_store()
except Exception:
# 状态不可用时宁可不启动,也不能让未处理异常杀掉进程。
log.exception("[ZT异常] 轮次状态初始化失败,本次不启动策略")
return
run = Runtime(
client=client, global_cfg=config.global_config, account_cfg=config.account_config,
orders=OrderBook(), open_watch=DipWatch(), add_watch=DipWatch(),
profit_tracker=ZTProfitTracker(config.account_config.grid_step_pct),
executor=executor
client=client,
global_cfg=config.global_config,
account_cfg=config.account_config,
orders=OrderBook(cancel_timeout_sec=CANCEL_TIMEOUT_SEC),
open_watch=DipWatch(),
add_watch=DipWatch(),
profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct),
)
# 获取本策略的信号开仓数据
signals = init_signals(
config.global_config,
config.account_config.signal_allow,
)
initialize = not state.state and not state.deals
deals = client.deals()
portfolio = client.portfolio()
if initialize and {d.order_sys_id: d for d in deals} != {
d.order_sys_id: d for d in client.deals()
}:
raise RuntimeError('ZT 初始化期间成交发生变化,请重新启动')
assets = portfolio.assets
positions = list(portfolio.positions.values())
log.info('[启动] ZT策略已启动账户=%s,信号=%d,持仓=%d',
config.account_config.account_id, len(signals), len(positions))
cache_portfolio(config.account_config.account_id, assets, positions, deals)
_sync_state(state, positions, deals, initialize=initialize)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(client, portfolio.orders, cancel_prefix='zt-')
Overview(assets, positions, config.account_config)
_log_startup(run, store, signals)
DEFAULT_TICK_INTERVAL = 30
while True:
lt = time.localtime()
if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0):
log.info("[ZT] 已到 15:00结束趋势策略")
log.info("[ZT启动] 已到 15:00结束做 T 策略")
return
current_sec = lt.tm_sec
# 计算距离下一个目标时间点0秒或30秒的等待时间
if current_sec < DEFAULT_TICK_INTERVAL:
wait_seconds = DEFAULT_TICK_INTERVAL - current_sec
# 计算距离下一个目标时间点0 秒或 30 秒)的等待时间
if current_sec < TICK_INTERVAL:
wait_seconds = TICK_INTERVAL - current_sec
elif current_sec < 60:
wait_seconds = 60 - current_sec
else:
wait_seconds = DEFAULT_TICK_INTERVAL
wait_seconds = TICK_INTERVAL
# 等待到目标时间点
time.sleep(wait_seconds)
# 单轮失败不能杀死唯一的交易定时线程。
try:
RunOnce(run, state, signals)
except Exception as e:
log.error(
f"[ZT] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True
)
RunOnce(run, store, signals)
except Exception as exc:
log.error("[ZT异常] 本 tick 执行失败,下一 tick 继续: %s", exc,
exc_info=True)
finally:
client.close()
def _log_startup(run: Runtime, store: RoundStore, signals: list[SignalItem]) -> None:
cfg = run.account_cfg
active = [item for item in store.rounds.values() if item.is_active]
log.info("[ZT启动] 账户=%s 状态文件=%s 轮次=%d 活动轮次=%d 信号=%d",
cfg.account_id, store.path, len(store.rounds), len(active), len(signals))
log.info("[ZT启动] 参数 手数=%d 卖出比例=%.2f 买回回落=%.2f%% 中性带=%.2f%% "
"价格上限=%.2f 最长持有=%d天 网格步长=%.2f%% 撤单超时=%d",
cfg.zt_open_hands, cfg.zt_sell_ratio, cfg.zt_buy_fall_pct,
cfg.zt_t_band_pct, cfg.zt_max_price, cfg.zt_max_hold_days,
cfg.grid_step_pct, CANCEL_TIMEOUT_SEC)
log.info("[ZT启动] 资金安全线=%.2f%% 排除证券=%s",
cfg.min_cash_ratio * 100, cfg.excluded_codes or '')
log.info("[ZT启动] 只管理本策略自己建仓的证券;账户已有持仓一律不接管、不做 T")
log.info("[ZT启动] 信号=%s", ', '.join(sorted(s.code for s in signals)) or '')
for code in sorted(store.rounds):
item = store.rounds[code]
if item.is_active:
log.info("[ZT启动] 未平轮次 %s 类型=%s 阶段=%s 敞口=%d 开仓均价=%.3f "
"开仓日=%s 委托=%s", code, item.kind, item.phase,
item.residual_qty, item.entry_avg_price, item.open_date,
item.entry_order_id)
def _open_store() -> RoundStore:
"""加载轮次状态;文件损坏时备份并从券商持仓重建,不阻断启动。"""
path = Path(config.global_config.qmt_data_dir) / (
f'zt_{config.account_config.account_id}_rounds.json')
try:
store = RoundStore(path)
except RoundStoreError:
log.exception("[ZT异常] 轮次状态无法解析,改由券商持仓重建基准")
try:
if executor is not None:
executor.shutdown(wait=True)
finally:
client.close()
path.replace(path.with_name(path.name + '.corrupt'))
log.warning("[ZT异常] 损坏状态已备份为 %s.corrupt", path.name)
except OSError:
log.exception("[ZT异常] 损坏状态备份失败,直接覆盖")
store = RoundStore(path)
_drop_foreign_bases(store)
return store
def _sync_state(state: State, positions: list[PositionItem], deals: list[DealItem],
*, initialize: bool = False) -> None:
"""使用 State 的独立接口同步,交易前核对归档结果与账户持仓。"""
if initialize:
state.sync_state(positions)
state.sync_deals(deals)
state.archiving()
def _drop_foreign_bases(store: RoundStore) -> int:
"""清掉旧版本留下的"接管"基准,保证只管理本策略自己建的仓。
holdings = {p.stock_code: p.volume for p in positions if p.volume > 0}
blocked = {d.stock_code for d in deals if d.order_sys_id not in state.deals_sys_ids}
blocked.update(d['stock_code'] for d in state.deals.values() if d['is_arch'] != 1)
for code in state.state.keys() | holdings.keys():
row = state.get_by_code(code)
if row.get('base_qty', 0) + row.get('added_qty', 0) != holdings.get(code, 0):
blocked.add(code)
state.blocked_codes = blocked
if blocked:
log.warning('[ZT 同步] 状态待核对,暂停交易:%s', ', '.join(sorted(blocked)))
只删除已结束且基准来源不是 ``opened`` 的记录;仍在进行中的轮次保留,
以便把未平敞口处理完。
"""
dropped = []
for code, item in list(store.rounds.items()):
if item.is_active or not item.base_qty:
continue
if item.base_source != BASE_SOURCE_OPENED:
dropped.append(code)
store.drop(code)
if dropped:
log.warning("[ZT启动] 丢弃 %d 条非本策略建仓的旧基准记录(来源=%s%s",
len(dropped), '接管', ', '.join(sorted(dropped)))
return len(dropped)
def RunOnce(run: Runtime, state: State, signals: list[SignalItem]) -> None:
def RunOnce(run: Runtime, store: RoundStore, signals: list[SignalItem]) -> None:
now = datetime.now()
if not trading_time(now):
return
print(
"=" * 40 + f" Ticker {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " + "=" * 40
)
today = now.date().isoformat()
started_at = time.monotonic()
futures: list[tuple[str, Future]] = []
# 1. 账户快照:数量与成本的唯一真相。
try:
deals = run.client.deals()
portfolio = run.client.portfolio()
assets = portfolio.assets
positions = list(portfolio.positions.values())
position_codes = list(portfolio.positions)
cache_portfolio(run.account_cfg.account_id, assets, positions, deals)
_sync_state(state, positions, deals)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
positions = portfolio.positions
cache_portfolio(run.account_cfg.account_id, assets,
list(positions.values()), deals)
except Exception:
log.exception("[Portfolio] 刷新账户快照失败")
log.exception("[ZT异常] 刷新账户快照失败,本轮跳过")
return
# 2. 验证可用资金;低于资金安全线时禁止开新仓
allow_open_by_cash = (
assets.available >= assets.total * run.account_cfg.min_cash_ratio
)
if not allow_open_by_cash:
log.info(
"[Status] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f",
assets.available,
assets.total,
)
# 2. 撤单只限本策略前缀;在途集合是唯一的防重依据
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
in_flight = in_flight_order_ids(run.orders.data)
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓
# 3. 接管基准 + 幂等累计成交 + 推进阶段
owned, ignored = owned_deals(deals)
if ignored:
foreign = sorted({str(deal.get_local_order_id)
for deal in deals
if not owns_local_order_id(deal.get_local_order_id)})
log.warning("[ZT成交] 忽略 %d 笔非本策略成交(本地编号=%s"
"本策略成交 %d", ignored,
', '.join(repr(name) for name in foreign[:10]), len(owned))
try:
_advance_rounds(run, store, positions, owned, in_flight, today)
except Exception:
log.exception("[ZT异常] 轮次推进失败,本轮不交易")
return
# 4. 行情:持仓 有基准的证券 信号候选。
signal_codes = {item.code for item in signals}
managed = _managed_codes(store, positions, signal_codes)
try:
ticks = run.client.full_tick(sorted(managed))
except Exception:
log.exception("[ZT异常] 获取行情失败,代码数量=%d,本轮跳过", len(managed))
return
# 5. 决策:串行执行,开仓与平仓共用同一份剩余资金。
market_ok = market_allow_open()
cash_ok = assets.available >= assets.total * run.account_cfg.min_cash_ratio
remaining = max(0.0, assets.available)
submitted = 0
for code in sorted(managed):
try:
decision = _manage_code(run, store, code, ticks.get(code),
positions.get(code), signal_codes, today,
remaining, market_ok, cash_ok)
except Exception:
log.exception("[ZT异常] %s 处理异常,继续后续证券", code)
continue
_log_decision(store, code, ticks.get(code), positions.get(code), decision)
if decision.submitted:
submitted += 1
if decision.reserved > 0:
remaining = max(0.0, remaining - decision.reserved)
# 4. 验证有效开仓信号:排除已有持仓。
allow_open: list[SignalItem] = []
allow_codes: list[str] = []
for signal in signals:
if signal.code not in portfolio.positions and signal.code not in state.blocked_codes:
allow_open.append(signal)
allow_codes.append(signal.code)
store.save()
_log_summary(run, store, assets, positions, managed, submitted, market_ok,
cash_ok, started_at)
if allow_open and not market_ok:
log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open))
# 5. 获取持仓和待开仓证券的实时行情 tick。
all_codes = list(dict.fromkeys(position_codes + allow_codes))
try:
ticks = run.client.full_tick(all_codes)
except Exception:
log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes))
def _log_decision(store: RoundStore, code: str, tick: Tick | None,
position: PositionItem | None, decision: Decision) -> None:
item = store.get(code)
price = tick.last_price if tick is not None else 0.0
dev = ((price - item.base_cost) / item.base_cost * 100
if item.base_cost > 0 else 0.0)
can_use = int(position.can_use_volume) if position is not None else 0
log.info("[ZT决策] %s 价=%.3f 基准=%.3f(%+.2f%%) 类型=%s 阶段=%s 敞口=%d "
"可卖=%d 持仓成本=%.3f -> %s",
code, price, item.base_cost, dev, item.kind or '-', item.phase,
item.residual_qty, can_use,
float(position.open_price) if position is not None else 0.0,
decision.reason)
def _log_summary(run: Runtime, store: RoundStore, assets, positions: dict,
managed: set[str], submitted: int, market_ok: bool,
cash_ok: bool, started_at: float) -> None:
ignored = sorted(set(positions) - managed)
if ignored:
log.info("[ZT跳过] 未接管持仓 %d 只,不参与做 T本策略只管理自己建仓的"
"证券):%s", len(ignored), ', '.join(ignored))
log.info("[ZT汇总] 持仓=%d 管理=%d 未接管=%d 新委托=%d 总资产=%.2f 可用=%.2f "
"大盘=%s 资金=%s 耗时=%d毫秒",
len(positions), len(managed), len(ignored), submitted, assets.total,
assets.available, '允许' if market_ok else '禁止',
'允许' if cash_ok else '不足',
int((time.monotonic() - started_at) * 1000))
def _managed_codes(store: RoundStore, positions: dict, signal_codes: set[str]) -> set[str]:
"""只管理有自有基准或未平轮次的证券,以及本轮信号候选(用于建仓)。"""
codes = set(signal_codes)
for code, item in store.rounds.items():
if item.is_active or is_owned_base(item):
codes.add(code)
return {code for code in codes if code}
def _advance_rounds(run: Runtime, store: RoundStore, positions: dict,
owned: list, in_flight: set[str], today: str) -> None:
"""累计成交、推进阶段、处理超期,最后统一落盘。
不接管账户已有持仓:没有自有基准的证券不会出现在轮次表里。
"""
for code, item in list(store.rounds.items()):
before_phase = item.phase
for leg, deal in apply_deals(item, owned, today):
filled = item.entry_filled_qty if leg == "entry" else item.exit_filled_qty
average = item.entry_avg_price if leg == "entry" else item.exit_avg_price
log.info("[ZT成交] %s %s腿 +%d股@%.3f 成交编号=%s 累计=%d股 均价=%.4f "
"金额=%.2f", code, '开仓' if leg == 'entry' else '平仓',
deal.volume, deal.price, deal.order_sys_id, filled, average,
item.entry_amount if leg == 'entry' else item.exit_amount)
advance(item, in_flight, today)
if item.phase != before_phase:
_log_transition(code, item, before_phase, today)
if expire(item, today, run.account_cfg.zt_max_hold_days):
log.warning("[ZT轮次] %s 超期放弃:%s;未平敞口已并回底仓,"
"基准数量=%d 建仓价=%.4f", code, item.note,
item.base_qty, item.base_cost)
touch(item)
store.put(item)
def _log_transition(code: str, item: Round, before_phase: str, today: str) -> None:
log.info("[ZT状态] %s %s -> %s 类型=%s 敞口=%d", code, before_phase,
item.phase, item.kind or '-', item.residual_qty)
if before_phase == PHASE_CLOSED or item.phase != PHASE_CLOSED:
return
log.info(
"[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s",
len(positions),
len(allow_open),
market_ok,
allow_open_by_cash,
)
# 启动线程,开始计算
# 7. 持仓计算。
futures.append(
(
"持仓计算",
run.executor.submit(
manage_positions, run, ticks, positions, market_ok, assets.available, state
),
)
)
# 8. 开仓计算:必须同时存在有效信号且大盘允许开仓。
if allow_open and market_ok and allow_open_by_cash:
futures.append(
("开仓计算", run.executor.submit(open_signal, run, ticks, allow_open))
)
# 9. 开始执行
for name, future in futures:
_wait_worker(name, future)
log.info(
"[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000)
)
held = '当日' if item.open_date == today else f'{item.open_date}'
log.info("[ZT轮次] %s 结束(%s) 开=%d股@%.4f 平=%d股@%.4f 价差收益=%.2f "
"基准=%d股@%.4f 持有=%s 备注=%s",
code, item.outcome, item.entry_filled_qty, item.entry_avg_price,
item.exit_filled_qty, item.exit_avg_price, item.realized_amount,
item.base_qty, item.base_cost, held, item.note or '')
def _wait_worker(name: str, future: Future) -> None:
"""保留单轮继续运行的语义,分别记录工作线程异常。"""
try:
future.result()
except Exception:
log.exception("[运行] %s线程失败", name)
def _manage_code(run: Runtime, store: RoundStore, code: str, tick: Tick | None,
position: PositionItem | None, signal_codes: set[str], today: str,
remaining: float, market_ok: bool, cash_ok: bool) -> Decision:
"""处理单只证券,返回预留资金与决策原因。"""
cfg = run.account_cfg
item = store.get(code)
if not item.is_active and item.phase != PHASE_OPEN and item.base_qty <= 0 \
and code not in signal_codes:
return Decision(0.0, "无基准无信号,不参与")
if code in cfg.excluded_codes:
return Decision(0.0, "已配置为排除股票")
price = tick.last_price if tick is not None else 0.0
if not math.isfinite(price) or price <= 0:
log.warning("[ZT跳过] %s 行情无效(价=%r),本轮不动作", code, price)
return Decision(0.0, "行情无效")
can_use = int(position.can_use_volume) if position is not None else 0
if item.phase == PHASE_OPEN:
return _try_exit(run, store, item, price, can_use, remaining)
if not item.can_open(today):
reason = ("今日已有未平轮次" if item.is_active
else "今日已完成一轮,不再开新轮")
return Decision(0.0, reason)
if item.base_qty <= 0:
return _try_open_base(run, store, item, price, can_use, remaining,
today, market_ok, cash_ok)
return _try_entry(run, store, item, price, can_use, remaining,
today, market_ok, cash_ok)
def _try_open_base(run: Runtime, store: RoundStore, item: Round, price: float,
can_use: int, remaining: float, today: str,
market_ok: bool, cash_ok: bool) -> Decision:
"""建底仓:需要信号、大盘与资金同时允许,成交均价即建仓价。"""
cfg = run.account_cfg
if not market_ok:
return Decision(0.0, "建仓跳过:大盘信号不允许")
if not cash_ok:
return Decision(0.0, f"建仓跳过:可用资金低于安全线({cfg.min_cash_ratio:.0%})")
if not rules.price_allowed(price, cfg.zt_max_price):
return Decision(0.0, f"建仓跳过:价格高于上限 {cfg.zt_max_price:.2f}")
volume = rules.entry_volume(KIND_LONG_T, price=price, open_hands=cfg.zt_open_hands,
sell_ratio=cfg.zt_sell_ratio, base_qty=0,
can_use_volume=can_use, available=remaining)
if volume <= 0:
return Decision(0.0, f"建仓跳过:剩余资金 {remaining:.2f} 买不起一手")
if run.orders.busy(item.code, "BUY"):
return Decision(0.0, "建仓跳过:已有买入委托在途")
if not run.open_watch.triggered("建仓", item.code, price):
return Decision(0.0, "建仓等待:尚未确认自低点反弹")
order_id = run.orders.new_order_id("zt", "base")
request = PlaceOrderRequest(OP_BUY, item.code, volume, order_id, cfg.strategy,
kind="base")
# 先落盘意图再发请求:进程在请求前后任一时刻退出,下一轮都能自愈——
# 未受理且无成交的轮次会被判为作废,已受理的委托仍在途,成交照常累计。
start_round(item, KIND_BASE, today)
item.entry_order_id = order_id
item.entry_plan_qty = volume
touch(item)
store.put(item)
store.save()
if not run.orders.place(run.client, request):
return Decision(0.0, f"建仓下单未受理,订单={order_id}(下一轮判为作废)")
run.open_watch.forget(item.code)
log.info("[ZT下单] %s 建仓买入 %d股 @%.3f 预计金额=%.2f 订单=%s",
item.code, volume, price, price * volume, order_id)
return Decision(price * volume, f"建仓已报 {volume}股@{price:.3f}", submitted=True)
def _try_entry(run: Runtime, store: RoundStore, item: Round, price: float,
can_use: int, remaining: float, today: str,
market_ok: bool, cash_ok: bool) -> Decision:
"""在已建立的基准上开一轮正T或反T。"""
cfg = run.account_cfg
if not rules.price_allowed(price, cfg.zt_max_price):
return Decision(0.0, f"跳过:价格高于上限 {cfg.zt_max_price:.2f}")
kind = rules.choose_kind(price, item.base_cost, cfg.zt_t_band_pct)
if kind is None:
return Decision(0.0, f"中性带内不做(±{cfg.zt_t_band_pct:.2f}%")
label = "正T低吸" if kind == KIND_LONG_T else "反T高抛"
# 正T 是加仓需要大盘与资金允许反T 是减仓,不受资金限制。
if kind == KIND_LONG_T and not market_ok:
return Decision(0.0, f"{label}跳过:大盘信号不允许")
if kind == KIND_LONG_T and not cash_ok:
return Decision(0.0, f"{label}跳过:可用资金低于安全线({cfg.min_cash_ratio:.0%})")
if run.orders.busy(item.code, entry_side(kind)):
return Decision(0.0, f"{label}跳过:已有{entry_side(kind)}委托在途")
volume = rules.entry_volume(kind, price=price, open_hands=cfg.zt_open_hands,
sell_ratio=cfg.zt_sell_ratio, base_qty=item.base_qty,
can_use_volume=can_use, available=remaining)
if volume <= 0:
if kind == KIND_SHORT_T:
return Decision(0.0, f"{label}跳过:可卖 {can_use} 股不足一手")
return Decision(0.0, f"{label}跳过:剩余资金 {remaining:.2f} 买不起一手")
if kind == KIND_LONG_T:
if not run.open_watch.triggered("正T低吸", item.code, price):
return Decision(0.0, f"{label}等待:尚未确认自低点反弹")
else:
pnl_rate = (price - item.base_cost) / item.base_cost * 100
observation = run.profit_tracker.observe(
f"{cfg.account_id}:{item.code}:{today}", pnl_rate)
if observation.state != GridState.RETREAT:
return Decision(0.0, f"{label}等待:网格 {observation.state.value}"
f"(峰值格={observation.peak_grid} 当前格="
f"{observation.current_grid}")
order_id = run.orders.new_order_id("zt", "entry")
request = PlaceOrderRequest(_op_of(entry_side(kind)), item.code, volume, order_id,
cfg.strategy, kind=kind)
start_round(item, kind, today)
item.entry_order_id = order_id
item.entry_plan_qty = volume
touch(item)
store.put(item)
store.save()
if not run.orders.place(run.client, request):
return Decision(0.0, f"{label}下单未受理,订单={order_id}(下一轮判为作废)")
if kind == KIND_LONG_T:
run.open_watch.forget(item.code)
log.info("[ZT下单] %s %s %d股 @%.3f 基准=%.4f 订单=%s",
item.code, label, volume, price, item.base_cost, order_id)
if kind == KIND_LONG_T:
return Decision(price * volume, f"{label}已报 {volume}股@{price:.3f}",
submitted=True)
return Decision(0.0, f"{label}已报 {volume}股@{price:.3f}", submitted=True)
def _try_exit(run: Runtime, store: RoundStore, item: Round, price: float,
can_use: int, remaining: float) -> Decision:
"""平掉轮次敞口正T 卖出、反T 买回。"""
cfg = run.account_cfg
label = "正T高抛" if item.kind == KIND_LONG_T else "反T买回"
if run.orders.busy(item.code, item.exit_side):
return Decision(0.0, f"{label}跳过:已有{item.exit_side}委托在途")
volume = rules.exit_volume(item.kind, residual_qty=item.residual_qty, price=price,
can_use_volume=can_use, available=remaining)
if volume <= 0:
if item.kind == KIND_LONG_T:
return Decision(0.0, f"{label}暂不可执行:可卖 {can_use} 股不足一手"
f"T+1 冻结则留待次日)")
return Decision(0.0, f"{label}暂不可执行:剩余资金 {remaining:.2f} 买不起一手")
# 先判价格条件再消费反弹观察DipWatch 触发后会清掉观察点,
# 若在价格没到位时就调用,会把有效观察点浪费掉,导致买回被系统性错过。
target = (item.entry_avg_price * (1 - cfg.zt_buy_fall_pct / 100)
if item.kind == KIND_SHORT_T
else item.entry_avg_price * (1 + cfg.grid_step_pct / 100))
if not rules.exit_triggered(item.kind, price, item.entry_avg_price,
buy_fall_pct=cfg.zt_buy_fall_pct,
profit_step_pct=cfg.grid_step_pct,
rebound_confirmed=True):
return Decision(0.0, f"{label}等待:未达目标价 {target:.3f}"
f"(开仓均价={item.entry_avg_price:.4f}")
if item.kind == KIND_SHORT_T and not run.add_watch.triggered("反T买回", item.code,
price):
return Decision(0.0, f"{label}等待:尚未确认自低点反弹")
order_id = run.orders.new_order_id("zt", "exit")
request = PlaceOrderRequest(_op_of(exit_side(item.kind)), item.code, volume, order_id,
cfg.strategy, kind=item.kind)
item.exit_order_id = order_id
item.exit_plan_qty = volume
item.phase = PHASE_CLOSING
touch(item)
store.put(item)
store.save()
if not run.orders.place(run.client, request):
return Decision(0.0, f"{label}下单未受理,订单={order_id}(下一轮回到待平仓)")
if item.kind == KIND_SHORT_T:
run.add_watch.forget(item.code)
log.info("[ZT下单] %s %s %d股 @%.3f 开仓均价=%.4f 目标价=%.3f 敞口=%d 订单=%s",
item.code, label, volume, price, item.entry_avg_price, target,
item.residual_qty, order_id)
if item.kind == KIND_SHORT_T:
return Decision(price * volume, f"{label}已报 {volume}股@{price:.3f}",
submitted=True)
return Decision(0.0, f"{label}已报 {volume}股@{price:.3f}", submitted=True)
def _op_of(side: str) -> int:
return OP_BUY if side == "BUY" else OP_SELL

View File

@@ -1,128 +0,0 @@
"""趋势策略开仓逻辑。"""
from datetime import datetime
from functools import lru_cache
import math
from sdk import OP_BUY
from libs.runtime import Runtime
from libs.order import PlaceOrderRequest
import logging as log
def open_signal(run: Runtime, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。"""
for item in open_signals:
try:
if not math.isfinite(item.last_close) or item.last_close <= 0:
log.info("[OpenSkip] %s 信号=%s跳过信号无效last_close不是有限正数", item.code, item.signal_key)
continue
if item.code in run.account_cfg.excluded_codes:
log.info("[OpenSkip] %s 信号=%s,跳过:已配置为排除股票", item.code, item.signal_key)
continue
# 1. 验证信号配置允许开仓的时间区间。
signal_config = run.global_cfg.signals.get(item.signal_key)
if signal_config is None:
log.info("[OpenSkip] %s 信号=%s,跳过:未找到信号配置",item.code,item.signal_key)
continue
if not check_timezone(signal_config.timezone):
log.info("[OpenSkip] %s 信号=%s,跳过:不在信号时间段(%s)",item.code,item.signal_key,signal_config.timezone)
continue
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
if run.orders.busy(item.code, "BUY"):
log.info("[OpenSkip] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key)
continue
# 3. 验证行情和最新价格是否有效。
tick = ticks.get(item.code)
price = tick.last_price if tick is not None else 0
if not math.isfinite(price) or price <= 0:
log.info("[OpenSkip] %s 信号=%s,跳过:价格无效", item.code, item.signal_key)
continue
# 5. 按配置的固定手数开仓,每手 100 股。
volume = run.account_cfg.zt_open_hands * 100
if volume <= 0:
log.info("[OpenSkip] %s 信号=%s,跳过:数量无效", item.code, item.signal_key)
continue
# 其它信号,均从观察低点反弹,防止直接接下跌中的“飞刀”。
if not run.open_watch.triggered("开仓", item.code, price):
continue
do_open(run, item.code, volume, item.signal_key, price)
except RuntimeError as exc:
log.exception("[OpenRuntimeError] %s 信号=%s,失败:%s",item.code,item.signal_key,exc)
except Exception as err:
log.exception("[OpenExceptionError] %s 信号=%s,异常:%s",item.code,item.signal_key,err)
continue
def do_open(
run: Runtime, code: str, volume: int, signal_key: str, price: float
) -> None:
"""生成本地订单号并按最新价提交开仓委托。"""
order_id = run.orders.new_order_id("zt","base")
request = PlaceOrderRequest(
OP_BUY,
code,
volume,
order_id,
signal_key,
kind="base",
)
if not run.orders.place(run.client, request):
raise RuntimeError("订单提交失败")
run.open_watch.forget(code)
log.info("[Open] %s 信号=%s,买入=%d股,原因=反弹已确认",code,signal_key,volume)
def check_timezone(timezone: str, now: datetime | None = None) -> bool:
"""验证当前时间是否处于配置区间。
``*`` 表示全天允许;多个区间用逗号分隔,例如
``9:30-10:30,13:30-14:30``。同时支持跨午夜区间。
"""
timezone = str(timezone or "").strip()
if timezone == "*":
return True
current = now or datetime.now()
current_minutes = current.hour * 60 + current.minute
for section in timezone.split(","):
bounds = section.strip().split("-")
if len(bounds) != 2:
continue
start = _parse_minutes(bounds[0])
end = _parse_minutes(bounds[1])
if start is None or end is None:
continue
if start <= end and start <= current_minutes <= end:
return True
if start > end and (current_minutes >= start or current_minutes <= end):
return True
return False
@lru_cache(maxsize=256)
def _parse_minutes(value: str) -> int | None:
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
try:
hour_text, minute_text = value.strip().split(":")
hour, minute = int(hour_text), int(minute_text)
except (TypeError, ValueError):
return None
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
return None
return hour * 60 + minute

View File

@@ -0,0 +1,38 @@
"""ZT 委托与成交的归属判定。
本地订单号由 ``OrderBook.new_order_id`` 生成,形如 ``zt-base-<hex>``、
``zt-added-<hex>``、``zt-SELL-<hex>``。QMT 把提交时传入的 ``userOrderId``
原样写进委托和成交的 ``remark``,客户端的
``OrderItem.local_order_id`` / ``DealItem.local_order_id`` 取 ``remark`` 的
``|`` 前段,因此它们等于当时的本地订单号。
2026-09-15 的生产库 ``zt_86037237_state.db`` 已核实:``remark`` 与
``order_local_id`` 完全相同(``zt-base-8e9da97a42e957408489``),没有
``|策略名`` 后缀。
手工单、IPO 单和其他策略单不带的 ``zt-`` 前缀,属于别人的成交,
绝不能进入本策略账本。
"""
# ZT 本地订单号前缀,与 `OrderBook.new_order_id("zt", ...)` 的调用保持一致。
OWNED_PREFIX = "zt-"
def owns_local_order_id(local_order_id: str) -> bool:
"""判断本地订单号是否属于 ZT 策略。
取严格前缀匹配:本地订单号始终是 ``zt-<角色>-<随机>``。
若上游改动了备注契约,这里会整体判定为"非本策略"
``_sync_state`` 会逐轮打印忽略数量,便于立刻发现。
"""
return str(local_order_id or "").strip().startswith(OWNED_PREFIX)
def owned_deals(deals: list) -> tuple[list, int]:
"""把成交分成"本策略""非本策略"两组。
Returns:
(归属本策略的成交列表, 被忽略的成交笔数)
"""
owned = [deal for deal in deals if owns_local_order_id(deal.get_local_order_id)]
return owned, len(deals) - len(owned)

View File

@@ -1,225 +0,0 @@
"""趋势策略持仓止盈与分级补仓。"""
from dataclasses import dataclass
import math
from libs.calc import calculate_min_profit_rate
from libs.grid_take_profit import GridState
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
from libs.state import State
from libs.order import PlaceOrderRequest
from libs.runtime import Runtime
import logging as log
LOSS_TIERS = -10.0
@dataclass(slots=True)
class TradeDecision:
"""一次止盈或补仓判断的统一结果。"""
submitted: bool
message: str = ""
reserved_cash: float = 0.0
def manage_positions(
runtime: Runtime,
ticks: dict[str, Tick],
positions: list[PositionItem],
market_ok: bool,
available: float,
state:State,
) -> None:
# 遍历处理每个持仓
for position in positions:
try:
available = max(0, available)
code = position.stock_code
tick = ticks.get(code)
if code in runtime.account_cfg.excluded_codes:
log.info(
"[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票",
code,
position.stock_name,
)
continue
if (
not code
or position.volume <= 0
or tick is None
or not math.isfinite(tick.last_price)
or tick.last_price <= 0
):
log.warning(
"[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效",
code or "未知",
position.stock_name,
)
continue
if code in state.blocked_codes:
log.warning('[Position] %s 状态待核对,暂停该证券交易', code)
continue
posState = state.get_by_code(position.stock_code)
if not posState:
continue
target_qty = posState.get('base_qty', 0)
cost_price = position.open_price
if posState.get('added_qty', 0) > 0:
target_qty = posState['added_qty']
cost_price = posState.get('added_price',0)
# 在途股份不影响已有可卖库存;补仓、底仓均受柜台可卖上限约束。
volume = max(0, min(target_qty, position.can_use_volume, position.volume))
if not math.isfinite(cost_price) or cost_price <= 0:
log.warning('[Position] %s 成本无效,暂停该证券交易', code)
continue
pnl_rate = round(
(tick.last_price - cost_price) / cost_price * 100,
2,
)
minimum_profit = calculate_min_profit_rate(cost_price, 1)
profit_decision = handle_profit(
runtime=runtime,
stock_code=position.stock_code,
volume=volume,
tick=tick,
pnl_rate=pnl_rate,
minimum_profit=minimum_profit,
)
profit_action = profit_decision.message or "未触发"
loss_add_action = "未启用"
if runtime.account_cfg.enable_loss_add_position and market_ok:
loss_decision = handle_loss(
runtime=runtime,
stock_code=position.stock_code,
volume=volume,
tick=tick,
pnl_rate=pnl_rate,
available=available,
)
available = available - loss_decision.reserved_cash
loss_add_action = loss_decision.message or "未触发"
elif runtime.account_cfg.enable_loss_add_position:
loss_add_action = "大盘信号不允许"
strTag = "-"
if pnl_rate >= minimum_profit:
strTag = ""
elif pnl_rate< LOSS_TIERS:
strTag = ""
if strTag != "-":
log.info(
"[Position %s ] %s %s,盈亏=%.2f%%,止盈=%s,补仓=%s",
strTag,
code,
position.stock_name,
pnl_rate,
profit_action,
loss_add_action,
)
except Exception:
log.exception(
"[Position] 持仓处理异常,代码=%s,继续处理后续持仓",
position.stock_code,
)
def handle_profit(
runtime: Runtime,
stock_code: str,
volume:int,
tick: Tick,
pnl_rate: float,
minimum_profit: float,
) -> TradeDecision:
"""基于跨轮保存的最高盈利网格判断是否提交止盈。"""
if pnl_rate < minimum_profit:
return TradeDecision(False)
key = _position_key(runtime, stock_code)
observation = runtime.profit_tracker.observe(key, pnl_rate)
if observation.state == GridState.ARMED:
return TradeDecision(
False,
f"首次, PNL:{pnl_rate:.2f}%,网格={observation.current_grid}",
)
if observation.state == GridState.RAISED:
return TradeDecision(
False,
f"突破, PNL:{pnl_rate:.2f}%,网格={observation.current_grid}",
)
if observation.state == GridState.STEADY:
return TradeDecision(False,f"持平, PNL:{pnl_rate:.2f}%,网格={observation.current_grid}",)
if runtime.orders.busy(stock_code, "SELL"):
return TradeDecision(False, "卖出委托处理中")
if volume <= 0:
return TradeDecision(False, "无可用持仓")
order_id = runtime.orders.new_order_id("zt","SELL")
request = PlaceOrderRequest(
op=OP_SELL,
code=stock_code,
volume=volume,
order_id=order_id,
strategy_name=runtime.account_cfg.strategy,
)
if not runtime.orders.place(runtime.client, request):
return TradeDecision(False, "止盈委托失败")
return TradeDecision(True, f"[止盈卖出] {volume} 股,订单={order_id}")
def handle_loss(
runtime: Runtime,
stock_code: str,
volume:int,
tick: Tick,
pnl_rate: float,
available: float,
) -> TradeDecision:
"""按亏损档位、反弹确认和本轮剩余预算提交补仓。"""
if pnl_rate > LOSS_TIERS:
return TradeDecision(False)
if not runtime.add_watch.triggered("补仓", stock_code, tick.last_price):
return TradeDecision(False, "等待价格反弹确认")
if runtime.orders.busy(stock_code, "BUY"):
return TradeDecision(False, "买入委托处理中")
volume = runtime.account_cfg.zt_open_hands * 100
amount = tick.last_price * volume
if volume <= 0 or amount > available:
return TradeDecision(False, "本轮可用资金不足")
order_id = runtime.orders.new_order_id("zt","added")
request = PlaceOrderRequest(
op=OP_BUY,
code=stock_code,
volume=volume,
order_id=order_id,
strategy_name=runtime.account_cfg.strategy,
kind="add",
)
if not runtime.orders.place(runtime.client, request):
return TradeDecision(False, "补仓订单委托失败")
runtime.add_watch.forget(stock_code)
return TradeDecision(True, f"[补仓买入] {volume} 股,订单={order_id}", amount)
def _position_key(runtime: Runtime, code: str) -> str:
# tracker 为该账户的 ZT Runtime 独享,与 sync_positions 使用同一个键。
return code
def get_add_num(hands: int, market_value: float) -> int:
if market_value > 10000:
return -1
if hands < 2:
return 0
return -1

View File

@@ -1,31 +0,0 @@
"""ZT 按已同步的仓位及实际成本管理止盈峰值。"""
from libs.grid_take_profit import GridTrailingTracker
class ZTProfitTracker(GridTrailingTracker):
def __init__(self, step: float = 1.0):
super().__init__(step)
self._bases: dict[str, tuple] = {}
def sync_positions(self, positions, state) -> None:
# 与交易线程串行执行;提交委托本身不会改变这里的基准。
current = {}
for position in positions:
code = position.stock_code
row = state.get_by_code(code)
if position.volume <= 0 or not row:
continue
bucket = 'added' if row.get('added_qty', 0) > 0 else 'base'
cost = row.get('added_price', 0) if bucket == 'added' else position.open_price
current[code] = (bucket, cost, row.get(f'{bucket}_order_local_id', ''),
row.get(f'{bucket}_created_at', ''))
for code in self._bases.keys() | current.keys():
if code in state.blocked_codes:
continue
if self._bases.get(code) != current.get(code):
self.clear(code)
if code in current:
self._bases[code] = current[code]
else:
self._bases.pop(code, None)

View File

@@ -0,0 +1,379 @@
"""ZT 做 T 轮次状态:一只股票同时最多一轮,允许跨日持有。
正T``LONG_T``与反T``SHORT_T``)共用同一组字段,区别只是两条腿的方向:
正Tentry=BUY exit=SELL 低吸 → 高抛
反Tentry=SELL exit=BUY 高抛 → 低吸
轮次只记录"我打算做什么、做到哪一步",不重算持仓数量:持仓数量永远以
券商 ``positions`` 为准。因此这里没有数量等式,也就没有"数量对不上就冻结"
这条路径;部分成交、分批成交、部分可卖都由 ``entry_filled_qty`` /
``exit_filled_qty`` 自然表达。
成交累计是幂等的:只统计 ``seen_deal_ids`` 里没有的成交编号。QMT 只返回
当日成交,跨日轮次必须靠这份记录才能记住之前已成交多少,所以它必须落盘。
基准只来自本策略自己的建仓成交(``base_source=opened``):程序不接管账户里
已有的持仓,别人的持仓不进轮次、也不参与做 T。
"""
import json
import os
from dataclasses import asdict, dataclass, field, fields
from datetime import date, datetime
from pathlib import Path
from libs.order import BUSY_STATUSES
PHASE_IDLE = "IDLE" # 无活动轮次
PHASE_OPENING = "OPENING" # 开仓腿已提交,等待成交或终态
PHASE_OPEN = "OPEN" # 开仓腿已定局且有余量,等待平仓条件
PHASE_CLOSING = "CLOSING" # 平仓腿已提交
PHASE_CLOSED = "CLOSED" # 本轮结束normal / aborted / expired
KIND_LONG_T = "LONG_T" # 正T先买后卖
KIND_SHORT_T = "SHORT_T" # 反T先卖后买
KIND_BASE = "BASE" # 建底仓:只有买入腿,成交均价即基准成本
ACTIVE_PHASES = (PHASE_OPENING, PHASE_OPEN, PHASE_CLOSING)
_ENTRY_SIDE = {KIND_LONG_T: "BUY", KIND_SHORT_T: "SELL", KIND_BASE: "BUY"}
_EXIT_SIDE = {KIND_LONG_T: "SELL", KIND_SHORT_T: "BUY", KIND_BASE: ""}
OUTCOME_NORMAL = "normal"
OUTCOME_ABORTED = "aborted"
OUTCOME_EXPIRED = "expired"
OUTCOME_BASE = "base"
BASE_SOURCE_OPENED = "opened" # 本策略建仓,成本取实际成交均价
class RoundStoreError(ValueError):
"""轮次状态文件无法解析;调用方据此从券商持仓重建。"""
@dataclass(slots=True)
class Round:
"""单只证券的做 T 轮次记录。"""
code: str = ""
kind: str = ""
phase: str = PHASE_IDLE
open_date: str = "" # 开仓腿提交日;非空且等于今天即视为已用掉当日轮次
close_date: str = ""
outcome: str = ""
# 建仓基准:用户指定用建仓价,不随做 T 买卖摊薄。
base_qty: int = 0
base_cost: float = 0.0
base_date: str = ""
base_source: str = "" # opened / adopted
# 两条腿对称记录便于正T/反T 共用同一套推进逻辑。
entry_order_id: str = ""
entry_plan_qty: int = 0
entry_filled_qty: int = 0
entry_amount: float = 0.0
exit_order_id: str = ""
exit_plan_qty: int = 0
exit_filled_qty: int = 0
exit_amount: float = 0.0
# 已计入的成交编号,保证跨轮重复同步不会重复累加。
seen_deal_ids: list[str] = field(default_factory=list)
# 本股最后一次有腿成交的日期;当天已有成交就不再开新轮。
last_trade_date: str = ""
updated_at: str = ""
note: str = ""
@property
def entry_side(self) -> str:
return _ENTRY_SIDE.get(self.kind, "")
@property
def exit_side(self) -> str:
return _EXIT_SIDE.get(self.kind, "")
@property
def residual_qty(self) -> int:
"""尚未平掉的轮次敞口正T 为待卖反T 为待买回。"""
return self.entry_filled_qty - self.exit_filled_qty
@property
def entry_avg_price(self) -> float:
return self.entry_amount / self.entry_filled_qty if self.entry_filled_qty else 0.0
@property
def exit_avg_price(self) -> float:
return self.exit_amount / self.exit_filled_qty if self.exit_filled_qty else 0.0
@property
def realized_amount(self) -> float:
"""已平部分的价差收益(不含费用),仅用于日志与审计。"""
qty = min(self.entry_filled_qty, self.exit_filled_qty)
if qty <= 0 or self.entry_avg_price <= 0 or self.exit_avg_price <= 0:
return 0.0
if self.kind == KIND_LONG_T:
return (self.exit_avg_price - self.entry_avg_price) * qty
if self.kind == KIND_SHORT_T:
return (self.entry_avg_price - self.exit_avg_price) * qty
return 0.0
@property
def is_active(self) -> bool:
return self.phase in ACTIVE_PHASES
def can_open(self, today: str) -> bool:
"""当日是否还能开新轮。
三个条件缺一不可:没有未平轮次、今天没开过、今天没有腿成交。
最后一条保证"一只股票每天只做一轮"是真正的往返上限:跨日未平的
轮次今天平掉之后,今天也不再开新轮,避免同一天里平旧仓又开新仓。
"""
return (not self.is_active
and self.open_date != today
and self.last_trade_date != today)
_ROUND_FIELDS = {item.name for item in fields(Round)}
def entry_side(kind: str) -> str:
"""该轮次方向的开仓腿买卖方向。"""
return _ENTRY_SIDE.get(kind, "")
def exit_side(kind: str) -> str:
"""该轮次方向的平仓腿买卖方向;建底仓没有平仓腿。"""
return _EXIT_SIDE.get(kind, "")
def in_flight_order_ids(orders: list, *, busy_statuses: set[str] | None = None) -> set[str]:
"""仍可能继续成交的本地订单号集合。
已完成56、已撤54、部撤53、废单57都不在集合内
因此它们一出现就代表对应腿已经定局。
"""
statuses = BUSY_STATUSES if busy_statuses is None else busy_statuses
return {
order.local_order_id
for order in orders
if order.local_order_id and str(order.order_status) in statuses
}
def apply_deals(round: Round, deals: list, today: str) -> list[tuple[str, object]]:
"""把属于本轮两条腿的成交累计进来;同一笔成交只计一次。
去重键是成交编号,不是本地订单号:一个委托拆成多笔成交是常态,
同一本地订单号下可以有多笔成交,各自都要计入。
Returns:
本轮新计入的 ``(腿名, 成交)`` 列表,腿名为 ``entry`` / ``exit``
供调用方逐笔打日志。
"""
applied: list[tuple[str, object]] = []
seen = set(round.seen_deal_ids)
for deal in deals:
local_id = deal.get_local_order_id
if local_id != round.entry_order_id and local_id != round.exit_order_id:
continue
key = deal.order_sys_id or f'{local_id}|{deal.trade_date}|{deal.trade_time}|{deal.volume}'
if key in seen:
continue
if local_id == round.entry_order_id:
round.entry_filled_qty += deal.volume
round.entry_amount += deal.trade_amount
applied.append(("entry", deal))
else:
round.exit_filled_qty += deal.volume
round.exit_amount += deal.trade_amount
applied.append(("exit", deal))
round.seen_deal_ids.append(key)
seen.add(key)
round.last_trade_date = today
return applied
def advance(round: Round, in_flight: set[str], today: str) -> None:
"""按委托是否仍在途推进阶段;只改变本记录,不下单。"""
if round.phase == PHASE_OPENING and round.entry_order_id not in in_flight:
if round.kind == KIND_BASE:
_settle_base(round, today)
elif round.residual_qty > 0:
round.phase = PHASE_OPEN
elif round.residual_qty == 0:
_finish(round, today, OUTCOME_ABORTED, "开仓腿未成交即终态")
else:
_finish(round, today, OUTCOME_ABORTED,
"成交累计异常:平仓量超过开仓量,本轮作废")
elif round.phase == PHASE_CLOSING and round.exit_order_id not in in_flight:
if round.residual_qty > 0:
round.phase = PHASE_OPEN # 平仓腿部分成交或有撤单,余量继续处理
elif round.residual_qty == 0:
_finish(round, today, OUTCOME_NORMAL, "")
else:
_finish(round, today, OUTCOME_NORMAL, "成交累计异常:平仓量超过开仓量")
def _settle_base(round: Round, today: str) -> None:
"""建仓腿定局:以实际成交均价确定基准成本(用户要求用建仓价)。"""
if round.entry_filled_qty <= 0:
_finish(round, today, OUTCOME_ABORTED, "建仓腿未成交即终态")
return
round.base_qty = round.entry_filled_qty
round.base_cost = round.entry_avg_price
round.base_date = round.open_date or today
round.base_source = BASE_SOURCE_OPENED
_finish(round, today, OUTCOME_BASE, "底仓已建立")
def expire(round: Round, today: str, max_hold_days: int) -> bool:
"""轮次持有超过上限则放弃;不强平,残量留作隔夜持仓。"""
if round.phase not in (PHASE_OPEN, PHASE_CLOSING) or not round.open_date:
return False
if _days_between(round.open_date, today) <= max_hold_days:
return False
_finish(round, today, OUTCOME_EXPIRED,
f"持有超过 {max_hold_days} 天,放弃继续平仓")
return True
def _finish(round: Round, today: str, outcome: str, note: str) -> None:
_absorb_residual(round)
round.phase = PHASE_CLOSED
round.close_date = today
round.outcome = outcome
round.exit_plan_qty = 0
if outcome == OUTCOME_ABORTED:
# 没有产生任何持仓的作废轮次不占用当日配额,允许重新判断一次。
round.open_date = ""
if note:
round.note = note
def _absorb_residual(round: Round) -> None:
"""把未平掉的轮次敞口并入底仓数量,成本基准保持建仓价不变。
没有这一步超期放弃的反T 会在"卖出未买回"的敞口上再开一轮,把仓位
越做越偏;并入底仓后基准数量与券商持仓重新对齐,下一轮的下单量才准。
"""
if round.kind == KIND_LONG_T:
round.base_qty = max(0, round.base_qty + round.residual_qty)
elif round.kind == KIND_SHORT_T:
round.base_qty = max(0, round.base_qty - round.residual_qty)
def _days_between(start: str, today: str) -> int:
try:
return (date.fromisoformat(today) - date.fromisoformat(start)).days
except ValueError:
return 0
def start_round(round: Round, kind: str, today: str) -> None:
"""在已有基准上开新一轮,清空上一轮的两条腿与审计字段。
必须走这个入口而不是直接改字段:上一轮的 ``exit_filled_qty`` 若是残留,
``residual_qty`` 会变成负数,``advance`` 会把它当成"作废"并立刻重开一轮。
"""
round.kind = kind
round.phase = PHASE_OPENING
round.open_date = today
round.close_date = ""
round.outcome = ""
round.note = ""
round.entry_order_id = ""
round.entry_plan_qty = 0
round.entry_filled_qty = 0
round.entry_amount = 0.0
round.exit_order_id = ""
round.exit_plan_qty = 0
round.exit_filled_qty = 0
round.exit_amount = 0.0
round.seen_deal_ids = []
def new_round(code: str, kind: str, today: str, base_qty: int, base_cost: float,
base_date: str = "", base_source: str = "") -> Round:
"""构造一条带基准的新轮次记录。"""
record = Round(code=code, base_qty=base_qty, base_cost=base_cost,
base_date=base_date or today, base_source=base_source)
start_round(record, kind, today)
return record
def new_base_round(code: str, today: str, plan_qty: int) -> Round:
"""建底仓:只有买入腿,成交均价随后写入 base_cost。"""
record = Round(code=code)
start_round(record, KIND_BASE, today)
record.entry_plan_qty = plan_qty
return record
def is_owned_base(round: Round) -> bool:
"""基准是否由本策略自己建立。
只有 ``base_source=opened``(建仓腿成交后写入)算自有基准;账户里已有的
持仓不会被接管,因此不会出现别的来源。
"""
return round.base_qty > 0 and round.base_source == BASE_SOURCE_OPENED
def touch(round: Round, now: datetime | None = None) -> None:
round.updated_at = (now or datetime.now()).isoformat(sep=" ", timespec="seconds")
class RoundStore:
"""每账户一个 JSON 文件,整文件原子替换。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.rounds: dict[str, Round] = {}
self.load()
def load(self) -> None:
try:
raw = self.path.read_text(encoding="utf-8")
except FileNotFoundError:
self.rounds = {}
return
except OSError as exc:
raise RoundStoreError(f"读取轮次状态失败: {exc}") from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RoundStoreError(f"解析轮次状态失败: {exc}") from exc
if not isinstance(payload, dict):
raise RoundStoreError("轮次状态根节点必须是对象")
rounds: dict[str, Round] = {}
for code, value in payload.items():
if not isinstance(value, dict):
raise RoundStoreError(f"轮次状态 {code} 必须是对象")
unknown = set(value) - _ROUND_FIELDS
if unknown:
raise RoundStoreError(f"轮次状态 {code} 含未知字段: {sorted(unknown)}")
value["code"] = code
rounds[code] = Round(**value)
self.rounds = rounds
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(self.path.name + ".tmp")
temporary.write_text(
json.dumps({code: asdict(item) for code, item in self.rounds.items()},
ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, self.path)
def get(self, code: str) -> Round:
return self.rounds.get(code) or Round(code=code)
def put(self, round: Round) -> None:
self.rounds[round.code] = round
def drop(self, code: str) -> None:
self.rounds.pop(code, None)

View File

@@ -0,0 +1,90 @@
"""ZT 正T/反T 的触发与数量规则:纯函数,不碰网络、存储和线程。
设计要点:
* 以建仓价 ``base_cost`` 为中枢,中性带 ``zt_t_band_pct`` 内不做任何动作。
现价低于下沿只考虑正T高于上沿只考虑反T —— 同一时刻只可能命中一种方向,
天然满足"一只股票每天只做一轮",不需要额外的冲突仲裁。
* 数量一律再受券商现实约束封顶:卖出封顶 ``can_use_volume``T+1 只在这里
体现买入封顶可用资金。正T 当天买入的份额当天不可卖,因此它的平仓腿
能卖多少完全由 ``can_use_volume`` 决定,卖不动就自然留成隔夜持仓。
"""
from .rounds import KIND_LONG_T, KIND_SHORT_T
LOT = 100
def choose_kind(price: float, base_cost: float, band_pct: float) -> str | None:
"""按现价相对建仓价的位置决定本轮方向;中性带内返回 None。"""
if price <= 0 or base_cost <= 0 or band_pct < 0:
return None
if price <= base_cost * (1 - band_pct / 100):
return KIND_LONG_T
if price >= base_cost * (1 + band_pct / 100):
return KIND_SHORT_T
return None
def price_allowed(price: float, max_price: float) -> bool:
"""高价股不参与做 T。"""
return 0 < price <= max_price
def entry_volume(kind: str, *, price: float, open_hands: int, sell_ratio: float,
base_qty: int, can_use_volume: int, available: float) -> int:
"""开仓腿计划数量0 表示不提交。"""
if price <= 0:
return 0
if kind == KIND_LONG_T:
# 正T 买入:按手数取量,再受可用资金封顶。
affordable = int(max(0.0, available) // (price * LOT)) * LOT
return max(0, min(open_hands * LOT, affordable))
if kind == KIND_SHORT_T:
# 反T 卖出:按建仓数量比例取整手,再受可卖库存封顶。
planned = int(base_qty * sell_ratio) // LOT * LOT
return max(0, min(planned, _whole_lots(can_use_volume)))
return 0
def exit_volume(kind: str, *, residual_qty: int, price: float,
can_use_volume: int, available: float) -> int:
"""平仓腿可提交数量0 表示当前无法平仓T+1 冻结或资金不足)。"""
if residual_qty <= 0 or price <= 0:
return 0
if kind == KIND_LONG_T:
return max(0, min(residual_qty, _whole_lots(can_use_volume)))
if kind == KIND_SHORT_T:
affordable = int(max(0.0, available) // (price * LOT)) * LOT
return max(0, min(residual_qty, affordable))
return 0
def entry_triggered(kind: str, price: float, base_cost: float, *, band_pct: float,
rebound_confirmed: bool, retrace_confirmed: bool) -> bool:
"""开仓腿是否满足触发条件。"""
if choose_kind(price, base_cost, band_pct) != kind:
return False
if kind == KIND_LONG_T:
return rebound_confirmed # 低吸要等反弹确认,不接下跌中的飞刀
return retrace_confirmed # 高抛要等盈利网格回撤,不追最高点
def exit_triggered(kind: str, price: float, entry_avg_price: float, *,
buy_fall_pct: float, profit_step_pct: float,
rebound_confirmed: bool) -> bool:
"""平仓腿是否满足触发条件。"""
if entry_avg_price <= 0:
return False
if kind == KIND_SHORT_T:
# 反T 买回:较卖出均价回落 buy_fall_pct 且已见反弹。
target = entry_avg_price * (1 - buy_fall_pct / 100)
return price <= target and rebound_confirmed
if kind == KIND_LONG_T:
# 正T 卖出:较买入均价上涨一个网格步长。
return price >= entry_avg_price * (1 + profit_step_pct / 100)
return False
def _whole_lots(volume: int) -> int:
return max(0, int(volume)) // LOT * LOT

View File

@@ -1,6 +1,4 @@
import ast
import sqlite3
import tempfile
import unittest
from dataclasses import asdict, fields
from datetime import datetime
@@ -8,10 +6,12 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from libs.order import OrderBook as ActiveOrders
from libs.state import FLAG_BUY, State
from sdk.models import Assets, DealItem, OrderItem, PositionItem
from sdk.portfolio import PortfolioMixin
# QMT 委托/成交的 offset_flag48 买入、49 卖出。
FLAG_BUY = 48
class ApiModelTests(unittest.TestCase):
def setUp(self):
@@ -73,26 +73,6 @@ class ApiModelTests(unittest.TestCase):
client.cancel_by_id.assert_called_once_with('sys1')
self.assertTrue(book.busy('600000.SH', 'BUY'))
def test_storage_and_price_fallback(self):
deal = self.client.deals()[0]
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'state.db'
book = State(path)
book.sync_deals([deal])
loaded = State(path).deals['sys1']
self.assertEqual(loaded['volume'], deal.volume)
self.assertEqual(loaded['trade_date'], '2026-09-07')
deal.order_sys_id = 'sys2'
deal.trade_amount = 0
book.sync_deals([deal, deal])
self.assertEqual(book.deals['sys2']['trade_amount'], 1000)
self.assertEqual(deal.trade_amount, 0)
deal.order_sys_id = 'sys3'
deal.price = 0
with self.assertRaises(sqlite3.IntegrityError):
book.sync_deals([deal])
self.assertEqual(set(State(path).deals), {'sys1', 'sys2'})
if __name__ == '__main__':
unittest.main()

View File

@@ -1,149 +1,85 @@
import sqlite3
import tempfile
"""委托簿:在途状态、方向锁、以及撤单范围。"""
import unittest
from contextlib import closing
from dataclasses import asdict, fields
from pathlib import Path
from unittest.mock import patch
from datetime import datetime, timedelta
from unittest.mock import Mock
from libs.state import FLAG_BUY, FLAG_SELL, State, StateItem
from sdk import DealItem, PositionItem
from libs.order import BUSY_STATUSES, OrderBook, TRACKED_STATUSES
from sdk import OrderItem
class OrderBookTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name) / 'state.db'
def deal(self, kind, sys_order_id, qty, price, date='2026-09-01'):
prefix = {'base': 'zt-base-', 'sell': 'zt-t-sell-', 'buy': 'zt-t-buy-'}[kind]
return DealItem(
order_sys_id=sys_order_id, stock_code='600000.SH',
offset_flag=FLAG_SELL if kind == 'sell' else FLAG_BUY,
volume=qty, price=price, trade_amount=qty * price,
trade_date=date, trade_time='10:00:00', remark=prefix + 'order1|zt',
)
def order(index, remark, status=50, side=23, age_minutes=30):
stamp = datetime.now() - timedelta(minutes=age_minutes)
return OrderItem(stock_code=f'60000{index}.SH', order_sys_id=f'sys{index}',
remark=remark, order_status=status, offset_flag=side,
insert_date=stamp.strftime('%Y%m%d'),
insert_time=stamp.strftime('%H%M%S'))
def test_json_is_never_read(self):
legacy = self.path.with_suffix('.json')
legacy.write_text('invalid JSON', encoding='utf-8')
book = State(self.path)
self.assertIsNone(book.load())
self.assertEqual((book.state, book.deals, book.deals_sys_ids), ({}, {}, set()))
self.assertEqual(legacy.read_text(encoding='utf-8'), 'invalid JSON')
def test_sync_deals_deduplicates_batch_and_restart(self):
book = State(self.path)
self.assertEqual((book.state, book.deals, book.deals_sys_ids), ({}, {}, set()))
first = self.deal('base', 'd1', 40, 10, '20260901')
second = self.deal('base', 'd2', 60, 12)
book.sync_deals([first, first, second])
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
self.assertEqual(book.deals['d1']['trade_date'], '2026-09-01')
self.assertEqual(book.deals['d2']['volume'], 60)
self.assertEqual(book.deals['d2']['order_local_id'], 'zt-base-order1')
book = State(self.path)
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
self.assertEqual(book.deals['d1']['order_local_id'], 'zt-base-order1')
with patch.object(book, '_connect') as connect:
book.sync_deals([first, second])
book.sync_deals([])
connect.assert_not_called()
self.assertEqual(len(book.deals), 2)
def test_sync_deals_failure_rolls_back_entire_batch_and_cache(self):
book = State(self.path)
first = self.deal('base', 'd1', 100, 10)
invalid = self.deal('base', 'd2', 100, 10)
invalid.offset_flag = -1
with self.assertRaises(sqlite3.IntegrityError):
book.sync_deals([first, invalid])
self.assertEqual(book.deals, {})
self.assertEqual(book.deals_sys_ids, set())
self.assertEqual(State(self.path).deals, {})
invalid.offset_flag = FLAG_BUY
book.sync_deals([first, invalid])
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
def test_load_refreshes_all_caches(self):
book = State(self.path)
writer = State(self.path)
writer.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
writer.sync_deals([self.deal('base', 'd1', 100, 10)])
book.load()
self.assertEqual(book.state['600000.SH']['base_qty'], 100)
self.assertEqual(book.deals_sys_ids, {'d1'})
self.assertEqual(book.deals['d1']['remark'], 'zt-base-order1|zt')
def cancelled(client):
return [call.args[0] for call in client.cancel_by_id.call_args_list]
class CancelScopeTests(unittest.TestCase):
"""撤单必须限本策略前缀;防重则继续看全账户在途。"""
def test_zt_prefix_cancels_only_its_own_orders(self):
orders = [order(0, 'zt-base-own'), order(1, 'zt-SELL-own'),
order(2, 'zt-entry-own'), order(3, 'IPO-new'),
order(4, ''), order(5, 'TREN-BUY-other')]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
self.assertEqual(cancelled(client), ['sys0', 'sys1', 'sys2'])
self.assertEqual(book.data, orders) # 撤单后仍保留在途锁
self.assertTrue(all(book.busy(o.stock_code, 'BUY') for o in orders))
def test_default_prefix_still_cancels_every_non_ipo_order(self):
orders = [order(0, 'zt-base-own'), order(1, 'TREN-BUY-other'),
order(2, 'IPO-new'), order(3, '')]
client = Mock()
OrderBook().refresh(client, orders)
self.assertEqual(cancelled(client), ['sys0', 'sys1', 'sys3'])
def test_fresh_and_unreportable_orders_are_never_cancelled(self):
# 48未报在跟踪集合内但不可撤刚提交的委托也不撤。
orders = [order(0, 'zt-entry-fresh', age_minutes=0),
order(1, 'zt-entry-filled', status=56),
order(2, 'zt-entry-unreported', status=48)]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
client.cancel_by_id.assert_not_called()
self.assertEqual(book.data, orders)
def test_position_columns_defaults_indexes_and_stable_id(self):
store = State(self.path)
store.sync_deals([self.deal('base', 'd1', 100, 10)])
saved_deals = dict(store.deals)
with closing(sqlite3.connect(self.path)) as db:
columns = {row[1] for row in db.execute('PRAGMA table_info(state)')}
self.assertEqual(columns, {'id', *(field.name for field in fields(StateItem))})
indexes = {row[1] for row in db.execute('PRAGMA index_list(state)')}
self.assertEqual(indexes, {'idx_state_stock_code'})
position = PositionItem(stock_code='600000.SH', volume=100, open_price=10,
stock_name='stock', can_use_volume=100, float_profit=-2.5)
store.sync_state([position])
saved = store.state[position.stock_code]
first_id = saved['id']
self.assertEqual(saved['base_qty'], 100)
self.assertEqual(saved['base_price'], 10)
self.assertEqual(saved['added_qty'], 0)
self.assertEqual(saved['base_order_local_id'], '')
self.assertTrue(saved['base_created_at'])
position.volume = 200
position.open_price = 12
store.sync_state([position])
self.assertEqual(store.state[position.stock_code]['id'], first_id)
self.assertEqual(store.state[position.stock_code], saved)
self.assertEqual(State(self.path).state[position.stock_code], saved)
store.sync_state([position, PositionItem(stock_code='600001.SH', volume=100)])
self.assertEqual(store.state[position.stock_code], saved)
self.assertEqual(store.state['600001.SH']['base_qty'], 100)
position.volume = 0
store.sync_state([position, PositionItem(stock_code='600002.SH')])
self.assertEqual(store.state, {})
self.assertEqual(State(self.path).state, {})
store.sync_state([PositionItem(stock_code='600001.SH', volume=100)])
self.assertGreater(store.state['600001.SH']['id'], first_id)
store.sync_state([])
self.assertEqual(store.state, {})
self.assertEqual(store.deals, saved_deals)
class BusyLockTests(unittest.TestCase):
def test_only_busy_statuses_lock_a_direction(self):
book = OrderBook()
client = Mock()
book.refresh(client, [order(0, 'zt-entry-a', status=50)], cancel_prefix='zt-')
self.assertTrue(book.busy('600000.SH', 'BUY'))
book.refresh(client, [order(1, 'zt-entry-b', status=56)], cancel_prefix='zt-')
self.assertFalse(book.busy('600001.SH', 'BUY'))
def test_state_fields_survive_restart_and_sync(self):
book = State(self.path)
row = asdict(StateItem(
stock_code='600000.SH', status='READY',
base_order_local_id='base-1', base_qty=100, base_price=10,
base_created_at='2026-09-08T09:30:00',
added_order_local_id='added-1', added_qty=50, added_price=9,
added_created_at='2026-09-08T10:30:00',
))
with closing(book._connect()) as db, db:
db.execute(
f"INSERT INTO state ({', '.join(row)}) VALUES ({', '.join(':' + key for key in row)})",
row,
)
book.load()
saved = book.state[row['stock_code']]
self.assertEqual({k: v for k, v in saved.items() if k != 'id'}, row)
book = State(self.path)
book.sync_state([PositionItem(stock_code=row['stock_code'], volume=150, open_price=9.5)])
self.assertEqual(book.state[row['stock_code']], saved)
with self.assertRaises(sqlite3.IntegrityError):
with closing(book._connect()) as db, db:
db.execute('UPDATE state SET added_qty = -1')
self.assertEqual(State(self.path).state[row['stock_code']], saved)
def test_busy_and_tracked_status_sets_are_disjoint_as_designed(self):
self.assertNotIn('56', BUSY_STATUSES)
self.assertTrue(BUSY_STATUSES <= TRACKED_STATUSES)
self.assertIn('56', TRACKED_STATUSES)
def test_place_marks_the_direction_busy_before_submitting(self):
book = OrderBook()
book.busy_cache.set('BUY-600000.SH', True, timeout=180)
self.assertTrue(book.busy('600000.SH', 'BUY'))
self.assertFalse(book.busy('600000.SH', 'SELL'))
def test_unknown_offset_flag_never_places_an_order(self):
from libs.order import PlaceOrderRequest
book = OrderBook()
client = Mock()
self.assertFalse(book.place(client, PlaceOrderRequest(99, '600000.SH', 100,
'zt-x', 'zt')))
client.passorder.assert_not_called()
if __name__ == '__main__':

View File

@@ -1,75 +0,0 @@
import sqlite3
import tempfile
import unittest
from dataclasses import asdict
from pathlib import Path
from unittest.mock import patch
from libs.state import FLAG_BUY, State
from sdk import DealItem, PositionItem
class StateStorageTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.store = State(Path(tmp.name) / 'state.db')
def deal(self, identity='first'):
return DealItem(
stock_code='600000.SH', order_sys_id=identity,
remark=f'zt-base-{identity}|zt', offset_flag=FLAG_BUY,
volume=100, price=10, trade_amount=1000,
trade_date='20260912', trade_time='100000',
)
def test_load_failure_does_not_publish_partial_cache(self):
writer = State(self.store.path)
writer.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
with patch.object(self.store, '_read_deals', side_effect=sqlite3.OperationalError('read failed')):
with self.assertRaises(sqlite3.OperationalError):
self.store.load()
self.assertEqual((self.store.state, self.store.deals, self.store.deals_sys_ids), ({}, {}, set()))
self.store.load()
self.assertEqual(self.store.state['600000.SH']['base_qty'], 100)
def test_cache_read_failure_rolls_back_archive_and_can_retry(self):
self.store.sync_deals([self.deal()])
with patch.object(self.store, '_read_deals', side_effect=sqlite3.OperationalError('read failed')):
with self.assertRaises(sqlite3.OperationalError):
self.store.archiving()
restarted = State(self.store.path)
self.assertEqual(restarted.state, {})
self.assertEqual(restarted.deals['first']['is_arch'], 0)
self.assertEqual(self.store.deals, restarted.deals)
self.store.archiving()
self.assertEqual(self.store.state['600000.SH']['base_qty'], 100)
self.assertEqual(self.store.deals['first']['is_arch'], 1)
def test_invalid_snapshot_preserves_existing_holdings(self):
self.store.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
saved = self.store.state
with self.assertRaises(ValueError):
self.store.sync_state([PositionItem(stock_code='600001.SH', volume=100, open_price=float('inf'))])
self.assertEqual(self.store.state, saved)
self.assertEqual(State(self.store.path).state, saved)
def test_normalization_preserves_input_and_rejects_nonfinite_price(self):
deal = self.deal()
deal.trade_amount = 0
original = asdict(deal)
self.store.sync_deals([deal])
self.assertEqual(asdict(deal), original)
self.assertEqual(self.store.deals['first']['trade_amount'], 1000)
self.assertEqual(self.store.deals['first']['trade_date'], '2026-09-12')
for price in (float('inf'), float('-inf'), float('nan')):
with self.subTest(price=price):
invalid = self.deal('invalid')
invalid.price = price
with self.assertRaises(ValueError):
self.store.sync_deals([self.deal('second'), invalid])
self.assertEqual(State(self.store.path).deals_sys_ids, {'first'})
if __name__ == '__main__':
unittest.main()

View File

@@ -1,86 +0,0 @@
import tempfile
import unittest
from dataclasses import replace
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.grid_take_profit import GridState
from libs.order import OrderBook
from libs.snapshot import get_collector_snapshot
from libs.state import State
from sdk import Assets, OrderItem, PositionItem
from strategy.zt import boot
from strategy.zt.profit import ZTProfitTracker
class ZTAuditFixTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.store = State(Path(tmp.name) / 'state.db')
self.code = '600000.SH'
def position(self, code):
return PositionItem(stock_code=code, volume=100, open_price=10)
def test_zt_cancels_only_owned_orders_and_tracks_all(self):
stamp = datetime.now() - timedelta(minutes=2)
orders = [OrderItem(stock_code=f'60000{i}.SH', order_sys_id=str(i), remark=remark,
order_status=50, offset_flag=23,
insert_date=stamp.strftime('%Y%m%d'), insert_time=stamp.strftime('%H%M%S'))
for i, remark in enumerate(['zt-base-own|zt', 'zt-SELL-own|zt',
'zt-added-own|zt', 'IPO-new|ipo', '', 'TREN-BUY-other'])]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2'])
self.assertEqual(book.data, orders)
self.assertTrue(all(book.busy(o.stock_code, 'BUY') for o in orders))
client.reset_mock()
book.refresh(client, orders)
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2', '4', '5'])
def test_profit_basis_changes_reset_peak_but_partial_sell_does_not(self):
tracker = ZTProfitTracker()
position = self.position(self.code)
row = dict(base_qty=100, base_order_local_id='one', base_created_at='now', added_qty=0)
state = NS(blocked_codes=set(), get_by_code=lambda code: row)
tracker.sync_positions([position], state)
self.assertEqual(tracker.observe(self.code, 20).state, GridState.ARMED)
row['base_qty'] = 50
tracker.sync_positions([replace(position, volume=50)], state)
self.assertEqual(tracker.observe(self.code, 19).state, GridState.RETREAT)
for update, cost in [({'added_qty': 100, 'added_price': 11}, 10),
({'added_qty': 0}, 10), ({}, 12),
({'base_order_local_id': 'reopened'}, 12)]:
row.update(update)
tracker.sync_positions([replace(position, open_price=cost)], state)
self.assertEqual(tracker.observe(self.code, 10).state, GridState.ARMED)
tracker.sync_positions([], state)
tracker.sync_positions([position], state)
self.assertEqual(tracker.observe(self.code, 5).state, GridState.ARMED)
def test_run_once_updates_collector_before_market_fetch(self):
positions = [self.position(self.code)]
self.store.sync_state(positions)
client = Mock()
run = NS(client=client, account_cfg=NS(account_id='zt-test', min_cash_ratio=0.1),
orders=Mock(), profit_tracker=ZTProfitTracker())
client.deals.return_value = []
client.portfolio.return_value = NS(assets=Assets(20000, 10000),
positions={self.code: positions[0]}, orders=[])
client.full_tick.side_effect = RuntimeError('no market data')
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, 'market_allow_open', return_value=True):
boot.RunOnce(run, self.store, [])
snapshot = get_collector_snapshot()
self.assertEqual(snapshot[0], 'zt-test')
self.assertEqual(snapshot[1].total, 20000)
self.assertEqual(snapshot[2], positions)
run.orders.refresh.assert_called_once_with(client, [], cancel_prefix='zt-')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,475 @@
"""ZT 新路径端到端建仓、正T、反T、T+1 隔夜、每日一轮、资金、启动与撤单范围。"""
import logging
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.signal import SignalItem
from libs.snapshot import get_collector_snapshot
from sdk import OP_BUY, OP_SELL, Assets, OrderItem, PositionItem
from strategy.zt import boot
from strategy.zt.rounds import Round, RoundStore, start_round
from tests.zt_harness import Fixture
CODE = '600000.SH'
OTHER = '600001.SH'
TODAY = '2026-09-15'
SIGNAL = [SignalItem(signal_key='dcm', code=CODE, last_close=10.0)]
class ZTBaseTests(unittest.TestCase):
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
def test_existing_account_positions_are_never_taken_over(self):
self.fx.hold(CODE, volume=1000, price=37.72)
self.fx.quote(CODE, 37.72)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.base_qty, 0) # 不写基准
self.assertEqual(item.phase, 'IDLE')
self.assertEqual(self.fx.placed, [])
# 也不纳入管理,账户已有持仓原样保留
self.assertFalse(self.fx.store.rounds)
def test_unmanaged_positions_are_listed_each_tick(self):
self.fx.hold(CODE, volume=1000, price=37.72)
self.fx.quote(CODE, 37.72)
logging.disable(logging.NOTSET)
with self.assertLogs(level='INFO') as captured:
self.fx.tick()
text = '\n'.join(captured.output)
self.assertIn('[ZT跳过]', text)
self.assertIn('未接管持仓 1 只', text)
self.assertIn(CODE, text)
def test_decision_and_summary_lines_are_logged(self):
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.quote(CODE, 10.0)
logging.disable(logging.NOTSET)
with self.assertLogs(level='INFO') as captured:
self.fx.tick()
text = '\n'.join(captured.output)
self.assertIn('[ZT汇总]', text)
self.assertIn('未接管=1 新委托=0', text)
def test_open_base_needs_signal_and_rebound_then_uses_the_fill_price(self):
self.fx.quote(CODE, 10.0)
self.fx.tick(SIGNAL) # 第一次观察,不追
self.assertEqual(self.fx.placed, [])
self.fx.tick(SIGNAL) # 同一价位即满足反弹确认
self.assertEqual(len(self.fx.placed), 1)
order = self.fx.placed[0]
self.assertEqual((order['op_type'], order['volume']), (OP_BUY, 100))
self.assertTrue(order['order_id'].startswith('zt-base-'))
self.fx.deals = [self.fx.deal(order['order_id'], 100, 10.25, sys_id='b1')]
self.fx.tick(SIGNAL)
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSED')
self.assertEqual(item.outcome, 'base')
self.assertEqual((item.base_qty, item.base_cost), (100, 10.25))
self.assertEqual(item.base_source, 'opened')
def test_open_base_is_skipped_without_a_signal(self):
self.fx.quote(CODE, 10.0)
self.fx.prime(self.fx.run.open_watch, CODE, 10.0)
self.fx.tick([])
self.assertEqual(self.fx.placed, [])
def test_decision_and_summary_lines_are_logged(self):
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.quote(CODE, 10.0)
self.fx.open_round() # 本策略自有基准 -> 纳入管理
logging.disable(logging.NOTSET)
with self.assertLogs(level='INFO') as captured:
self.fx.tick()
text = '\n'.join(captured.output)
self.assertIn('[ZT决策]', text)
self.assertIn('[ZT汇总]', text)
self.assertIn('中性带内不做', text)
self.assertIn('未接管=0', text)
class ZTShortTTests(unittest.TestCase):
"""反T高抛后低吸买回。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.own_base(CODE, 1000, 10.0)
def enter(self):
self.fx.quote(CODE, 11.0)
self.fx.tick() # 网格首次观察
self.fx.quote(CODE, 10.5)
self.fx.tick() # 网格回撤 -> 高抛
def test_sell_high_then_buy_back(self):
self.enter()
self.assertEqual(len(self.fx.placed), 1)
entry = self.fx.placed[0]
self.assertEqual((entry['op_type'], entry['volume']), (OP_SELL, 500))
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPENING')
self.assertEqual((item.kind, item.base_qty), ('SHORT_T', 1000))
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1')]
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPEN')
self.assertEqual(item.residual_qty, 500)
# 买回需要"较卖均价回落 + 反弹确认":上一轮 tick 已在上方建立观察点。
self.fx.quote(CODE, 10.8)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSING')
exit_order = self.fx.placed[-1]
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_BUY, 500))
self.assertTrue(exit_order['order_id'].startswith('zt-exit-'))
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1'),
self.fx.deal(exit_order['order_id'], 500, 10.8, sys_id='b1')]
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSED')
self.assertEqual(item.outcome, 'normal')
self.assertEqual(item.residual_qty, 0)
self.assertEqual(item.base_qty, 1000) # 成本基准数量不变
self.assertAlmostEqual(item.realized_amount, 100.0)
def test_no_sell_inside_the_neutral_band(self):
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.quote(CODE, 10.05)
self.fx.tick()
self.fx.tick()
self.assertEqual(self.fx.placed, [])
def test_sell_only_round_is_counted_in_the_tick_summary(self):
logging.disable(logging.NOTSET)
self.fx.quote(CODE, 11.0)
self.fx.tick() # 首次观察,不下单
self.fx.quote(CODE, 10.5)
with self.assertLogs(level='INFO') as captured:
self.fx.tick() # 网格回撤 -> 高抛
text = '\n'.join(captured.output)
self.assertIn('[ZT下单]', text)
self.assertIn('[ZT决策]', text)
# 卖出腿不预留资金,仍必须计入"新委托",否则日志会漏报卖出。
self.assertIn('新委托=1', text)
def test_price_above_the_cap_never_starts_a_round(self):
self.fx.hold(CODE, volume=1000, price=190.0)
self.fx.quote(CODE, 200.5)
self.fx.tick()
self.fx.tick()
self.assertEqual(self.fx.placed, [])
def test_position_not_sellable_cannot_open_a_short_t(self):
self.fx.hold(CODE, volume=1000, price=10.0, can_use=0)
self.enter()
self.assertEqual(self.fx.placed, [])
class ZTLongTTests(unittest.TestCase):
"""正T低吸后高抛当天买入受 T+1 限制。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.own_base(CODE, 1000, 10.0)
def enter(self, price=9.0):
self.fx.quote(CODE, price)
self.fx.prime(self.fx.run.open_watch, CODE, price)
self.fx.tick()
def test_buy_the_dip_then_wait_for_t_plus_1(self):
self.enter()
self.assertEqual(len(self.fx.placed), 1)
entry = self.fx.placed[0]
self.assertEqual((entry['op_type'], entry['volume']), (OP_BUY, 100))
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual((item.kind, item.phase), ('LONG_T', 'OPEN'))
self.assertEqual(item.residual_qty, 100)
# 当天买入不可卖:可卖库存仍为 0只能隔夜。
self.fx.hold(CODE, volume=1100, price=10.0, can_use=0)
self.fx.quote(CODE, 9.5)
self.fx.tick()
self.assertEqual(len(self.fx.placed), 1) # 没有新的卖单
self.assertEqual(self.fx.store.get(CODE).phase, 'OPEN')
# 可卖恢复后才能高抛平仓。
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSING')
exit_order = self.fx.placed[-1]
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_SELL, 100))
def test_only_one_round_per_stock_per_day(self):
self.enter()
entry = self.fx.placed[0]
exit_order_id = None
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
self.fx.tick()
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
self.fx.quote(CODE, 9.5)
self.fx.tick()
exit_order_id = self.fx.placed[-1]['order_id']
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1'),
self.fx.deal(exit_order_id, 100, 9.5, sys_id='s1')]
self.fx.tick()
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
placed_after_close = len(self.fx.placed)
# 同一天价格再次满足低吸,也不允许开新轮。
self.fx.quote(CODE, 8.8)
self.fx.prime(self.fx.run.open_watch, CODE, 8.8)
self.fx.tick()
self.assertEqual(len(self.fx.placed), placed_after_close)
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
class ZTRiskTests(unittest.TestCase):
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
def test_cash_budget_is_shared_between_codes_in_one_tick(self):
self.fx.account_cfg.min_cash_ratio = 0.0
for code in (CODE, OTHER):
self.fx.hold(code, volume=1000, price=10.0)
self.fx.own_base(code, 1000, 10.0)
self.fx.quote(code, 9.0)
self.fx.prime(self.fx.run.open_watch, code, 9.0)
self.fx.assets.total = 1500.0
self.fx.assets.available = 1500.0
self.fx.tick()
self.assertEqual(len(self.fx.placed), 1) # 只够一手的钱
self.assertEqual(self.fx.placed[0]['stock_code'], CODE)
def test_failed_place_self_heals_on_the_next_tick(self):
from sdk import APIError
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.own_base(CODE, 1000, 10.0)
self.fx.quote(CODE, 9.0)
self.fx.prime(self.fx.run.open_watch, CODE, 9.0)
self.fx.client.passorder.side_effect = APIError(400, 'rejected')
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPENING') # 意图先落盘,请求被拒
self.fx.client.passorder.side_effect = None
self.fx.client.passorder.return_value = {'status': 'success'}
self.fx.tick() # 未受理且无成交 -> 判为作废
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSED')
self.assertEqual(item.outcome, 'aborted')
self.assertEqual(item.base_qty, 1000)
def test_only_zt_orders_are_cancelled(self):
stamp = datetime.now() - timedelta(minutes=30)
self.fx.orders = [
self._order('sys-tren', 'TREN-BUY-1|trend', stamp),
self._order('sys-zt', 'zt-entry-1', stamp),
self._order('sys-ipo', 'IPO-abc', stamp),
self._order('sys-manual', '', stamp),
]
self.fx.quote(CODE, 10.0)
self.fx.tick()
cancelled = [call.args[0] for call in self.fx.client.cancel_by_id.call_args_list]
self.assertEqual(cancelled, ['sys-zt'])
@staticmethod
def _order(sys_id, remark, stamp):
return OrderItem(stock_code=CODE, order_sys_id=sys_id, remark=remark,
order_status=50, offset_flag=23,
insert_date=stamp.strftime('%Y%m%d'),
insert_time=stamp.strftime('%H%M%S'))
def test_expired_round_folds_its_exposure_into_the_base(self):
self.fx.hold(CODE, volume=1000, price=10.0)
item = self.fx.own_base(CODE, 1000, 10.0)
start_round(item, 'SHORT_T', '2020-01-01')
item.entry_order_id = 'zt-entry-old'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.phase = 'OPEN'
self.fx.store.put(item)
self.fx.store.save()
self.fx.quote(CODE, 11.0)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.outcome, 'expired')
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
self.assertEqual(item.base_cost, 10.0) # 成本仍是建仓价
def test_stale_order_id_disappearing_returns_the_round_to_open(self):
self.fx.hold(CODE, volume=1000, price=10.0)
item = self.fx.own_base(CODE, 1000, 10.0)
start_round(item, 'SHORT_T', TODAY)
item.entry_order_id = 'zt-entry-1'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.exit_order_id = 'zt-exit-1'
item.phase = 'CLOSING'
self.fx.store.put(item)
self.fx.store.save()
self.fx.quote(CODE, 10.0)
self.fx.tick() # 平仓腿已不在途且无成交
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPEN')
self.assertEqual(item.residual_qty, 500)
class ZTForeignBaseCleanupTests(unittest.TestCase):
"""升级清理:旧版本留下的"接管"基准不得继续参与做 T。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
def open_store(self):
with patch.object(boot.config, 'global_config',
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
qmt_token='')), \
patch.object(boot.config, 'account_config', self.fx.account_cfg):
return boot._open_store()
def test_stale_takeover_record_is_dropped(self):
self.fx.store.put(Round(code=CODE, base_qty=1000, base_cost=37.72,
base_source='adopted', phase='CLOSED'))
self.fx.store.save()
self.fx.own_base(OTHER, 500, 9.5)
store = self.open_store()
self.assertNotIn(CODE, store.rounds)
self.assertIn(OTHER, store.rounds)
def test_unclosed_round_is_kept_so_its_exposure_can_be_finished(self):
item = Round(code=CODE, base_qty=1000, base_cost=37.72, base_source='adopted')
start_round(item, 'SHORT_T', TODAY)
item.entry_order_id = 'zt-entry-1'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.phase = 'OPEN'
self.fx.store.put(item)
self.fx.store.save()
self.assertIn(CODE, self.open_store().rounds)
def test_owned_and_empty_records_are_untouched(self):
self.fx.own_base(CODE, 500, 9.5)
self.fx.store.put(Round(code=OTHER)) # 无基准的空记录
self.fx.store.save()
store = self.open_store()
self.assertIn(CODE, store.rounds)
self.assertIn(OTHER, store.rounds)
class ZTStartTests(unittest.TestCase):
"""启动路径:使用新轮次文件、不碰旧账本、跨重启恢复未平轮次。"""
def start(self, client, directory):
account = NS(account_id='test', strategy='zt', grid_step_pct=1.0,
signal_allow=[], zt_open_hands=1, zt_max_hold_days=5,
zt_t_band_pct=1.0, zt_sell_ratio=0.5, zt_buy_fall_pct=1.0,
zt_max_price=200.0, excluded_codes=[],
min_cash_ratio=0.1)
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
with patch.object(boot, 'Client', return_value=client), \
patch.object(boot.config, 'global_config', global_cfg), \
patch.object(boot.config, 'account_config', account), \
patch.object(boot, 'init_signals', return_value=[]), \
patch.object(boot.time, 'localtime',
return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
boot.StartZT()
return account
def test_uses_the_rounds_store_and_never_touches_the_old_ledger(self):
client = Mock()
with tempfile.TemporaryDirectory() as tmp:
self.start(client, tmp)
self.assertFalse((Path(tmp) / 'zt_test_state.db').exists())
self.assertEqual(client.deals.call_count, 0) # 15:00 直接退出,没跑 tick
self.assertEqual(client.portfolio.call_count, 0) # 也不再为接管读持仓
client.close.assert_called_once()
with patch.object(boot.config, 'global_config',
NS(qmt_data_dir=tmp, qmt_base_url='u', qmt_token='')), \
patch.object(boot.config, 'account_config', NS(account_id='test')):
store = boot._open_store()
self.assertEqual(store.path.name, 'zt_test_rounds.json')
self.assertEqual(store.rounds, {})
def test_start_does_not_read_positions_at_all(self):
# 不接管持仓,启动阶段不需要账户快照,第一次读盘发生在第一个 tick。
client = Mock()
with tempfile.TemporaryDirectory() as tmp:
self.start(client, tmp)
self.assertEqual(client.portfolio.call_count, 0)
self.assertEqual(client.deals.call_count, 0)
def test_restores_an_unclosed_round_across_restart(self):
client = Mock()
with tempfile.TemporaryDirectory() as tmp:
store = RoundStore(Path(tmp) / 'zt_test_rounds.json')
item = Round(code=CODE, base_qty=1000, base_cost=10.0)
start_round(item, 'SHORT_T', '2026-09-14')
item.entry_order_id = 'zt-entry-1'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.phase = 'OPEN'
store.put(item)
store.save()
self.start(client, tmp)
restored = RoundStore(Path(tmp) / 'zt_test_rounds.json').get(CODE)
self.assertEqual(restored.phase, 'OPEN')
self.assertEqual(restored.residual_qty, 500)
self.assertEqual(restored.entry_avg_price, 11.0)
class ZTCollectorTests(unittest.TestCase):
def test_snapshot_is_cached_even_when_market_fetch_fails(self):
fx = Fixture()
self.addCleanup(fx.cleanup)
fx.assets = Assets(total=20000, available=10000)
fx.hold(CODE, volume=100, price=10.0)
fx.quote(CODE, 10.0)
fx.client.full_tick.side_effect = RuntimeError('no market data')
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, 'market_allow_open', return_value=True):
boot.RunOnce(fx.run, fx.store, [])
snapshot = get_collector_snapshot()
self.assertEqual(snapshot[0], 'zt-test')
self.assertEqual(snapshot[1].total, 20000)
self.assertEqual([p.stock_code for p in snapshot[2]], [CODE])
self.assertEqual(fx.placed, [])
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,104 @@
"""ZT 配置:手数、中性带、最长持有天数与开关的校验。"""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import yaml
import config
from config import AccountConfig, GlobalConfig, SignalConfig
from strategy.zt import boot
class ZTConfigTests(unittest.TestCase):
def zt_config(self, directory, **overrides):
root = Path(directory)
(root / '_global.yaml').write_text(yaml.safe_dump({
'qmt_base_url': 'unused', 'api_host': 'unused',
'qmt_data_dir': directory, 'hosts': {'test': 'account'},
}), encoding='utf-8')
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
account.update(overrides)
(root / 'account.yaml').write_text(yaml.safe_dump(account), encoding='utf-8')
return root
def test_config_accepts_only_nonnegative_integer_hands(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
for hands in (None, 0, 3, -1, 1.5, '3', True):
with self.subTest(hands=hands):
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
if hands is not None:
account['zt_open_hands'] = hands
root = self.zt_config(directory, **{k: v for k, v in
account.items()
if k not in ('buy_value',
'strategy',
'signal_allow')})
if hands is None or type(hands) is int and hands >= 0:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_open_hands, hands or 0)
else:
with self.assertRaisesRegex(ValueError, 'zt_open_hands'):
config.load(root, 'test')
def test_t_band_and_hold_days_defaults_and_validation(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
_, loaded = config.load(self.zt_config(directory), 'test')
self.assertEqual(loaded.zt_t_band_pct, 1.0)
self.assertEqual(loaded.zt_max_hold_days, 5)
for band, valid in ((0, True), (0.5, True), (1.0, True), (-1, False)):
with self.subTest(band=band):
root = self.zt_config(directory, zt_t_band_pct=band)
if valid:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_t_band_pct, band)
else:
with self.assertRaisesRegex(ValueError, 'zt_t_band_pct'):
config.load(root, 'test')
for days, valid in ((1, True), (5, True), (0, False), (-1, False),
(1.5, False), ('5', False), (True, False)):
with self.subTest(days=days):
root = self.zt_config(directory, zt_max_hold_days=days)
if valid:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_max_hold_days, days)
else:
with self.assertRaisesRegex(ValueError, 'zt_max_hold_days'):
config.load(root, 'test')
def test_zero_hands_does_not_initialize_strategy(self):
with patch.object(config, 'account_config', AccountConfig()), \
patch.object(boot, 'Client') as client, \
patch.object(boot, '_open_store') as store, \
patch.object(boot, 'init_signals') as signals:
boot.StartZT()
for dependency in (client, store, signals):
dependency.assert_not_called()
def test_unknown_account_key_is_rejected_with_a_clear_error(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
root = self.zt_config(directory, zt_sell_ratios=0.5) # 拼错的名字
with self.assertRaisesRegex(ValueError, 'zt_sell_ratios'):
config.load(root, 'test')
class SignalConfigTests(unittest.TestCase):
def test_signal_defaults(self):
item = SignalConfig()
self.assertEqual((item.url, item.timezone), ('', '*'))
self.assertFalse(item.gt_last_price_is_open)
self.assertEqual(GlobalConfig().signals, {})
def test_zero_hands_is_the_off_switch(self):
self.assertEqual(AccountConfig().zt_open_hands, 0)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,91 +0,0 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
import yaml
import config
from config import AccountConfig, GlobalConfig, SignalConfig
from sdk import Tick
from strategy.zt import boot
from strategy.zt.open import open_signal
from strategy.zt.positions import handle_loss
class ZTOpenHandsTests(unittest.TestCase):
def runtime(self, hands):
run = NS(
account_cfg=AccountConfig(zt_open_hands=hands, buy_value=10000, strategy='zt'),
global_cfg=GlobalConfig(signals={'dcm': SignalConfig()}),
client=Mock(), orders=Mock(), open_watch=Mock(), add_watch=Mock(),
)
run.orders.busy.return_value = False
run.orders.place.return_value = True
run.open_watch.triggered.return_value = True
run.add_watch.triggered.return_value = True
return run
def test_open_and_add_use_same_hands_at_different_prices(self):
run = self.runtime(3)
code = '600000.SH'
for price in (8, 12):
with self.subTest(price=price):
open_signal(run, {code: Tick(last_price=price)},
[NS(code=code, signal_key='dcm', last_close=10)])
self.assertEqual(run.orders.place.call_args.args[1].volume, 300)
decision = handle_loss(run, code, 100, Tick(last_price=price), -20, 5000)
self.assertTrue(decision.submitted)
self.assertEqual(run.orders.place.call_args.args[1].volume, 300)
self.assertEqual(decision.reserved_cash, price * 300)
def test_add_does_not_reduce_hands_when_cash_is_insufficient(self):
run = self.runtime(3)
decision = handle_loss(run, '600000.SH', 100, Tick(last_price=10), -20, 2999)
self.assertFalse(decision.submitted)
run.orders.place.assert_not_called()
def test_zero_hands_does_not_initialize_strategy(self):
with patch.object(config, 'account_config', AccountConfig()), \
patch.object(boot, 'Client') as client, \
patch.object(boot, 'State') as state, \
patch.object(boot, 'ThreadPoolExecutor') as executor, \
patch.object(boot, 'init_signals') as signals:
boot.StartZT()
for dependency in (client, state, executor, signals):
dependency.assert_not_called()
def test_zero_hands_never_submits_open_or_add_orders(self):
run = self.runtime(0)
code = '600000.SH'
open_signal(run, {code: Tick(last_price=10)},
[NS(code=code, signal_key='dcm', last_close=10)])
decision = handle_loss(run, code, 100, Tick(last_price=10), -20, 10000)
self.assertFalse(decision.submitted)
run.orders.place.assert_not_called()
def test_config_accepts_only_nonnegative_integer_hands(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
root = Path(directory)
(root / '_global.yaml').write_text(yaml.safe_dump({
'qmt_base_url': 'unused', 'api_host': 'unused',
'qmt_data_dir': directory, 'hosts': {'test': 'account'},
}), encoding='utf-8')
for hands in (None, 0, 3, -1, 1.5, '3', True):
with self.subTest(hands=hands):
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
if hands is not None:
account['zt_open_hands'] = hands
(root / 'account.yaml').write_text(yaml.safe_dump(account), encoding='utf-8')
if hands is None or type(hands) is int and hands >= 0:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_open_hands, hands or 0)
else:
with self.assertRaisesRegex(ValueError, 'zt_open_hands'):
config.load(root, 'test')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,177 @@
"""ZT 归属过滤:非本策略成交通知不得进入账本,也不得中断策略。"""
import logging
import unittest
from sdk import DealItem
from strategy.zt import boot
from strategy.zt.ownership import OWNED_PREFIX, owned_deals, owns_local_order_id
from strategy.zt.rounds import RoundStore, start_round
from tests.zt_harness import Fixture
TODAY = '2026-09-15'
class OwnershipPredicateTests(unittest.TestCase):
def test_only_local_order_ids_generated_by_zt_are_owned(self):
owned = ['zt-base-8e9da97a42e957408489', 'zt-added-9239083181eb39712994',
'zt-entry-0b10c994b8242682983f', 'zt-exit-1']
foreign = ['', None, ' ', 'TREN-BUY-1', 'MORN-2', 'IPO-abc', 'DCM-3',
'zt', 'azt-base-1', 'ztbase-1']
for value in owned:
with self.subTest(value=value):
self.assertTrue(owns_local_order_id(value))
for value in foreign:
with self.subTest(value=value):
self.assertFalse(owns_local_order_id(value))
self.assertEqual(OWNED_PREFIX, 'zt-')
def test_owned_deals_splits_and_counts(self):
def deal(remark):
return DealItem(stock_code='600000.SH', order_sys_id=remark or 'none',
remark=remark)
deals = [deal('zt-entry-a'), deal(''), deal('TREN-BUY-1'), deal('zt-exit-b')]
owned, ignored = owned_deals(deals)
self.assertEqual([d.get_local_order_id for d in owned],
['zt-entry-a', 'zt-exit-b'])
self.assertEqual(ignored, 2)
class ForeignDealIsolationTests(unittest.TestCase):
"""手工单与其他策略单既不进轮次,也不影响本策略的判断。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold('600000.SH', volume=1000, price=10.0)
self.fx.quote('600000.SH', 10.0)
def run_tick(self):
self.fx.tick()
return self.fx.store.get('600000.SH')
def test_manual_deal_without_remark_neither_raises_nor_blocks(self):
self.fx.open_round()
self.fx.deals = [DealItem(stock_code='600000.SH', order_sys_id='m1',
remark='', offset_flag=48, volume=100, price=9.0,
trade_amount=900.0, trade_date='20260915',
trade_time='100000')]
item = self.run_tick() # 旧实现在这里抛 IntegrityError
self.assertEqual(item.entry_filled_qty, 0)
self.assertEqual(item.outcome, 'aborted') # 开仓腿无成交且已不在途
self.assertEqual(self.fx.placed, [])
def test_foreign_strategy_deal_cannot_touch_the_round(self):
self.fx.open_round()
self.fx.deals = [DealItem(stock_code='600000.SH', order_sys_id='t1',
remark='TREN-BUY-9|trend', offset_flag=48,
volume=500, price=20.0, trade_amount=10000.0,
trade_date='20260915', trade_time='100000')]
item = self.run_tick() # 旧实现把外部买入当补仓写进 added 桶
self.assertEqual(item.entry_filled_qty, 0)
self.assertEqual(item.entry_amount, 0.0)
self.assertEqual(item.base_qty, 1000)
def test_owned_deals_are_still_counted(self):
self.fx.open_round()
self.fx.deals = [self.fx.deal('zt-entry-1', 300, 9.0)]
item = self.run_tick()
self.assertEqual(item.entry_filled_qty, 300)
self.assertEqual(item.entry_avg_price, 9.0)
# 现价 10.0 对买入均价 9.0 已超过一个网格步长,同一轮 tick 内即挂出卖单。
self.assertEqual(item.phase, 'CLOSING')
self.assertEqual([p['stock_code'] for p in self.fx.placed], ['600000.SH'])
self.assertTrue(self.fx.placed[0]['order_id'].startswith('zt-exit-'))
def test_mixed_batch_keeps_only_owned_deals(self):
self.fx.open_round()
self.fx.deals = [
self.fx.deal('zt-entry-1', 100, 9.0, sys_id='own'),
DealItem(stock_code='600000.SH', order_sys_id='manual', remark='',
offset_flag=48, volume=100, price=9.0, trade_amount=900.0,
trade_date='20260915', trade_time='100000'),
DealItem(stock_code='600000.SH', order_sys_id='trend',
remark='TREN-BUY-1', offset_flag=48, volume=100, price=9.0,
trade_amount=900.0, trade_date='20260915', trade_time='100000'),
]
item = self.run_tick()
self.assertEqual(item.entry_filled_qty, 100)
self.assertEqual(len(item.seen_deal_ids), 1)
def test_repeated_ticks_never_double_count(self):
self.fx.open_round()
self.fx.deals = [self.fx.deal('zt-entry-1', 300, 9.0)]
self.assertEqual(self.run_tick().entry_filled_qty, 300)
self.assertEqual(self.run_tick().entry_filled_qty, 300)
class RunOnceResilienceTests(unittest.TestCase):
"""任何单点失败都只能跳过本轮,不能打断唯一的交易定时线程。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold('600000.SH', volume=1000, price=10.0)
self.fx.quote('600000.SH', 10.0)
def test_snapshot_failure_skips_the_round_quietly(self):
from unittest.mock import patch
with patch.object(boot, 'trading_time', return_value=True):
self.fx.client.portfolio.side_effect = RuntimeError('api down')
boot.RunOnce(self.fx.run, self.fx.store, [])
self.assertEqual(self.fx.placed, [])
def test_round_advance_failure_skips_trading(self):
from unittest.mock import patch
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, '_advance_rounds', side_effect=RuntimeError('broken')):
boot.RunOnce(self.fx.run, self.fx.store, [])
self.assertEqual(self.fx.placed, [])
def test_market_data_failure_skips_trading(self):
from unittest.mock import patch
with patch.object(boot, 'trading_time', return_value=True):
self.fx.client.full_tick.side_effect = RuntimeError('no ticks')
boot.RunOnce(self.fx.run, self.fx.store, [])
self.assertEqual(self.fx.placed, [])
def test_startup_state_failure_closes_client_without_raising(self):
from types import SimpleNamespace as NS
from unittest.mock import patch
client = self.fx.client
with patch.object(boot.config, 'account_config', self.fx.account_cfg), \
patch.object(boot.config, 'global_config',
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
qmt_token='', api_host='u')), \
patch.object(boot, 'Client', return_value=client), \
patch.object(boot, '_open_store', side_effect=RuntimeError('disk')):
boot.StartZT() # 不抛异常
client.close.assert_called_once()
def test_corrupt_state_is_backed_up_and_rebuilt(self):
from types import SimpleNamespace as NS
from unittest.mock import patch
path = self.fx.rounds_path
path.write_text('{not json', encoding='utf-8')
client = self.fx.client
with patch.object(boot.config, 'account_config', self.fx.account_cfg), \
patch.object(boot.config, 'global_config',
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
qmt_token='', api_host='u')) , \
patch.object(boot, 'Client', return_value=client), \
patch.object(boot, 'init_signals', return_value=[]), \
patch.object(boot.time, 'localtime',
return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
boot.StartZT()
self.assertTrue(path.with_name(path.name + '.corrupt').is_file())
self.assertEqual(RoundStore(path).rounds, {})
client.close.assert_called_once()
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,420 @@
"""ZT 轮次状态:幂等成交累计、阶段推进、跨日配额、超期放弃、持久化。"""
import json
import tempfile
import unittest
from pathlib import Path
from sdk import DealItem, OrderItem, PositionItem
from strategy.zt.rounds import (
BASE_SOURCE_OPENED,
KIND_LONG_T,
KIND_SHORT_T,
OUTCOME_ABORTED,
OUTCOME_BASE,
OUTCOME_EXPIRED,
OUTCOME_NORMAL,
PHASE_CLOSED,
PHASE_CLOSING,
PHASE_IDLE,
PHASE_OPEN,
PHASE_OPENING,
Round,
RoundStore,
RoundStoreError,
advance,
apply_deals,
expire,
in_flight_order_ids,
is_owned_base,
new_base_round,
new_round,
start_round,
)
TODAY = '2026-09-15'
def deal(order_sys_id, remark, volume=100, price=10.0):
return DealItem(stock_code='600000.SH', order_sys_id=order_sys_id, remark=remark,
offset_flag=48, volume=volume, price=price,
trade_amount=price * volume,
trade_date='20260915', trade_time='100000')
def order(local_id, status):
return OrderItem(stock_code='600000.SH', order_sys_id=local_id, remark=local_id,
offset_flag=48, order_status=status,
insert_date='20260915', insert_time='100000')
class RoundModelTests(unittest.TestCase):
def test_directions_are_mirrored_between_long_and_short_t(self):
long_t = Round(code='600000.SH', kind=KIND_LONG_T)
short_t = Round(code='600000.SH', kind=KIND_SHORT_T)
self.assertEqual((long_t.entry_side, long_t.exit_side), ('BUY', 'SELL'))
self.assertEqual((short_t.entry_side, short_t.exit_side), ('SELL', 'BUY'))
def test_residual_and_average_prices(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
entry_filled_qty=200, entry_amount=2000.0,
exit_filled_qty=100, exit_amount=1100.0)
self.assertEqual(item.residual_qty, 100)
self.assertAlmostEqual(item.entry_avg_price, 10.0)
self.assertAlmostEqual(item.exit_avg_price, 11.0)
self.assertEqual(Round().entry_avg_price, 0.0)
def test_daily_quota_and_cross_day_recovery(self):
item = Round(code='600000.SH')
self.assertTrue(item.can_open(TODAY))
item.open_date = TODAY # 今天已开过一轮
self.assertFalse(item.can_open(TODAY))
item.open_date = '2026-09-14'
item.phase = PHASE_OPEN # 昨日未平的轮次继续持有
self.assertFalse(item.can_open(TODAY))
item.phase = PHASE_CLOSED
self.assertTrue(item.can_open(TODAY))
item.last_trade_date = TODAY # 今天已有腿成交
self.assertFalse(item.can_open(TODAY))
item.last_trade_date = '2026-09-14' # 昨日成交,今天可以做一轮
self.assertTrue(item.can_open(TODAY))
def test_new_round_keeps_the_established_base(self):
item = new_round('600000.SH', KIND_LONG_T, TODAY, 500, 26.89,
base_date='2026-09-10', base_source='opened')
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual((item.base_qty, item.base_cost), (500, 26.89))
self.assertEqual((item.base_date, item.base_source), ('2026-09-10', 'opened'))
class ApplyDealsTests(unittest.TestCase):
def test_repeated_sync_never_double_counts(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, entry_order_id='zt-base-1')
batch = [deal('s1', 'zt-base-1'), deal('s2', 'zt-base-1')]
apply_deals(item, batch, TODAY)
self.assertEqual(item.entry_filled_qty, 200)
self.assertAlmostEqual(item.entry_amount, 2000.0)
apply_deals(item, batch, TODAY) # 同一批再次同步
self.assertEqual(item.entry_filled_qty, 200)
apply_deals(item, batch + [deal('s3', 'zt-base-1')], TODAY)
self.assertEqual(item.entry_filled_qty, 300)
self.assertEqual(item.last_trade_date, TODAY)
def test_only_this_rounds_legs_are_counted(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
entry_order_id='zt-base-1', exit_order_id='zt-SELL-1')
apply_deals(item, [
deal('s1', 'zt-base-1'),
deal('s2', ''), # 手工单
deal('s3', 'TREN-BUY-1'), # 其他策略
deal('s4', 'zt-added-other'), # 本策略但不是本轮
deal('s5', 'zt-SELL-1', price=11.0),
], TODAY)
self.assertEqual(item.entry_filled_qty, 100)
self.assertEqual(item.exit_filled_qty, 100)
self.assertEqual(item.residual_qty, 0)
self.assertEqual(sorted(item.seen_deal_ids), ['s1', 's5'])
class AdvanceTests(unittest.TestCase):
def test_entry_fully_filled_moves_to_open(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
phase=PHASE_OPENING, entry_order_id='zt-base-1',
entry_filled_qty=300)
advance(item, {'other'}, TODAY)
self.assertEqual(item.phase, PHASE_OPEN)
self.assertEqual(item.residual_qty, 300)
def test_entry_still_in_flight_does_not_move(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
phase=PHASE_OPENING, entry_order_id='zt-base-1',
entry_filled_qty=100)
advance(item, {'zt-base-1'}, TODAY)
self.assertEqual(item.phase, PHASE_OPENING)
def test_aborted_entry_releases_the_daily_quota(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPENING,
open_date=TODAY, entry_order_id='zt-base-1')
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_ABORTED)
self.assertEqual(item.open_date, '')
self.assertTrue(item.can_open(TODAY))
def test_partially_closed_round_returns_to_open(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_CLOSING,
entry_order_id='zt-base-1', exit_order_id='zt-SELL-1',
entry_filled_qty=300, exit_filled_qty=100)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_OPEN)
self.assertEqual(item.residual_qty, 200)
def test_fully_closed_round_finishes(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSING,
open_date=TODAY, entry_order_id='zt-SELL-1', exit_order_id='zt-added-1',
entry_filled_qty=100, exit_filled_qty=100)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_NORMAL)
self.assertEqual(item.close_date, TODAY)
self.assertEqual(item.open_date, TODAY) # 完成轮次占用当日配额
def test_overnight_round_keeps_its_open_date(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-14', entry_order_id='zt-SELL-1',
entry_filled_qty=100)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_OPEN) # 仍待买回,允许隔夜
self.assertFalse(item.can_open(TODAY))
class BaseEstablishmentTests(unittest.TestCase):
def test_base_cost_comes_from_the_actual_fill(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
apply_deals(item, [deal('s1', 'zt-base-1', volume=300, price=26.89)], TODAY)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_BASE)
self.assertEqual(item.base_qty, 300)
self.assertAlmostEqual(item.base_cost, 26.89)
self.assertEqual(item.base_source, BASE_SOURCE_OPENED)
self.assertEqual(item.base_date, TODAY)
def test_partial_base_fill_is_accepted(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
apply_deals(item, [deal('s1', 'zt-base-1', volume=100, price=26.0)], TODAY)
advance(item, set(), TODAY)
self.assertEqual((item.base_qty, item.base_cost), (100, 26.0))
def test_empty_base_fill_aborts_and_frees_the_quota(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
advance(item, set(), TODAY)
self.assertEqual(item.outcome, OUTCOME_ABORTED)
self.assertEqual(item.open_date, '')
self.assertTrue(item.can_open(TODAY))
def test_base_round_settles_only_after_the_order_is_no_longer_in_flight(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
apply_deals(item, [deal('s1', 'zt-base-1', volume=300, price=26.89)], TODAY)
advance(item, {'zt-base-1'}, TODAY)
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual(item.base_qty, 0)
def test_adoption_is_not_supported(self):
# 程序不接管账户已有持仓Round 只认识自己建仓写下的基准。
item = Round(code='600000.SH', base_qty=500, base_cost=37.72,
base_source=BASE_SOURCE_OPENED)
self.assertTrue(is_owned_base(item))
for source in ('', 'adopted', 'configured'):
with self.subTest(source=source):
self.assertFalse(is_owned_base(Round(code='600000.SH', base_qty=500,
base_cost=37.72,
base_source=source)))
self.assertFalse(is_owned_base(Round(code='600000.SH')))
def test_apply_deals_reports_applied_fills_for_logging(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
entry_order_id='zt-entry-1', exit_order_id='zt-exit-1')
applied = apply_deals(item, [deal('s1', 'zt-entry-1'),
deal('s2', 'TREN-BUY-1'),
deal('s3', 'zt-exit-1')], TODAY)
self.assertEqual([leg for leg, _ in applied], ['entry', 'exit'])
self.assertEqual([entry.order_sys_id for _, entry in applied], ['s1', 's3'])
self.assertEqual(apply_deals(item, [deal('s1', 'zt-entry-1')], TODAY), [])
class StartRoundTests(unittest.TestCase):
"""开新轮必须清空上一轮的两条腿,否则残量会静默把本轮判成作废。"""
def closed_round(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSED,
open_date='2026-09-14', close_date='2026-09-14',
outcome=OUTCOME_NORMAL, note='旧备注',
entry_order_id='e1', entry_plan_qty=500, entry_filled_qty=500,
entry_amount=5500.0, exit_order_id='x1', exit_plan_qty=500,
exit_filled_qty=500, exit_amount=4900.0,
seen_deal_ids=['s1', 's2'])
return item
def test_start_round_clears_both_legs_and_audit_fields(self):
item = self.closed_round()
start_round(item, KIND_LONG_T, TODAY)
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual(item.kind, KIND_LONG_T)
self.assertEqual(item.open_date, TODAY)
self.assertEqual((item.close_date, item.outcome, item.note), ('', '', ''))
self.assertEqual((item.entry_order_id, item.exit_order_id), ('', ''))
self.assertEqual((item.entry_filled_qty, item.exit_filled_qty), (0, 0))
self.assertEqual((item.entry_amount, item.exit_amount), (0.0, 0.0))
self.assertEqual(item.seen_deal_ids, [])
self.assertEqual(item.residual_qty, 0)
def test_start_round_keeps_the_established_base(self):
item = self.closed_round()
item.base_qty, item.base_cost = 1000, 10.0
item.base_date, item.base_source = '2026-09-10', BASE_SOURCE_OPENED
start_round(item, KIND_SHORT_T, TODAY)
self.assertEqual((item.base_qty, item.base_cost), (1000, 10.0))
self.assertEqual((item.base_date, item.base_source),
('2026-09-10', BASE_SOURCE_OPENED))
def test_stale_exit_counter_cannot_abort_a_new_round(self):
# 复现:直接改字段开新轮,上一轮的 exit_filled_qty 让 residual 变负,
# advance 会判成作废并立刻重开一轮。
item = self.closed_round()
item.kind = KIND_LONG_T
item.phase = PHASE_OPENING
item.open_date = TODAY
item.entry_order_id = 'e2'
item.entry_filled_qty, item.entry_amount = 0, 0.0
self.assertEqual(item.residual_qty, -500)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.note, '成交累计异常:平仓量超过开仓量,本轮作废')
fixed = self.closed_round()
start_round(fixed, KIND_LONG_T, TODAY)
fixed.entry_order_id = 'e2'
apply_deals(fixed, [deal('s9', 'e2', volume=300, price=9.0)], TODAY)
advance(fixed, set(), TODAY)
self.assertEqual(fixed.phase, PHASE_OPEN)
self.assertEqual(fixed.residual_qty, 300)
def test_new_base_round_starts_clean(self):
item = new_base_round('600000.SH', TODAY, 300)
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual(item.entry_plan_qty, 300)
self.assertEqual(item.base_qty, 0)
class ResidualAbsorptionTests(unittest.TestCase):
"""超期放弃必须把敞口并回底仓,否则会在裸敞口上继续开新轮。"""
def test_unclosed_short_t_leg_reduces_the_base(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-09', base_qty=1000, base_cost=10.0,
entry_filled_qty=500, entry_amount=5500.0)
self.assertTrue(expire(item, TODAY, 5))
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
self.assertAlmostEqual(item.base_cost, 10.0) # 成本仍是建仓价
self.assertEqual(item.residual_qty, 500) # 敞口数值保留在审计字段里
def test_unclosed_long_t_leg_increases_the_base(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPEN,
open_date='2026-09-09', base_qty=1000, base_cost=10.0,
entry_filled_qty=300, entry_amount=2700.0)
self.assertTrue(expire(item, TODAY, 5))
self.assertEqual(item.base_qty, 1300)
def test_normal_completion_leaves_the_base_untouched(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSING,
open_date=TODAY, base_qty=1000, base_cost=10.0,
entry_order_id='e1', exit_order_id='x1',
entry_filled_qty=500, entry_amount=5500.0,
exit_filled_qty=500, exit_amount=4900.0)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.base_qty, 1000)
def test_aborted_round_never_touches_the_base(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPENING,
open_date=TODAY, base_qty=1000, base_cost=10.0,
entry_order_id='e1')
advance(item, set(), TODAY)
self.assertEqual(item.outcome, OUTCOME_ABORTED)
self.assertEqual(item.base_qty, 1000)
class ExpireTests(unittest.TestCase):
def test_round_beyond_max_hold_days_is_abandoned_not_forced(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-09', entry_filled_qty=100)
self.assertTrue(expire(item, TODAY, 5))
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_EXPIRED)
self.assertEqual(item.residual_qty, 100) # 残量留作隔夜,不强平
def test_round_within_the_limit_is_kept(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-14', entry_filled_qty=100)
self.assertFalse(expire(item, TODAY, 5))
self.assertEqual(item.phase, PHASE_OPEN)
def test_inactive_rounds_never_expire(self):
for phase in (PHASE_OPENING, PHASE_CLOSED):
with self.subTest(phase=phase):
item = Round(code='600000.SH', phase=phase, open_date='2020-01-01')
self.assertFalse(expire(item, TODAY, 5))
class InFlightTests(unittest.TestCase):
def test_only_busy_statuses_count_as_in_flight(self):
orders = [order(f'zt-o{i}', status) for i, status in
enumerate(['48', '49', '50', '51', '52', '55', '53', '54', '56', '57'])]
self.assertEqual(in_flight_order_ids(orders),
{'zt-o0', 'zt-o1', 'zt-o2', 'zt-o3', 'zt-o4', 'zt-o5'})
def test_empty_local_ids_are_ignored(self):
self.assertEqual(in_flight_order_ids([order('', '50')]), set())
class RoundStoreTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.path = Path(temp.name) / 'zt_rounds.json'
def test_roundtrip_survives_restart(self):
store = RoundStore(self.path)
item = new_round('600000.SH', KIND_SHORT_T, TODAY, 500, 26.89)
item.entry_order_id = 'zt-SELL-1'
item.entry_filled_qty = 300
item.entry_amount = 8067.0
item.seen_deal_ids = ['s1', 's2']
store.put(item)
store.save()
reloaded = RoundStore(self.path)
restored = reloaded.get('600000.SH')
self.assertEqual(restored, item)
self.assertEqual(restored.seen_deal_ids, ['s1', 's2'])
def test_missing_file_starts_empty_and_unknown_code_is_idle(self):
store = RoundStore(self.path)
self.assertEqual(store.rounds, {})
self.assertEqual(store.get('600000.SH').phase, PHASE_IDLE)
def test_save_leaves_no_temporary_file(self):
store = RoundStore(self.path)
store.put(Round(code='600000.SH'))
store.save()
self.assertEqual([p.name for p in self.path.parent.iterdir()],
['zt_rounds.json'])
def test_corrupt_or_foreign_state_raises_for_rebuild(self):
cases = {
'bad json': '{not json',
'wrong root': '[]',
'wrong item': '{"600000.SH": 3}',
'unknown field': json.dumps({'600000.SH': {'code': '600000.SH', 'zzz': 1}}),
}
for label, text in cases.items():
with self.subTest(label=label):
self.path.write_text(text, encoding='utf-8')
with self.assertRaises(RoundStoreError):
RoundStore(self.path)
def test_drop_removes_a_code(self):
store = RoundStore(self.path)
store.put(Round(code='600000.SH'))
store.drop('600000.SH')
store.drop('600001.SH')
self.assertEqual(store.rounds, {})
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,152 @@
"""ZT 正T/反T 规则:方向选择、手数与资金/库存封顶、买卖触发条件。"""
import unittest
from strategy.zt.rounds import KIND_LONG_T, KIND_SHORT_T
from strategy.zt.rules import (
choose_kind,
entry_triggered,
entry_volume,
exit_triggered,
exit_volume,
price_allowed,
)
BASE_COST = 10.0
class ChooseKindTests(unittest.TestCase):
def test_band_decides_the_direction(self):
self.assertEqual(choose_kind(9.0, BASE_COST, 1.0), KIND_LONG_T)
self.assertEqual(choose_kind(11.0, BASE_COST, 1.0), KIND_SHORT_T)
def test_neutral_band_does_nothing(self):
for price in (9.91, 10.0, 10.09):
with self.subTest(price=price):
self.assertIsNone(choose_kind(price, BASE_COST, 1.0))
def test_invalid_inputs_yield_no_direction(self):
for price, cost, band in ((0, BASE_COST, 1.0), (-1, BASE_COST, 1.0),
(9.0, 0, 1.0), (9.0, BASE_COST, -1)):
with self.subTest(price=price, cost=cost, band=band):
self.assertIsNone(choose_kind(price, cost, band))
def test_zero_band_picks_a_side_but_never_both(self):
self.assertEqual(choose_kind(9.99, BASE_COST, 0), KIND_LONG_T)
self.assertEqual(choose_kind(10.01, BASE_COST, 0), KIND_SHORT_T)
def test_price_cap(self):
self.assertTrue(price_allowed(199.0, 200.0))
self.assertFalse(price_allowed(200.01, 200.0))
self.assertFalse(price_allowed(0, 200.0))
class EntryVolumeTests(unittest.TestCase):
def test_long_t_uses_hands_and_is_capped_by_cash(self):
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=100000.0), 300)
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=2500.0), 200)
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=999.0), 0)
def test_long_t_never_forces_a_lot_when_cash_is_short(self):
# 与 calc_buy_volume 的 max(1, ...) 不同:这里买不起就不买。
self.assertEqual(entry_volume(KIND_LONG_T, price=1500.0, open_hands=1,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=5000.0), 0)
def test_short_t_uses_ratio_and_is_capped_by_sellable_inventory(self):
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=1000,
can_use_volume=1000, available=0.0), 500)
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=1000,
can_use_volume=250, available=0.0), 200)
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=1000,
can_use_volume=99, available=0.0), 0)
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=100,
can_use_volume=100, available=0.0), 0)
def test_unknown_kind_or_bad_price_does_nothing(self):
self.assertEqual(entry_volume('???', price=10.0, open_hands=3, sell_ratio=0.5,
base_qty=100, can_use_volume=100, available=1e6), 0)
self.assertEqual(entry_volume(KIND_LONG_T, price=0.0, open_hands=3, sell_ratio=0.5,
base_qty=0, can_use_volume=0, available=1e6), 0)
class ExitVolumeTests(unittest.TestCase):
def test_long_t_exit_is_limited_by_sellable_inventory(self):
# 正T 当天买入的份额 T+1 才可卖:可卖为 0 时只能留成隔夜。
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
can_use_volume=0, available=1e6), 0)
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
can_use_volume=300, available=1e6), 300)
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
can_use_volume=250, available=1e6), 200)
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=150, price=10.0,
can_use_volume=100, available=1e6), 100)
def test_short_t_exit_is_limited_by_cash(self):
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
can_use_volume=0, available=3000.0), 300)
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
can_use_volume=0, available=100000.0), 500)
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
can_use_volume=0, available=50.0), 0)
def test_nothing_to_close(self):
for residual in (0, -100):
with self.subTest(residual=residual):
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=residual, price=10.0,
can_use_volume=1000, available=1e6), 0)
class TriggerTests(unittest.TestCase):
def test_entry_needs_direction_plus_confirmation(self):
self.assertTrue(entry_triggered(KIND_LONG_T, 9.0, BASE_COST, band_pct=1.0,
rebound_confirmed=True, retrace_confirmed=False))
self.assertFalse(entry_triggered(KIND_LONG_T, 9.0, BASE_COST, band_pct=1.0,
rebound_confirmed=False, retrace_confirmed=True))
self.assertTrue(entry_triggered(KIND_SHORT_T, 11.0, BASE_COST, band_pct=1.0,
rebound_confirmed=False, retrace_confirmed=True))
# 方向与位置不符时即使确认也不触发
self.assertFalse(entry_triggered(KIND_SHORT_T, 9.0, BASE_COST, band_pct=1.0,
rebound_confirmed=True, retrace_confirmed=True))
self.assertFalse(entry_triggered(KIND_LONG_T, 10.0, BASE_COST, band_pct=1.0,
rebound_confirmed=True, retrace_confirmed=True))
def test_short_t_exit_needs_fall_and_rebound(self):
kwargs = dict(buy_fall_pct=1.0, profit_step_pct=1.0)
self.assertTrue(exit_triggered(KIND_SHORT_T, 9.8, 10.0,
rebound_confirmed=True, **kwargs))
self.assertFalse(exit_triggered(KIND_SHORT_T, 9.8, 10.0,
rebound_confirmed=False, **kwargs))
self.assertFalse(exit_triggered(KIND_SHORT_T, 9.95, 10.0,
rebound_confirmed=True, **kwargs))
def test_long_t_exit_needs_a_profit_step(self):
kwargs = dict(buy_fall_pct=1.0, profit_step_pct=1.0)
self.assertTrue(exit_triggered(KIND_LONG_T, 10.1, 10.0,
rebound_confirmed=False, **kwargs))
self.assertFalse(exit_triggered(KIND_LONG_T, 10.0, 10.0,
rebound_confirmed=True, **kwargs))
# 正T 平仓不看回落,回落到成本之下不卖
self.assertFalse(exit_triggered(KIND_LONG_T, 9.8, 10.0,
rebound_confirmed=True, **kwargs))
def test_missing_basis_never_triggers(self):
self.assertFalse(exit_triggered(KIND_LONG_T, 12.0, 0.0,
buy_fall_pct=1.0, profit_step_pct=1.0,
rebound_confirmed=True))
self.assertFalse(exit_triggered('???', 12.0, 10.0, buy_fall_pct=1.0,
profit_step_pct=1.0, rebound_confirmed=True))
if __name__ == '__main__':
unittest.main()

View File

@@ -1,110 +0,0 @@
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.grid_take_profit import GridState
from libs.state import State
from sdk import Assets, DealItem, PositionItem, Tick
from strategy.zt import boot
from strategy.zt.positions import manage_positions
class ZTTradingTests(unittest.TestCase):
def setUp(self):
self.code = '600000.SH'
self.run = NS(account_cfg=NS(account_id='test', strategy='zt', buy_value=1000, zt_open_hands=1,
excluded_codes=[], enable_loss_add_position=False,
min_cash_ratio=0.1),
orders=Mock(), client=Mock(), profit_tracker=Mock(), add_watch=Mock())
self.run.orders.busy.return_value = False
self.run.orders.place.return_value = True
self.run.profit_tracker.observe.return_value.state = GridState.RETREAT
self.run.add_watch.triggered.return_value = True
def manage(self, added=0, usable=500, road=0, cost=10, added_cost=10, price=11):
position = PositionItem(stock_code=self.code, volume=1000, can_use_volume=usable,
on_road_volume=road, open_price=cost)
state = NS(blocked_codes=set(), get_by_code=lambda code: dict(
base_qty=500, added_qty=added, added_price=added_cost))
manage_positions(self.run, {self.code: Tick(last_price=price)}, [position], True, 1500, state)
def test_added_position_is_capped_by_sellable_inventory(self):
for added, usable, expected in [(500, 100, 100), (100, 500, 100), (0, 500, 500)]:
with self.subTest(added=added, usable=usable):
self.run.orders.place.reset_mock()
self.manage(added=added, usable=usable)
self.assertEqual(self.run.orders.place.call_args.args[1].volume, expected)
def test_zero_sellable_does_not_divide_by_default_added_cost(self):
with patch('strategy.zt.positions.log.exception') as error:
self.manage(usable=0, added_cost=0)
error.assert_not_called()
self.run.orders.place.assert_not_called()
def test_unavailable_shares_do_not_disable_loss_management(self):
self.run.account_cfg.enable_loss_add_position = True
self.manage(usable=0, cost=20, price=10, added_cost=0)
self.assertEqual(self.run.orders.place.call_args.args[1].op, 23)
def test_on_road_shares_do_not_disable_available_base(self):
self.manage(road=100)
self.assertEqual(self.run.orders.place.call_args.args[1].volume, 500)
def test_added_cost_is_used_even_if_base_cost_is_higher(self):
self.manage(added=100, cost=20, added_cost=10, price=11)
self.assertEqual(self.run.orders.place.call_args.args[1].volume, 100)
def test_invalid_selected_cost_never_trades(self):
for cost in [0, -1, float('nan'), float('inf')]:
with self.subTest(cost=cost), patch('strategy.zt.positions.log.exception') as error:
self.manage(added=100, added_cost=cost)
error.assert_not_called()
self.run.orders.place.assert_not_called()
def start(self, client, directory):
self.run.account_cfg.grid_step_pct = 1
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
self.run.account_cfg.signal_allow = []
with patch.object(boot, 'Client', return_value=client), \
patch.object(boot.config, 'global_config', global_cfg), \
patch.object(boot.config, 'account_config', self.run.account_cfg), \
patch.object(boot, 'init_signals', return_value=[]), \
patch.object(boot, 'cache_portfolio'), patch.object(boot, 'Overview'), \
patch.object(boot.time, 'localtime', return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
boot.StartZT()
def test_start_initializes_once_without_snapshot_retry_loop(self):
client = Mock()
client.deals.return_value = []
client.portfolio.return_value = NS(assets=Assets(10000, 10000),
positions={self.code: PositionItem(stock_code=self.code, volume=100, open_price=10)}, orders=[])
with tempfile.TemporaryDirectory() as tmp:
self.start(client, tmp)
store = State(Path(tmp) / 'zt_test_state.db')
self.assertEqual(store.state[self.code]['base_qty'], 100)
with closing(store._connect()) as db:
self.assertIsNone(db.execute("SELECT 1 FROM sqlite_master WHERE name='state_meta'").fetchone())
self.assertEqual(client.portfolio.call_count, 1)
self.assertEqual(client.deals.call_count, 2)
client.reset_mock()
self.start(client, tmp)
self.assertEqual(client.portfolio.call_count, 1)
self.assertEqual(client.deals.call_count, 1)
def test_start_rejects_changed_deals_without_writing_baseline(self):
client = Mock()
client.deals.side_effect = [[], [DealItem(order_sys_id='new')]]
client.portfolio.return_value = NS(assets=Assets(10000, 10000), positions={}, orders=[])
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
self.start(client, tmp)
store = State(Path(tmp) / 'zt_test_state.db')
self.assertEqual((store.state, store.deals), ({}, {}))
client.close.assert_called_once()
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,125 @@
"""ZT 新路径测试脚手架:真实 Runtime/OrderBook/DipWatch + 模拟客户端。"""
import tempfile
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock
from libs.grid_take_profit import GridTrailingTracker
from libs.order import OrderBook
from libs.runtime import Runtime
from libs.watch import DipWatch
from sdk import Assets, DealItem, PositionItem, Tick
from strategy.zt.rounds import Round, RoundStore, start_round
ACCOUNT = 'zt-test'
def account_cfg(**overrides):
cfg = NS(account_id=ACCOUNT, strategy='zt', host_key='test',
grid_step_pct=1.0, zt_open_hands=1, zt_sell_ratio=0.5,
zt_buy_fall_pct=1.0, zt_max_price=200.0, zt_t_band_pct=1.0,
zt_max_hold_days=5, min_cash_ratio=0.1,
excluded_codes=[], signal_allow=['dcm'], buy_value=10000.0)
for key, value in overrides.items():
setattr(cfg, key, value)
return cfg
def global_cfg(**overrides):
cfg = NS(qmt_base_url='http://unused', qmt_token='', api_host='http://unused',
qmt_data_dir='.', signals={})
for key, value in overrides.items():
setattr(cfg, key, value)
return cfg
class Fixture:
"""一套隔离的账户快照、轮次存储与运行上下文。"""
def __init__(self, **cfg_overrides):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name)
self.rounds_path = self.path / f'zt_{ACCOUNT}_rounds.json'
self.store = RoundStore(self.rounds_path)
self.account_cfg = account_cfg(**cfg_overrides)
self.assets = Assets(total=100000.0, available=100000.0)
self.positions = {}
self.orders = []
self.deals = []
self.ticks = {}
self.client = self._client()
self.run = Runtime(
client=self.client, global_cfg=global_cfg(),
account_cfg=self.account_cfg,
orders=OrderBook(cancel_timeout_sec=300),
open_watch=DipWatch(expire_seconds=600, rebound_threshold=0.0),
add_watch=DipWatch(expire_seconds=600, rebound_threshold=0.0),
profit_tracker=GridTrailingTracker(self.account_cfg.grid_step_pct),
)
def _client(self):
client = Mock()
client.deals.side_effect = lambda: self.deals
client.portfolio.side_effect = lambda: NS(
assets=self.assets, positions=self.positions, orders=self.orders)
client.full_tick.side_effect = lambda codes: dict(self.ticks)
return client
def cleanup(self):
self.tmp.cleanup()
# ---- 便捷构造 ----
def hold(self, code='600000.SH', volume=1000, price=10.0, can_use=None):
position = PositionItem(stock_code=code, volume=volume, open_price=price,
can_use_volume=volume if can_use is None else can_use)
self.positions[code] = position
return position
def quote(self, code='600000.SH', price=10.0):
self.ticks[code] = Tick(last_price=price)
return self.ticks[code]
def deal(self, local_id, volume, price, sys_id=None, code='600000.SH'):
return DealItem(stock_code=code, order_sys_id=sys_id or f'{local_id}-{volume}',
remark=local_id, offset_flag=48, volume=volume, price=price,
trade_amount=price * volume, trade_date='20260915',
trade_time='100000')
def prime(self, watch, code, price):
"""让 DipWatch 先建立观察点,下一次同价或更高价即满足反弹确认。"""
watch.triggered('prime', code, price)
def tick(self, signals=()):
"""跑一轮 RunOnce绕过真实时钟的交易时段判断。"""
from unittest.mock import patch
from strategy.zt import boot
with patch.object(boot, 'trading_time', return_value=True):
boot.RunOnce(self.run, self.store, list(signals))
return self.store
def own_base(self, code='600000.SH', qty=1000, cost=10.0, today='2026-09-15'):
"""把该证券标记为"本策略自己建仓"(模拟建仓腿已成交)。"""
item = Round(code=code, base_qty=qty, base_cost=cost, base_date=today,
base_source='opened', phase='CLOSED', outcome='base')
self.store.put(item)
self.store.save()
return item
def open_round(self, code='600000.SH', kind='LONG_T', order_id='zt-entry-1',
today='2026-09-15', **fields):
"""写入一条已提交开仓腿的轮次记录。"""
item = Round(code=code, base_qty=fields.pop('base_qty', 1000),
base_cost=fields.pop('base_cost', 10.0),
base_source=fields.pop('base_source', 'opened'))
start_round(item, kind, today)
item.entry_order_id = order_id
for key, value in fields.items():
setattr(item, key, value)
self.store.put(item)
self.store.save()
return item
@property
def placed(self):
return [call.kwargs for call in self.client.passorder.call_args_list]