release: v1.30.1 — 移除 ZIG 未来函数指标与 zig_breakout 策略:回测数字可信优先

This commit is contained in:
Justin Gu
2026-09-03 02:02:19 +08:00
parent 1e95bab476
commit 1433f4eed0
14 changed files with 21 additions and 508 deletions
+10
View File
@@ -2,6 +2,16 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.30.1] — 2026-09-03
**彻底移除 ZIG 之字转向指标与 `zig_breakout` 策略**——ZIG 是教科书级的**未来函数**:波峰/波谷拐点只有在**其后**的走势反向走满 X% 确认转向后才会**回溯标出**,也就是说序列里每个拐点的位置都用到了"当时不可能知道"的未来信息。把它当买卖信号回测,等于允许策略在波谷那一天精准买入、在见顶前一天精准卖出——收益必然严重虚高、参数寻优必然过拟合,回测结果与实盘表现脱节,**这正是本工具最要防的失真**。
### 移除
- **为什么必须删,而不是加警示**——v1.29 引入时已尝试用「硬止损 + 右侧突破确认进场」两层保护对冲前视偏差,但那只能**缓解**而非**消除**:`zig_breakout` 的建仓信号"ZIG 向上启动(波谷确认)"本质上仍是偷看未来的产物,建立其上的任何回测数字、寻优选参、AI 解读都自带系统性偏差。铁证就在本项目自己的黄金基线里:`zig_breakout` 在固定样本上胜率 **100%**、总收益 **+88.2%**、夏普 **1.73**——一组完美得不像真话的数字,恰恰是前视偏差的签名(实盘信号天然滞后于回测拐点,实盘表现只会大幅劣于回测)。回测工具的生命线是"数字可信":与其留着一个数字必然失真的指标靠 docstring 警告自律(挡不住复制粘贴),不如删掉——**宁可少一个指标,不给用户一个实盘必然打脸的完美陷阱**。
- **移除范围(三端同步消失)**——① `MyTT.ZIG` 指标函数(`src/easy_tdx/MyTT.py`);② 内置策略 `zig_breakout``backtest/strategies/builtin.py` 注册表删除后,CLI 回测、Web API、WebUI 回测策略下拉与对比页自动不再出现);③ 参数寻优预设网格(`presets.py`)与「一键寻优所有策略」中的对应条目;④ 仓库根示例策略 `strategies/zig_breakout.py``--strategy-file` 入口);⑤ 单测 `test_mytt_zig.py` / `test_zig_strategy.py` 删除,向量化对拍测试的路径依赖白名单随之清空(其余全部 19 个内置策略均可向量化对拍),黄金基线 `backtest_metrics.json` 移除 zig_breakout 条目(其余策略零漂移);⑥ README / strategies/README / 架构文档中的相关说明。
- **兼容性影响(有意为之)**——外部脚本 `from easy_tdx.MyTT import ZIG` 将抛 `ImportError`,引用 `zig_breakout` 的回测命令 / API 调用将报"策略不存在":**快速失败好过静默失真**。需要之字转向类形态分析的用户,可自行基于已确认 K 线实现不含未来函数的右侧确认版本,或改用海龟突破(`turtle_breakout`/ 唐奇安通道(`donchian`)等天然右侧的策略。
## [1.29.2] — 2026-09-02 ## [1.29.2] — 2026-09-02
**可编辑安装失效时给出友好报错**[#58](https://github.com/handsomejustin/easy_tdx/discussions/58))——`pip install -e .` 会在 site-packages 写入 `_editable_impl_easy_tdx.pth`(内容为仓库 `src/` 绝对路径)。仓库目录被移动/重命名/重新 clone 或该文件丢失后,`easy-tdx` 只会抛出一句无法定位的 `No module named 'easy_tdx.cli'``easy_tdx` 本体因 site-packages 里的 `web/dist` 命名空间碎片仍可导入,报错极具误导性(实测复现,失效态 `easy_tdx.__path__` 只剩 site-packages 碎片路径)。 **可编辑安装失效时给出友好报错**[#58](https://github.com/handsomejustin/easy_tdx/discussions/58))——`pip install -e .` 会在 site-packages 写入 `_editable_impl_easy_tdx.pth`(内容为仓库 `src/` 绝对路径)。仓库目录被移动/重命名/重新 clone 或该文件丢失后,`easy-tdx` 只会抛出一句无法定位的 `No module named 'easy_tdx.cli'``easy_tdx` 本体因 site-packages 里的 `web/dist` 命名空间碎片仍可导入,报错极具误导性(实测复现,失效态 `easy_tdx.__path__` 只剩 site-packages 碎片路径)。
+1 -1
View File
@@ -30,7 +30,7 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普
**这一步的意义**:取数自由(v1.0)→ 回测自由(v1.17)→ 防过拟合(v1.25)→ **认知自由(v1.29**。前几步解决「数据与工具的不平等」,这一步解决「解读能力的不平等」——机构有研究员写晨报,你现在有自己的 AI 分析师。仍是 MIT 开源、零注册、零平台费,唯一成本是你自己模型的调用费(用 Ollama 本地模型连这个都是零)。 **这一步的意义**:取数自由(v1.0)→ 回测自由(v1.17)→ 防过拟合(v1.25)→ **认知自由(v1.29**。前几步解决「数据与工具的不平等」,这一步解决「解读能力的不平等」——机构有研究员写晨报,你现在有自己的 AI 分析师。仍是 MIT 开源、零注册、零平台费,唯一成本是你自己模型的调用费(用 Ollama 本地模型连这个都是零)。
**v1.29 同场加映**(借鉴社区 Fork [swimmingaaron/easy_tdx](https://github.com/swimmingaaron/easy_tdx) 的好想法,逐项甄别后移植):**ZIG 右侧突破回补策略**(MyTT 新增之字转向指标,波谷建仓带硬止损 + 见顶记前高 + 右侧突破回补,对冲未来函数的前视偏差);**交易时段感知刷新**(看板轮询休市自动暂停、三态状态栏、手动刷新不受限,不再半夜空转刷数据);**120 分钟 K 线**(MAC 原生两级降级获取)与**逐 bar 涨跌幅/振幅字段**(`pre_close`/`change_pct`/`amplitude_pct` 直接返回);**159 只核心龙头池**(东财全行业龙头名单 → `universe=core` 一键只扫龙头,配「龙头池」页面;名单仅为扫描范围,不构成任何推荐);全站**风险提示与免责声明**常驻。WebUI 工程加固:未知 API 路径返回 JSON 404 而非 HTML、入口页 `no-store` 防缓存旧版——更新即所见。 **v1.29 同场加映**(借鉴社区 Fork [swimmingaaron/easy_tdx](https://github.com/swimmingaaron/easy_tdx) 的好想法,逐项甄别后移植):**交易时段感知刷新**(看板轮询休市自动暂停、三态状态栏、手动刷新不受限,不再半夜空转刷数据);**120 分钟 K 线**(MAC 原生两级降级获取)与**逐 bar 涨跌幅/振幅字段**(`pre_close`/`change_pct`/`amplitude_pct` 直接返回);**159 只核心龙头池**(东财全行业龙头名单 → `universe=core` 一键只扫龙头,配「龙头池」页面;名单仅为扫描范围,不构成任何推荐);全站**风险提示与免责声明**常驻。WebUI 工程加固:未知 API 路径返回 JSON 404 而非 HTML、入口页 `no-store` 防缓存旧版——更新即所见。
**期货持仓雷达:中金所成交持仓排名(v1.29.1 新增)**——每个交易日收盘后约 16:15,中金所官网公布各期货品种「成交量 / 持买单量(多单)/ 持卖单量(空单)」各前 20 名期货公司会员的排名,这是散户能免费看到的**最接近"主力动向"的公开数据**。本工具把它变成一键采集:CLI 一行命令(`easy-tdx ccpm IF --table`)、Web API`GET /api/v1/ccpm/rank`)、WebUI「期货持仓排名」页(品种下拉 + 日期选择器 + 一键采集,合约页签自动标注主力合约,前 20 名合计多单/空单/净持仓概览,三组排名并排表格)。覆盖 IF 沪深300 / IH 上证50 / IC 中证500 / IM 中证1000 四大股指期货与 TS/TF/T/TL 2/5/10/30 年期国债期货;非交易日与未发布自动识别并可回溯最近交易日;每个交易日数据发布后不可变,按日落盘缓存(`~/.easy_tdx/cache/ccpm/`),历史二次查询零网络。页面附三段新手科普:「(代客)」是什么意思、多单/空单/加减仓怎么看、以及最重要的一条——**排名表看不出是套保还是投机,空单多 ≠ 看空市场**(股指期货空单大头常是机构套保盘)。 **期货持仓雷达:中金所成交持仓排名(v1.29.1 新增)**——每个交易日收盘后约 16:15,中金所官网公布各期货品种「成交量 / 持买单量(多单)/ 持卖单量(空单)」各前 20 名期货公司会员的排名,这是散户能免费看到的**最接近"主力动向"的公开数据**。本工具把它变成一键采集:CLI 一行命令(`easy-tdx ccpm IF --table`)、Web API`GET /api/v1/ccpm/rank`)、WebUI「期货持仓排名」页(品种下拉 + 日期选择器 + 一键采集,合约页签自动标注主力合约,前 20 名合计多单/空单/净持仓概览,三组排名并排表格)。覆盖 IF 沪深300 / IH 上证50 / IC 中证500 / IM 中证1000 四大股指期货与 TS/TF/T/TL 2/5/10/30 年期国债期货;非交易日与未发布自动识别并可回溯最近交易日;每个交易日数据发布后不可变,按日落盘缓存(`~/.easy_tdx/cache/ccpm/`),历史二次查询零网络。页面附三段新手科普:「(代客)」是什么意思、多单/空单/加减仓怎么看、以及最重要的一条——**排名表看不出是套保还是投机,空单多 ≠ 看空市场**(股指期货空单大头常是机构套保盘)。
+2 -2
View File
@@ -194,8 +194,8 @@ const LAYERS = [
d:['DeepSeek · Qwen · 智谱 · Kimi · MiniMax…','OpenAI / Anthropic 兼容 + Ollama 本地'], d:['DeepSeek · Qwen · 智谱 · Kimi · MiniMax…','OpenAI / Anthropic 兼容 + Ollama 本地'],
tip:'LlmClient 用标准库 urllib 实现(零三方依赖):Provider 预设 + ~/.easy_tdx 配置文件与 WebUI 双向同步,为回测报告生成自然语言解读,对话历史落 SQLite。' }, tip:'LlmClient 用标准库 urllib 实现(零三方依赖):Provider 预设 + ~/.easy_tdx 配置文件与 WebUI 双向同步,为回测报告生成自然语言解读,对话历史落 SQLite。' },
{ t:'示例策略库', s:'strategies/(仓库根目录)', { t:'示例策略库', s:'strategies/(仓库根目录)',
d:['~19 个 Strategy 子类','MA / MACD / 海龟 / ZIG / 捉妖 / 乖离 …','运行时动态加载进回测与扫描'], d:['~16 个 Strategy 子类','MA / MACD / 海龟 / 捉妖 / 乖离 …','运行时动态加载进回测与扫描'],
tip:'仓库根 strategies/ 目录:ma_cross、macd_cross、turtle_breakout、zig_breakout、zhuoyao_momentum、bias_reversal 等 19 个示例策略文件,由 CLIbacktest --strategy-file、run-all --strategies-dir)与选股扫描在运行时动态 import;与包内 backtest/strategies/ 注册表相互独立。' } tip:'仓库根 strategies/ 目录:ma_cross、macd_cross、turtle_breakout、zhuoyao_momentum、bias_reversal 等 16 个示例策略文件,由 CLIbacktest --strategy-file、run-all --strategies-dir)与选股扫描在运行时动态 import;与包内 backtest/strategies/ 注册表相互独立。' }
] ]
] ]
}, },
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.29.2" version = "1.30.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
-113
View File
@@ -571,117 +571,4 @@ def FSL(CLOSE, VOL, CAPITAL): # 分水岭指标:多空趋势强弱分界(SW
return RD(SWL), RD(SWS) return RD(SWL), RD(SWS)
def ZIG(S, X=35): # 之字转向指标(未来函数):S为价格序列,X为转向阈值百分比(如10表示10%)
"""之字转向指标 (ZigZag) — 经典未来函数。
当价格从前一个极值点反向变动超过 X% 时确立波峰/波谷拐点并转向,
拐点之间线性插值,返回与 S 等长的拟合序列。
注意:拐点只有在**其后**的走势确认了转向才会回溯标出,序列中波峰/
波谷位置含有未来信息。把 ZIG 拐点直接当买卖信号回测会严重高估收益
(前视偏差);如需使用,必须配合右侧确认或止损保护(参见内置策略
``zig_breakout`` 的做法)。
Args:
S: 价格序列(通常为 CLOSE)
X: 转向阈值百分比。10 表示 10%;也可传小数形式 0.1(以 1.0 为界
自动区分,故阈值本身小于 1% 时请用小数形式)
Returns:
np.ndarray: 与 S 等长的 ZIG 之字转向插值序列
"""
S = np.asarray(S, dtype=float)
n = len(S)
if n == 0:
return np.array([], dtype=float)
if n == 1:
return S.copy()
x = float(X) / 100.0 if float(X) > 1.0 else float(X)
if x <= 0:
return S.copy()
ZIG_STATE_START = 0
ZIG_STATE_RISE = 1
ZIG_STATE_FALL = 2
peer_i = 0
candidate_i = None
peers = [0]
state = ZIG_STATE_START
for scan_i in range(1, n):
if scan_i == n - 1:
# 扫描到序列尾部:未确立的候选极值按当前方向收尾
if candidate_i is None:
peers.append(scan_i)
else:
if state == ZIG_STATE_RISE:
if S[scan_i] >= S[candidate_i]:
peers.append(scan_i)
else:
peers.append(candidate_i)
if candidate_i != scan_i:
peers.append(scan_i)
elif state == ZIG_STATE_FALL:
if S[scan_i] <= S[candidate_i]:
peers.append(scan_i)
else:
peers.append(candidate_i)
if candidate_i != scan_i:
peers.append(scan_i)
else:
peers.append(scan_i)
break
if state == ZIG_STATE_START:
if S[peer_i] != 0:
if S[scan_i] >= S[peer_i] * (1.0 + x):
candidate_i = scan_i
state = ZIG_STATE_RISE
elif S[scan_i] <= S[peer_i] * (1.0 - x):
candidate_i = scan_i
state = ZIG_STATE_FALL
elif state == ZIG_STATE_RISE:
if S[scan_i] >= S[candidate_i]:
candidate_i = scan_i
elif S[candidate_i] != 0 and S[scan_i] <= S[candidate_i] * (1.0 - x):
peer_i = candidate_i
peers.append(peer_i)
state = ZIG_STATE_FALL
candidate_i = scan_i
elif state == ZIG_STATE_FALL:
if S[scan_i] <= S[candidate_i]:
candidate_i = scan_i
elif S[candidate_i] != 0 and S[scan_i] >= S[candidate_i] * (1.0 + x):
peer_i = candidate_i
peers.append(peer_i)
state = ZIG_STATE_RISE
candidate_i = scan_i
# 去除重复拐点并确保末端对齐
clean_peers = []
for p in peers:
if not clean_peers or p != clean_peers[-1]:
clean_peers.append(p)
if clean_peers[-1] != n - 1:
clean_peers.append(n - 1)
# 拐点间线性插值
z = np.zeros(n, dtype=float)
for i in range(len(clean_peers) - 1):
p_start = clean_peers[i]
p_end = clean_peers[i + 1]
v_start = S[p_start]
v_end = S[p_end]
if p_end == p_start:
z[p_start] = v_start
else:
slope = (v_end - v_start) / (p_end - p_start)
for j in range(p_end - p_start + 1):
z[p_start + j] = v_start + slope * j
return RD(z)
# 望大家能提交更多指标和函数 https://github.com/mpquant/MyTT # 望大家能提交更多指标和函数 https://github.com/mpquant/MyTT
-112
View File
@@ -30,7 +30,6 @@ from easy_tdx.MyTT import (
EMA, EMA,
EMV, EMV,
FSL, FSL,
HHV,
KDJ, KDJ,
KTN, KTN,
MA, MA,
@@ -39,7 +38,6 @@ from easy_tdx.MyTT import (
TAQ, TAQ,
TRIX, TRIX,
WR, WR,
ZIG,
) )
__all__: list[str] = [] # 注册副作用即可,无需导出符号 __all__: list[str] = [] # 注册副作用即可,无需导出符号
@@ -693,113 +691,3 @@ class FslStrategy(ParametrizedStrategy):
def entry_exit_masks(self) -> tuple[Any, Any]: def entry_exit_masks(self) -> tuple[Any, Any]:
"""与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。"""
return self.gold, self.dead return self.gold, self.dead
# ── ZIG 右侧突破回补 ─────────────────────────────────────────────────────────
@register_strategy(
name="zig_breakout",
label="ZIG 右侧突破回补",
description=(
"ZIG 向上启动(波谷确认)全仓买入;ZIG 见顶回落清仓并记录 N 日最高点,"
"其后收盘突破前高×(1+确认比例) 时右侧回补。两路径买入均带硬止损,"
"对冲 ZIG 波谷确认的前视偏差(未来函数,实盘信号会滞后)。"
),
)
class ZigBreakoutStrategy(ParametrizedStrategy):
"""ZIG 右侧突破回补(Re-entry on Breakout + 硬止损保护)。
ZIG 是未来函数:波峰/波谷只有在其后走势确认转向才回溯标出,回测里
"波谷启动"信号天然偷看未来。本策略用两层保护缓解而非消除该偏差:
1. 买入即挂 ``stop_loss_pct`` 硬止损(引擎逐 bar 监控,跌破自动平仓),
假波谷不至于深套;
2. 卖出后不追 ZIG 新波谷,而是等价格**右侧突破**前高确认_pct 再回补,
"猜底"换成"确认后进场"
交易逻辑::
空仓 + ZIG 上行 → 全仓买入(带止损)
持仓 + ZIG 下行(见顶) → 全仓卖出,记录 HHV(high, N) 为前高
空仓 + 收盘 ≥ 前高×(1+确认) → 右侧回补(带止损)
注意:``_breakout_level`` 随持仓路径变化,信号不可向量化,故不实现
``entry_exit_masks``——引擎自动走逐 bar 回放路径(与 next() 完全一致)。
"""
params = [
Param(
"zig_delta",
float,
default=10.0,
min_value=0.5,
max_value=50.0,
label="ZIG转向阈值%",
),
Param(
"confirm_pct",
float,
default=2.0,
min_value=0.1,
max_value=20.0,
label="突破确认比例%",
),
Param("hhv_period", int, default=20, min_value=5, max_value=120, label="前高周期"),
Param(
"stop_loss_pct",
float,
default=3.0,
min_value=0.0,
max_value=30.0,
label="硬止损%",
),
]
def init(self) -> None:
self.zig = self.I(ZIG, self.data.close, self.p["zig_delta"])
self.hhv = self.I(HHV, self.data.high, self.p["hhv_period"])
# 见顶清仓时记录的前高(0 = 未记录,等待首次建仓-见顶周期)
self._breakout_level: float = 0.0
def next(self) -> None:
i = self._bar_index
if i == 0:
return
cur_close = float(self.data.close[0])
cur_zig = float(self.zig[i])
prev_zig = float(self.zig[i - 1])
cur_pos = self.position["size"]
# 持仓:ZIG 见顶回落 → 清仓,并记录突破位(HHV 含未来 bar 已确认的高点)
if cur_pos > 0 and cur_zig < prev_zig:
self._breakout_level = float(self.hhv[i])
self.sell(size=0)
return
if cur_pos == 0:
# 路径 1:ZIG 向上启动(波谷确认)→ 初始建仓
if cur_zig > prev_zig:
self._breakout_level = 0.0
self._buy_with_stop()
return
# 路径 2:右侧突破前高 → 回补(洗盘结束、主升确立)
if self._breakout_level > 0:
threshold = self._breakout_level * (1.0 + self.p["confirm_pct"] / 100.0)
if cur_close >= threshold:
self._breakout_level = 0.0
self._buy_with_stop()
def _buy_with_stop(self) -> None:
"""市价全仓买入并按 ``stop_loss_pct`` 挂硬止损(0 = 不挂)。
市价单(price=None)由引擎在下一根开盘成交,与本地其他内置策略
口径一致,避免信号 bar 收盘价成交的前视味道。
"""
pct = self.p["stop_loss_pct"] / 100.0
if pct > 0:
self.buy(size=0, stop_loss_pct=pct)
else:
self.buy(size=0)
@@ -98,12 +98,6 @@ STRATEGY_PRESETS: dict[str, dict[str, list[Any]]] = {
# capital 仅作粗档扫描(1千万/1亿/10亿股),覆盖小盘→大盘 # capital 仅作粗档扫描(1千万/1亿/10亿股),覆盖小盘→大盘
"capital": [1e7, 1e8, 1e9, 1e10], "capital": [1e7, 1e8, 1e9, 1e10],
}, # 4 }, # 4
# ── 之字转向类 ───────────────────────────────────────────────────────────
"zig_breakout": {
# 转向阈值×确认比例 = 4×3;hhv/止损用默认(HHV20 / 3%
"zig_delta": [5.0, 8.0, 10.0, 15.0],
"confirm_pct": [1.0, 2.0, 3.0],
}, # 12
} }
-1
View File
@@ -24,7 +24,6 @@ easy-tdx backtest SH 600519 --strategy-file strategies/macd_cross.py --cash 5000
| `bias_reversal.py` | 乖离率反转 | 反转 | 震荡回归 | | `bias_reversal.py` | 乖离率反转 | 反转 | 震荡回归 |
| `volume_price.py` | 量价配合 | 综合判断 | 放量突破 | | `volume_price.py` | 量价配合 | 综合判断 | 放量突破 |
| `obv_trend.py` | OBV 能量潮趋势 | 量价趋势 | 资金持续流入的上升趋势 | | `obv_trend.py` | OBV 能量潮趋势 | 量价趋势 | 资金持续流入的上升趋势 |
| `zig_breakout.py` | ZIG 右侧突破回补 | 右侧突破/波段 | 波谷启动建仓,见顶卖出后右侧突破前高回补(带硬止损;ZIG 为未来函数,回测信号有前视性) |
## 编写自定义策略 ## 编写自定义策略
-83
View File
@@ -1,83 +0,0 @@
"""ZIG 右侧突破回补策略(Re-entry on Breakout + 硬止损保护)。
交易逻辑
--------
1. **空仓**:ZIG 向上启动(底部波谷确认)→ 全仓买入建仓,挂硬止损(默认 3%)。
2. **持仓**:ZIG 见顶回落 → 全仓卖出,并记录 N 日最高价为 breakout_level。
3. **空仓等待回补**:收盘价突破 breakout_level × (1 + confirm_pct/100)
→ 右侧突破确认,洗盘结束主升确立,全仓买入回补(同样带硬止损)。
4. **风控保护**:买入后未见顶但跌破止损线,由引擎自动触发止损平仓,
对冲 ZIG 波谷确认的前视偏差(ZIG 是未来函数,拐点回溯标出)。
注意:与内置注册表中的 ``zig_breakout````easy_tdx.backtest.strategies``
同一套逻辑;本文件供 ``--strategy-file`` 离线扫描(``easy-tdx screen scan``
使用,参数硬编码为默认档。
用法::
easy-tdx backtest SZ 300223 --strategy-file strategies/zig_breakout.py --table
easy-tdx screen scan --strategy strategies/zig_breakout.py --universe core
"""
from easy_tdx.backtest import Strategy
from easy_tdx.MyTT import HHV, ZIG
class ZigBreakoutStrategy(Strategy):
"""ZIG 右侧突破回补策略(含硬止损保护)。"""
def __init__(
self,
zig_delta: float = 10.0,
confirm_pct: float = 2.0,
hhv_period: int = 20,
stop_loss_pct: float = 3.0,
) -> None:
super().__init__()
self.zig_delta = zig_delta
self.confirm_pct = confirm_pct
self.hhv_period = hhv_period
self.stop_loss_pct = stop_loss_pct
def init(self) -> None:
self.zig = self.I(ZIG, self.data.close, self.zig_delta)
self.hhv = self.I(HHV, self.data.high, self.hhv_period)
self._breakout_level: float = 0.0
def next(self) -> None:
i = self._bar_index
if i == 0:
return
cur_close = float(self.data.close[0])
cur_zig = float(self.zig[i])
prev_zig = float(self.zig[i - 1])
cur_pos = self.position["size"]
# 持仓:ZIG 见顶 → 全仓卖出,记录突破位
if cur_pos > 0 and cur_zig < prev_zig:
self._breakout_level = float(self.hhv[i])
self.sell(size=0)
return
# 空仓:两种买入路径(均带硬止损)
if cur_pos == 0:
# 路径 1:ZIG 向上启动(底部波谷确认)→ 初始建仓
if cur_zig > prev_zig:
self._breakout_level = 0.0
self._buy_with_stop()
return
# 路径 2:右侧突破前高 → 回补建仓(洗盘结束、主升确立)
if self._breakout_level > 0:
threshold = self._breakout_level * (1.0 + self.confirm_pct / 100.0)
if cur_close >= threshold:
self._breakout_level = 0.0
self._buy_with_stop()
def _buy_with_stop(self) -> None:
pct = self.stop_loss_pct / 100.0
if pct > 0:
self.buy(size=0, stop_loss_pct=pct)
else:
self.buy(size=0)
-13
View File
@@ -336,19 +336,6 @@
"ulcer_index": 0.0, "ulcer_index": 0.0,
"var_95": -0.0, "var_95": -0.0,
"win_rate": 0.0 "win_rate": 0.0
},
"zig_breakout": {
"cvar_95": 0.028842972139600086,
"max_consecutive_losses": 0,
"max_consecutive_wins": 5,
"max_drawdown": 0.08762079023098233,
"sharpe": 1.726724943367625,
"sqn": 3.2661255981598982,
"total_return": 0.881929152129431,
"total_trades": 5,
"ulcer_index": 0.032735452584468416,
"var_95": 0.021750558683001117,
"win_rate": 1.0
} }
} }
} }
+2 -8
View File
@@ -302,18 +302,12 @@ def test_auto_falls_back_on_mask_shape_mismatch() -> None:
def test_vector_path_actually_used_for_builtins() -> None: def test_vector_path_actually_used_for_builtins() -> None:
"""默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。 """默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。
例外白名单:信号依赖路径状态(无法用静态掩码等价表达)的策略 若某策略信号依赖路径状态(无法用静态掩码等价表达),应在此说明并
引擎对它们走逐 bar 回放(与 next() 完全一致),属设计而非回退 考虑引擎走逐 bar 回放的白名单机制(当前无此类策略)
""" """
from easy_tdx.backtest.strategy import Strategy as Base from easy_tdx.backtest.strategy import Strategy as Base
# zig_breakout 的 _breakout_level(见顶清仓后记录的前高)随持仓路径
# 变化,掩码不可表达;见 builtin.py 该策略的注释
path_dependent = {"zig_breakout"}
for name in get_registry().names(): for name in get_registry().names():
if name in path_dependent:
continue
strat_cls = get_registry().get(name).strategy_cls strat_cls = get_registry().get(name).strategy_cls
assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, ( assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, (
f"{name} 未实现 entry_exit_masksauto 将永远走逐 bar" f"{name} 未实现 entry_exit_masksauto 将永远走逐 bar"
+5 -5
View File
@@ -31,18 +31,18 @@ class TestLlmHistoryStore:
def test_context_roundtrip(self, store): def test_context_roundtrip(self, store):
store.add( store.add(
_rec( _rec(
strategy="zig_breakout", strategy="macd",
strategy_label="ZIG 右侧突破回补", strategy_label="MACD 金叉",
symbol="600519", symbol="600519",
category="DAY", category="DAY",
params={"zig_delta": 5.0, "confirm_pct": 2.0}, params={"short": 12, "long": 26},
start_date="2024-01-01", start_date="2024-01-01",
end_date="2025-01-01", end_date="2025-01-01",
) )
) )
it = store.list_all()[0] it = store.list_all()[0]
assert it.strategy == "zig_breakout" and it.symbol == "600519" assert it.strategy == "macd" and it.symbol == "600519"
assert it.params == {"zig_delta": 5.0, "confirm_pct": 2.0} # JSON 往返保真 assert it.params == {"short": 12, "long": 26} # JSON 往返保真
assert it.start_date == "2024-01-01" assert it.start_date == "2024-01-01"
def test_corrupt_params_json_tolerated(self, store, tmp_path): def test_corrupt_params_json_tolerated(self, store, tmp_path):
-66
View File
@@ -1,66 +0,0 @@
"""MyTT.ZIG 之字转向指标单元测试(借鉴 Fork 移植,v1.29)。
覆盖:边界输入(空/单根/零阈值)、单调序列恒等、V 型反转拐点标定、
阈值两种写法(5 与 0.05)等价、输出形状与有限性。
"""
from __future__ import annotations
import numpy as np
from easy_tdx.MyTT import ZIG
def test_zig_empty_and_single():
assert ZIG(np.array([]), 10).size == 0
single = ZIG(np.array([42.0]), 10)
assert single.shape == (1,) and single[0] == 42.0
def test_zig_zero_threshold_returns_self():
s = np.array([1.0, 5.0, 2.0, 8.0])
assert np.array_equal(ZIG(s, 0), s)
def test_zig_monotonic_series_identity():
"""单调序列无拐点,ZIG 退化为自身(RD 保留 3 位小数)。"""
line = np.linspace(1.0, 2.0, 50)
assert np.allclose(ZIG(line, 10), line, atol=1e-3)
def test_zig_v_shape_trough():
"""V 型反转:谷底被标为拐点,前后两段各自线性插值。"""
v = np.concatenate([np.linspace(100.0, 80.0, 30), np.linspace(80.0, 120.0, 40)])
z = ZIG(v, 5)
assert z.shape == v.shape
assert np.isfinite(z).all()
assert abs(z[0] - 100) < 0.01
assert abs(z[-1] - 120) < 0.01
# 谷底(两个 80 中的后者,上升段起点)被精确对齐
assert abs(z.min() - 80) < 0.01
assert abs(z[30] - 80) < 0.01
# 拐点间线性:下降段任意点是两端点的线性插值
assert abs(z[15] - (100 + 80) / 2) < 0.01
def test_zig_threshold_forms_equivalent():
s = 100 + 10 * np.sin(np.arange(80) / 6.0)
assert np.allclose(ZIG(s, 5), ZIG(s, 0.05), atol=1e-9)
def test_zig_zigzag_alternating_peaks():
"""标准锯齿:每个预设峰谷都应成为拐点(ZIG 值在拐点处触及其价格)。"""
seg = [10.0, 13.0, 10.0, 13.0, 10.0, 13.0] # ±30% 摆动,阈值 10% 必转向
s = np.array(seg)
z = ZIG(s, 10)
for i, price in enumerate(seg):
assert abs(z[i] - price) < 0.01, f"锯齿序列每根都是拐点: idx={i}"
def test_zig_noisy_series_shape():
rng = np.random.default_rng(7)
s = 100 + np.cumsum(rng.normal(0, 1.5, 200))
z = ZIG(s, 12)
assert z.shape == s.shape
assert np.isfinite(z).all()
assert z.min() >= s.min() - 1e-3 and z.max() <= s.max() + 1e-3
-97
View File
@@ -1,97 +0,0 @@
"""zig_breakout 内置策略单元测试(借鉴 Fork 移植,v1.29)。
覆盖:注册表登记与参数 schema、合成锯齿行情能产生交易、
止损单挂在买入信号上(OCO bracket)、寻优预设网格登记。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategies import get_registry
from easy_tdx.backtest.strategies.presets import STRATEGY_PRESETS
def _zigzag_df(n: int = 300, seed: int = 42) -> pd.DataFrame:
"""先跌后大涨再回调的合成行情(触发 ZIG 波谷启动与见顶清仓)。"""
rng = np.random.default_rng(seed)
trend = np.concatenate(
[
np.linspace(100, 80, n // 3),
np.linspace(80, 130, n * 2 // 5),
np.linspace(130, 110, n - n // 3 - n * 2 // 5),
]
)
close = trend + rng.normal(0, 0.8, len(trend))
high = close + rng.uniform(0, 1.5, len(trend))
low = close - rng.uniform(0, 1.5, len(trend))
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=len(trend), freq="B"),
"open": close + rng.normal(0, 0.3, len(trend)),
"high": high,
"low": low,
"close": close,
"vol": rng.integers(1e6, 5e6, len(trend)).astype(float),
"amount": close * 1e6,
}
)
def test_registry_entry_and_params():
entry = get_registry().get("zig_breakout")
assert entry.label == "ZIG 右侧突破回补"
names = [p.name for p in entry.params]
assert names == ["zig_delta", "confirm_pct", "hhv_period", "stop_loss_pct"]
defaults = {p.name: p.default for p in entry.params}
assert defaults == {
"zig_delta": 10.0,
"confirm_pct": 2.0,
"hhv_period": 20,
"stop_loss_pct": 3.0,
}
def test_build_validates_params():
entry = get_registry().get("zig_breakout")
inst = entry.build({"zig_delta": 5})
assert inst.p["zig_delta"] == 5.0 and inst.p["hhv_period"] == 20
with pytest.raises(ValueError):
entry.build({"zig_delta": -1}) # 低于 min_value
def test_strategy_trades_and_bracket_stop():
entry = get_registry().get("zig_breakout")
result = BacktestEngine(entry.build(), cash=1_000_000).run(_zigzag_df())
assert len(result.trades) > 0
# 锯齿行情应至少出现一次 BUYtrades 为 DataFrame
assert (result.trades["direction"] == "BUY").any()
assert (result.trades["direction"] == "SELL").any()
def test_strategy_file_variant_loadable():
"""strategies/zig_breakout.py 独立文件可供 --strategy-file 加载。"""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "strategies" / "zig_breakout.py"
spec = importlib.util.spec_from_file_location("zig_file_test", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
result = BacktestEngine(mod.ZigBreakoutStrategy(), cash=1_000_000).run(_zigzag_df())
assert len(result.trades) > 0
assert (result.trades["direction"] == "BUY").any()
def test_preset_grid_registered():
assert "zig_breakout" in STRATEGY_PRESETS
grid = STRATEGY_PRESETS["zig_breakout"]
assert "zig_delta" in grid and "confirm_pct" in grid
# 笛卡尔积不超过寻优器上限
n = 1
for vals in grid.values():
n *= len(vals)
assert n <= 200