diff --git a/docs/strategy-audit-2026-09-12.md b/docs/strategy-audit-2026-09-12.md index a8229f0..b55a9da 100644 --- a/docs/strategy-audit-2026-09-12.md +++ b/docs/strategy-audit-2026-09-12.md @@ -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、服务端成交映射、网格和快照模块。本次只更新报告,不修改策略、测试或生产数据,不连接交易账户。 diff --git a/docs/zt.md b/docs/zt.md index 5bdc136..02d5f07 100644 --- a/docs/zt.md +++ b/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` 已删除,不再读写;旧库保留为 +审计记录。不接管账户已有持仓:升级后只有本策略新建立的底仓会进入轮次, +老持仓保持不动。 diff --git a/py-client/README.md b/py-client/README.md index 91da137..3774195 100644 --- a/py-client/README.md +++ b/py-client/README.md @@ -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决策]` 每只证券每轮一行,写明最终动作或等待原因。 diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index 3ebaaf9..a0770f7 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -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 diff --git a/py-client/etc/cai_cai.yaml b/py-client/etc/cai_cai.yaml index ec1532f..3a21c91 100644 --- a/py-client/etc/cai_cai.yaml +++ b/py-client/etc/cai_cai.yaml @@ -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 diff --git a/py-client/etc/dev.yaml b/py-client/etc/dev.yaml index 2a657b6..44d54a2 100644 --- a/py-client/etc/dev.yaml +++ b/py-client/etc/dev.yaml @@ -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 diff --git a/py-client/etc/fu_xing.yaml b/py-client/etc/fu_xing.yaml index 4389afa..7572bb4 100644 --- a/py-client/etc/fu_xing.yaml +++ b/py-client/etc/fu_xing.yaml @@ -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 diff --git a/py-client/etc/hu.yaml b/py-client/etc/hu.yaml index ab9c4b4..2d1097b 100644 --- a/py-client/etc/hu.yaml +++ b/py-client/etc/hu.yaml @@ -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 diff --git a/py-client/etc/liao.yaml b/py-client/etc/liao.yaml index 6789efd..8502048 100644 --- a/py-client/etc/liao.yaml +++ b/py-client/etc/liao.yaml @@ -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 diff --git a/py-client/etc/long.yaml b/py-client/etc/long.yaml index 042e9bb..1f28b71 100644 --- a/py-client/etc/long.yaml +++ b/py-client/etc/long.yaml @@ -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 diff --git a/py-client/etc/test.yaml b/py-client/etc/test.yaml index 8818f83..a4fdb40 100644 --- a/py-client/etc/test.yaml +++ b/py-client/etc/test.yaml @@ -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 diff --git a/py-client/etc/tong_zhao.yaml b/py-client/etc/tong_zhao.yaml index 10ce245..7d8e02a 100644 --- a/py-client/etc/tong_zhao.yaml +++ b/py-client/etc/tong_zhao.yaml @@ -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 diff --git a/py-client/etc/wen_ting.yaml b/py-client/etc/wen_ting.yaml index 4602070..7fa76b5 100644 --- a/py-client/etc/wen_ting.yaml +++ b/py-client/etc/wen_ting.yaml @@ -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 diff --git a/py-client/etc/xiao_dong.yaml b/py-client/etc/xiao_dong.yaml index ea1932a..5c1fdfc 100644 --- a/py-client/etc/xiao_dong.yaml +++ b/py-client/etc/xiao_dong.yaml @@ -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 diff --git a/py-client/etc/yanweidong.yaml b/py-client/etc/yanweidong.yaml index 519bab4..b9752d2 100644 --- a/py-client/etc/yanweidong.yaml +++ b/py-client/etc/yanweidong.yaml @@ -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 diff --git a/py-client/etc/yin_fei.yaml b/py-client/etc/yin_fei.yaml index 0e023d1..be139bd 100644 --- a/py-client/etc/yin_fei.yaml +++ b/py-client/etc/yin_fei.yaml @@ -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 diff --git a/py-client/etc/zhang.yaml b/py-client/etc/zhang.yaml index 43e933d..b09ebec 100644 --- a/py-client/etc/zhang.yaml +++ b/py-client/etc/zhang.yaml @@ -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 diff --git a/py-client/libs/state.py b/py-client/libs/state.py deleted file mode 100644 index 11a3fce..0000000 --- a/py-client/libs/state.py +++ /dev/null @@ -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) diff --git a/py-client/strategy/zt/boot.py b/py-client/strategy/zt/boot.py index 56a30dd..f33037d 100644 --- a/py-client/strategy/zt/boot.py +++ b/py-client/strategy/zt/boot.py @@ -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) - - DEFAULT_TICK_INTERVAL = 30 + _log_startup(run, store, signals) + 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 diff --git a/py-client/strategy/zt/open.py b/py-client/strategy/zt/open.py deleted file mode 100644 index 4a6497a..0000000 --- a/py-client/strategy/zt/open.py +++ /dev/null @@ -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 diff --git a/py-client/strategy/zt/ownership.py b/py-client/strategy/zt/ownership.py new file mode 100644 index 0000000..052cd68 --- /dev/null +++ b/py-client/strategy/zt/ownership.py @@ -0,0 +1,38 @@ +"""ZT 委托与成交的归属判定。 + +本地订单号由 ``OrderBook.new_order_id`` 生成,形如 ``zt-base-``、 +``zt-added-``、``zt-SELL-``。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) diff --git a/py-client/strategy/zt/positions.py b/py-client/strategy/zt/positions.py deleted file mode 100644 index 84981cf..0000000 --- a/py-client/strategy/zt/positions.py +++ /dev/null @@ -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 diff --git a/py-client/strategy/zt/profit.py b/py-client/strategy/zt/profit.py deleted file mode 100644 index 33b2432..0000000 --- a/py-client/strategy/zt/profit.py +++ /dev/null @@ -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) diff --git a/py-client/strategy/zt/rounds.py b/py-client/strategy/zt/rounds.py new file mode 100644 index 0000000..31c1424 --- /dev/null +++ b/py-client/strategy/zt/rounds.py @@ -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) diff --git a/py-client/strategy/zt/rules.py b/py-client/strategy/zt/rules.py new file mode 100644 index 0000000..8f2f417 --- /dev/null +++ b/py-client/strategy/zt/rules.py @@ -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 diff --git a/py-client/tests/test_deal_model.py b/py-client/tests/test_deal_model.py index d644789..2944218 100644 --- a/py-client/tests/test_deal_model.py +++ b/py-client/tests/test_deal_model.py @@ -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() diff --git a/py-client/tests/test_orderbook.py b/py-client/tests/test_orderbook.py index b0ab492..0f4a933 100644 --- a/py-client/tests/test_orderbook.py +++ b/py-client/tests/test_orderbook.py @@ -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__': diff --git a/py-client/tests/test_state_storage.py b/py-client/tests/test_state_storage.py deleted file mode 100644 index 9588302..0000000 --- a/py-client/tests/test_state_storage.py +++ /dev/null @@ -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() diff --git a/py-client/tests/test_zt_audit_fixes.py b/py-client/tests/test_zt_audit_fixes.py deleted file mode 100644 index daf0a77..0000000 --- a/py-client/tests/test_zt_audit_fixes.py +++ /dev/null @@ -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() diff --git a/py-client/tests/test_zt_boot.py b/py-client/tests/test_zt_boot.py new file mode 100644 index 0000000..b68f3b4 --- /dev/null +++ b/py-client/tests/test_zt_boot.py @@ -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() diff --git a/py-client/tests/test_zt_config.py b/py-client/tests/test_zt_config.py new file mode 100644 index 0000000..f714177 --- /dev/null +++ b/py-client/tests/test_zt_config.py @@ -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() diff --git a/py-client/tests/test_zt_open_hands.py b/py-client/tests/test_zt_open_hands.py deleted file mode 100644 index 13cdc0c..0000000 --- a/py-client/tests/test_zt_open_hands.py +++ /dev/null @@ -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() diff --git a/py-client/tests/test_zt_ownership.py b/py-client/tests/test_zt_ownership.py new file mode 100644 index 0000000..9bfb06b --- /dev/null +++ b/py-client/tests/test_zt_ownership.py @@ -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() diff --git a/py-client/tests/test_zt_rounds.py b/py-client/tests/test_zt_rounds.py new file mode 100644 index 0000000..300cca6 --- /dev/null +++ b/py-client/tests/test_zt_rounds.py @@ -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() diff --git a/py-client/tests/test_zt_rules.py b/py-client/tests/test_zt_rules.py new file mode 100644 index 0000000..1a961be --- /dev/null +++ b/py-client/tests/test_zt_rules.py @@ -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() diff --git a/py-client/tests/test_zt_trading.py b/py-client/tests/test_zt_trading.py deleted file mode 100644 index 2855f24..0000000 --- a/py-client/tests/test_zt_trading.py +++ /dev/null @@ -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() diff --git a/py-client/tests/zt_harness.py b/py-client/tests/zt_harness.py new file mode 100644 index 0000000..9299832 --- /dev/null +++ b/py-client/tests/zt_harness.py @@ -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]