fix bug
This commit is contained in:
@@ -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-08(Trend 超时撤单覆盖手工单与其他策略单)仍未修复**:修复会改变 Trend 的
|
||||
> 撤单范围,ZT 侧已限定为 `zt-` 前缀,Trend 侧待单独决定。
|
||||
|
||||
复审日期:2026-09-13。基线:`67b46ca0492cccdda8a41263b727a56df4a637c1` 加本次读取的工作区代码,包含手工修改及未提交文件。保留原文件名以便沿用引用。
|
||||
|
||||
范围:三个策略及直接相关的委托簿、状态存储、SDK、服务端成交映射、网格和快照模块。本次只更新报告,不修改策略、测试或生产数据,不连接交易账户。
|
||||
|
||||
148
docs/zt.md
148
docs/zt.md
@@ -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` 已删除,不再读写;旧库保留为
|
||||
审计记录。不接管账户已有持仓:升级后只有本策略新建立的底仓会进入轮次,
|
||||
老持仓保持不动。
|
||||
|
||||
@@ -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决策]` 每只证券每轮一行,写明最终动作或等待原因。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
38
py-client/strategy/zt/ownership.py
Normal file
38
py-client/strategy/zt/ownership.py
Normal 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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
379
py-client/strategy/zt/rounds.py
Normal file
379
py-client/strategy/zt/rounds.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""ZT 做 T 轮次状态:一只股票同时最多一轮,允许跨日持有。
|
||||
|
||||
正T(``LONG_T``)与反T(``SHORT_T``)共用同一组字段,区别只是两条腿的方向:
|
||||
|
||||
正T:entry=BUY exit=SELL 低吸 → 高抛
|
||||
反T:entry=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)
|
||||
90
py-client/strategy/zt/rules.py
Normal file
90
py-client/strategy/zt/rules.py
Normal 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
|
||||
@@ -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_flag:48 买入、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()
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
475
py-client/tests/test_zt_boot.py
Normal file
475
py-client/tests/test_zt_boot.py
Normal 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()
|
||||
104
py-client/tests/test_zt_config.py
Normal file
104
py-client/tests/test_zt_config.py
Normal 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()
|
||||
@@ -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()
|
||||
177
py-client/tests/test_zt_ownership.py
Normal file
177
py-client/tests/test_zt_ownership.py
Normal 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()
|
||||
420
py-client/tests/test_zt_rounds.py
Normal file
420
py-client/tests/test_zt_rounds.py
Normal 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()
|
||||
152
py-client/tests/test_zt_rules.py
Normal file
152
py-client/tests/test_zt_rules.py
Normal 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()
|
||||
@@ -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()
|
||||
125
py-client/tests/zt_harness.py
Normal file
125
py-client/tests/zt_harness.py
Normal 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]
|
||||
Reference in New Issue
Block a user