From 62e80d892e6fe25034242a80be30a2e5e6b48faf Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Thu, 3 Sep 2026 03:07:57 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.30.2=20=E2=80=94=20=E6=97=A0?= =?UTF-8?q?=E6=9C=AA=E6=9D=A5=E5=87=BD=E6=95=B0=E6=8C=87=E6=A0=87=E6=89=A9?= =?UTF-8?q?=E5=AE=B916=E4=B8=AA=EF=BC=88=E6=B3=A8=E5=86=8C=E6=8C=87?= =?UTF-8?q?=E6=A0=8750=E4=B8=AA=EF=BC=89+=20=E5=86=85=E7=BD=AE=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=A1=A5=E9=BD=9054=E4=B8=AA=EF=BC=9AWebUI=E5=9B=9E?= =?UTF-8?q?=E6=B5=8B=E5=85=A8=E8=A6=86=E7=9B=96=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E4=B8=80=E8=87=B4=E6=80=A7=E6=97=A0=E6=9C=AA?= =?UTF-8?q?=E6=9D=A5=E5=87=BD=E6=95=B0=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 + README.md | 2 +- docs/回测系统完全上手手册.html | 2 +- pyproject.toml | 2 +- src/easy_tdx/MyTT.py | 206 +++ src/easy_tdx/MyTT.pyi | 71 +- src/easy_tdx/backtest/strategies/builtin.py | 1302 +++++++++++++++++++ src/easy_tdx/indicator.py | 91 ++ tests/golden/backtest_metrics.json | 455 +++++++ tests/unit/test_backtest_engine_vector.py | 4 +- tests/unit/test_mytt.py | 345 ++++- 11 files changed, 2496 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 019c919..30d9ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.30.2] — 2026-09-03 + +**无未来函数指标扩容 16 个 + WebUI 回测策略补齐至 54 个**——本轮从互联网主流指标库(TradingView 热榜 / StockCharts / 通达信社区)甄别出一批**计算上只引用当期及历史数据、信号不漂移**的经典指标补进 MyTT,并把回测端"有指标无策略"的缺口全部填平:此前 WebUI 回测下拉只有 19 个内置策略,34 个注册指标里近半数"看得见指标、跑不了回测"——现在**50 个注册指标、54 个内置策略一一对应**,回测页下拉、CLI、Web API 三端自动同步。 + +### 新增(指标,MyTT V4.3,16 个函数) + +- **趋势/止损类**:`SUPERTREND` 超级趋势(Wilder ATR 递推锁带,带线即移动止损,2025 年 TradingView 各"最佳指标"榜单常客)、`CHANDELIER` 吊灯止损(Chuck LeBeau,HHV−K×ATR 经典离场位)、`HMA` 赫尔均线(低滞后不重绘)、`KAMA` 考夫曼自适应均线(效率比驱动:趋势市贴价、震荡市自动走平)、`ICHIMOKU` 一目均衡表(五线一云;先行带用 26 期前的值画到当前位置,只引用过去数据——**迟行带 CHIKOU 按定义引用未来收盘,仅作图示对齐,当期信号严禁使用**,已在 docstring 与注册描述中显著标注)。 +- **动量/振荡类**:`UOS` 终极指标(Larry Williams,7/14/28 三周期加权)、`CMO` 钱德动量振荡器、`TSI` 真实强度指数(双重 EMA 平滑,深回调钝化少)、`FISHER` 费雪变换(John Ehlers,拐点尖锐无钝化)。 +- **波动率/状态识别类**:`SQUEEZE` TTM 挤压动量(布林带收进肯特纳通道=挤压态 + 线性回归动量定方向)、`CHOP` 盘整指数(>61.8 震荡 / <38.2 趋势,策略侧稀缺的"行情状态开关")、`BBP` 布林 %B 位置、`BBW` 布林带宽(收窄=变盘预警)。 +- **量能/资金类**:`AD` 累积/派发线(Marc Chaikin)、`CMF` 佳庆资金流量、`EFI` 艾尔德强力指数——补齐此前最薄弱的"资金流向"一块。 +- 明确排除项维持 v1.30.1 的口径:ZigZag 家族、`FINDHIGH/FINDLOW/BACKSET` 等通达信未来函数、需右侧 K 线确认的 Bill Williams Fractals、需要 tick 级数据的 Volume Profile 均不引入。 + +### 新增(策略,35 个,内置策略 19 → 54) + +- **存量指标补策略(19 个)**:`psy_reversal` / `mtm_cross` / `roc_zero` / `expma_cross` / `dfma_cross` / `cr_reversal` / `xsii_breakout` / `obv_cross` / `vr_reversal` / `mass_cross` / `mfi_reversal` / `brar_reversal` / `asi_cross` / `zhuoyao_trend`(多周期涨幅共振)/ `bias_signal_cross` / `sar_follow` / `vwap_cross` / `aroon_cross` / `fk_reversal`。 +- **新指标首发策略(16 个)**:`supertrend`(方向翻转跟随)/ `kama_cross` / `hma_cross` / `chandelier`(通道突破进场+吊灯止损离场)/ `ichimoku_cross`(转换/基准线金叉+云层位置确认)/ `uos_reversal` / `cmo_reversal` / `tsi_cross` / `fisher_cross` / `squeeze_breakout`(挤压解除+动量方向)/ `chop_trend`(趋态开关+均线方向)/ `ad_cross` / `cmf_zero` / `efi_zero` / `bbp_reversal` / `bbw_squeeze`。全部实现 `entry_exit_masks`,走上 v1.28 的向量化快速路径。 + +### 测试与防回归 + +- **无未来函数前缀一致性回归(`TestNoLookahead`)**:把 200 根 K 线截断到前 120 根,16 个新指标的输出在重叠段必须与"仅用前缀数据计算"逐位一致——未来函数的致命特征是后到数据改写历史输出,任何引用 t+1 之后数据的实现当场爆红。该测试与黄金基线、向量化对拍构成三重防线。 +- 向量化对拍扩至 74 例全绿(54 策略逐 bar vs 向量化逐位一致且全部实际产生交易);黄金基线 `backtest_metrics.json` 重生成至 54 策略全量锁定,**19 个存量策略数字一位未动**(存量行为零漂移)。`test_mytt.py` 新增 47 例数值正确性测试(范围/预热/公式口径/一字板除零保护)。 +- 文档同步:README 策略计数 19 → 54,《回测系统完全上手手册》陈旧的"18 个内置策略"修正。 + ## [1.30.1] — 2026-09-03 **彻底移除 ZIG 之字转向指标与 `zig_breakout` 策略**——ZIG 是教科书级的**未来函数**:波峰/波谷拐点只有在**其后**的走势反向走满 X% 确认转向后才会**回溯标出**,也就是说序列里每个拐点的位置都用到了"当时不可能知道"的未来信息。把它当买卖信号回测,等于允许策略在波谷那一天精准买入、在见顶前一天精准卖出——收益必然严重虚高、参数寻优必然过拟合,回测结果与实盘表现脱节,**这正是本工具最要防的失真**。 diff --git a/README.md b/README.md index 78dc202..db85fe2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 **34个技术指标**(MACD、KDJ、RSI、BOLL……连”捉妖大师”和”30日乖离率信号”都给你算好)开箱即用。 **缠论分析**(笔、中枢、买卖点、背驰)一键出结果——你不再需要手画分型、猜线段。 -**内置回测引擎**——写个策略文件,一行命令跑回测,19 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。**深度风险报告**(v1.28 新增)——绩效指标扩到 25 项(新增 Ulcer 指数、95% 日 VaR/CVaR、SQN 系统质量数、最大连胜/连亏);基准对比从超额收益一个数升级为 α/β/信息比率/跟踪误差四件套(「涨的时候跟不跟得上大盘、跌的时候抗不抗跌」一眼可读);策略一行带移动止损与百分比止损止盈(`self.buy(trail_stop=0.08)` 涨得越高止损线跟得越高,OCO 任一触发即失效);引擎行为由黄金测试锁定——19 个内置策略的指标基线进 CI,撮合/费率逻辑任何静默漂移当场爆红。 +**内置回测引擎**——写个策略文件,一行命令跑回测,54 个经典策略自带(覆盖全部 50 个注册指标),多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。**深度风险报告**(v1.28 新增)——绩效指标扩到 25 项(新增 Ulcer 指数、95% 日 VaR/CVaR、SQN 系统质量数、最大连胜/连亏);基准对比从超额收益一个数升级为 α/β/信息比率/跟踪误差四件套(「涨的时候跟不跟得上大盘、跌的时候抗不抗跌」一眼可读);策略一行带移动止损与百分比止损止盈(`self.buy(trail_stop=0.08)` 涨得越高止损线跟得越高,OCO 任一触发即失效);引擎行为由黄金测试锁定——54 个内置策略的指标基线进 CI,撮合/费率逻辑任何静默漂移当场爆红。 **行情终端 Web UI**(v1.23 重大升级)——`easy-tdx serve` 一条命令,浏览器秒变专业看盘终端:**市场看板**(五大指数实时推送 + 迷你分时、涨跌统计、四维情绪雷达、全市场涨跌分布直方图、涨停雷达、行业/概念热冷榜、涨幅/跌幅/成交额/换手四联排行榜、两市异动雷达)、**自选行情**(输入 6 位代码即加,全表 SSE 实时刷新、行内迷你分时)、**个股详情弹窗**(五档盘口 + 1/3/5 日分时 + 带 MA/BOLL/MACD/KDJ/RSI 指标切换的日 K,一键加自选、一键全策略寻优)、**板块下钻**(行业/概念弹窗看板块走势 + 成分股涨跌榜直达个股)。实时推送采用 SSE 单循环轮询 fan-out 架构(盘中 8 秒一拍,无人订阅自动休眠),自选持久化 SQLite。展示层设计对标专业终端(暗色、红涨绿跌、高信息密度),数据全部来自通达信协议直连——不花一分钱。 diff --git a/docs/回测系统完全上手手册.html b/docs/回测系统完全上手手册.html index d4ccd3b..58089fa 100644 --- a/docs/回测系统完全上手手册.html +++ b/docs/回测系统完全上手手册.html @@ -640,7 +640,7 @@ easy-tdx serve --host 0.0.0.0
页面上还有一个橙色按钮 "一键寻优所有策略"。点它,系统会用每个策略自带的预设参数网格,把所有 18 个内置策略都跑一遍寻优,然后给你一个全局排名。
+页面上还有一个橙色按钮 "一键寻优所有策略"。点它,系统会用每个策略自带的预设参数网格,把所有 54 个内置策略都跑一遍寻优,然后给你一个全局排名。
这对不知道用哪个策略的新手特别有用:让电脑告诉你,这个股票上哪个策略历史表现最好。
diff --git a/pyproject.toml b/pyproject.toml index 4f9647c..f8a10f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.30.1" +version = "1.30.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/MyTT.py b/src/easy_tdx/MyTT.py index f33a9dc..eb7536f 100644 --- a/src/easy_tdx/MyTT.py +++ b/src/easy_tdx/MyTT.py @@ -16,6 +16,8 @@ # V4.0 2026-06-02 handsomejustin 新增 ZHUOYAO,BIAS_SIGNAL两个自创函数 # V4.1 2026-06-14 新增 SAR(抛物线转向), VWAP(成交量加权均价), AROON(阿隆指标); 注册 FK # V4.2 2026-07-09 新增 FSL(分水岭指标) +# V4.3 2026-09-03 新增无未来函数指标16个:HMA,KAMA,SUPERTREND,CHANDELIER,ICHIMOKU, +# UOS,CMO,TSI,FISHER,SQUEEZE,CHOP,AD,CMF,EFI,BBP,BBW(全部只引用当期及历史数据) # 以下所有函数如无特别说明,输入参数S均为numpy序列或者列表list,N为整型int # 应用层1级函数完美兼容通达信或同花顺,具体使用方法请参考通达信 @@ -571,4 +573,208 @@ def FSL(CLOSE, VOL, CAPITAL): # 分水岭指标:多空趋势强弱分界(SW return RD(SWL), RD(SWS) +# ── V4.3 新增:无未来函数指标(只引用当期及历史数据,信号不漂移) ──────────── +def HMA(S, N=16): # Hull 赫尔均线:WMA(2*WMA(N/2)-WMA(N), sqrt(N)),低滞后且不重绘 + return WMA(2 * WMA(S, N // 2) - WMA(S, N), max(int(round(np.sqrt(N))), 1)) + + +def KAMA(S, N=10, FAST=2, SLOW=30): # 考夫曼自适应均线:效率比驱动平滑系数(贴价/走平自适应) + S = np.asarray(S, dtype=float) + change = np.abs(S - REF(S, N)) # N 周期净变化 + volatility = SUM(np.abs(DIFF(S)), N) # N 周期路径总波动 + with np.errstate(divide="ignore", invalid="ignore"): + er = np.where(volatility > 0, change / volatility, 0.0) # 效率比 0~1 + er = np.where(np.isnan(change) | np.isnan(volatility), np.nan, er) # 预热段保持 NaN + fast_sc = 2.0 / (FAST + 1) + slow_sc = 2.0 / (SLOW + 1) + sc = (er * (fast_sc - slow_sc) + slow_sc) ** 2 # 平滑系数(ER 高→贴价,ER 低→走平) + out = np.full(len(S), np.nan) + valid = np.flatnonzero(~np.isnan(sc)) + if len(valid): + start = valid[0] + out[start] = S[start] + for i in range(start + 1, len(S)): + out[i] = out[i - 1] + sc[i] * (S[i] - out[i - 1]) + return out + + +def SUPERTREND(CLOSE, HIGH, LOW, N=10, M=3.0): # 超级趋势:HL2±M*ATR 递推锁带,穿带翻多空 + CLOSE = np.asarray(CLOSE, dtype=float) + HIGH = np.asarray(HIGH, dtype=float) + LOW = np.asarray(LOW, dtype=float) + n = len(CLOSE) + sar_like = np.full(n, np.nan) + if n == 0: + return sar_like, np.zeros(0, dtype=int) + # Wilder 平滑 ATR(与 TradingView ta.rma 同口径);首根 TR 用自身 H-L + prev_c = np.concatenate(([CLOSE[0]], CLOSE[:-1])) + tr = MAX(MAX(HIGH - LOW, ABS(HIGH - prev_c)), ABS(LOW - prev_c)) + atr = np.empty(n) + atr[0] = tr[0] + for i in range(1, n): + atr[i] = atr[i - 1] + (tr[i] - atr[i - 1]) / N + hl2 = (HIGH + LOW) / 2 + basic_up = hl2 + M * atr + basic_dn = hl2 - M * atr + # 递推锁带:带只朝价格方向收紧,反向放宽被前收盘穿越时重置 + final_up = basic_up.copy() + final_dn = basic_dn.copy() + for i in range(1, n): + final_up[i] = ( + basic_up[i] + if (basic_up[i] < final_up[i - 1] or CLOSE[i - 1] > final_up[i - 1]) + else final_up[i - 1] + ) + final_dn[i] = ( + basic_dn[i] + if (basic_dn[i] > final_dn[i - 1] or CLOSE[i - 1] < final_dn[i - 1]) + else final_dn[i - 1] + ) + direction = np.ones(n, dtype=int) # 1=多头(ST=下轨),-1=空头(ST=上轨) + for i in range(1, n): + if direction[i - 1] == 1: + direction[i] = -1 if CLOSE[i] < final_dn[i] else 1 + else: + direction[i] = 1 if CLOSE[i] > final_up[i] else -1 + st = np.where(direction == 1, final_dn, final_up) + return st, direction + + +def CHANDELIER(CLOSE, HIGH, LOW, N=22, M=22, K=3.0): # 吊灯止损:多头 HHV-K*ATR / 空头 LLV+K*ATR + atr = ATR(CLOSE, HIGH, LOW, M) + long_stop = HHV(HIGH, N) - atr * K + short_stop = LLV(LOW, N) + atr * K + return long_stop, short_stop + + +def ICHIMOKU(HIGH, LOW, CLOSE, P1=9, P2=26, P3=52, SHIFT=26): # 一目均衡表:五线一云 + tenkan = (HHV(HIGH, P1) + LLV(LOW, P1)) / 2 # 转换线 + kijun = (HHV(HIGH, P2) + LLV(LOW, P2)) / 2 # 基准线 + span_a = REF((tenkan + kijun) / 2, SHIFT) # 先行带A:SHIFT期前的值画到当前(仅过去数据) + span_b = REF((HHV(HIGH, P3) + LLV(LOW, P3)) / 2, SHIFT) # 先行带B + chikou = REF(CLOSE, -SHIFT) # 迟行带:当前收盘画回SHIFT期前,仅作图示(当期信号禁用) + return tenkan, kijun, span_a, span_b, chikou + + +def UOS(CLOSE, HIGH, LOW, P1=7, P2=14, P3=28, M=6): # 终极指标:7/14/28 三周期 BP/TR 加权动量 + th = MAX(HIGH, REF(CLOSE, 1)) # 真高 + tl = MIN(LOW, REF(CLOSE, 1)) # 真低 + bp = CLOSE - tl # 买压 + tr = th - tl # 真波幅 + + def _avg(p): # SMA(BP,p)/SMA(TR,p),分母为零(一字段)时取中性 0.5 + num, den = MA(bp, p), MA(tr, p) + with np.errstate(divide="ignore", invalid="ignore"): + ratio = np.where(den > 0, num / den, 0.5) + return np.where(np.isnan(num) | np.isnan(den), np.nan, ratio) + + uos = 100 * (4 * _avg(P1) + 2 * _avg(P2) + _avg(P3)) / 7 + uos_ma = MA(uos, M) + return RD(uos), RD(uos_ma) + + +def CMO(CLOSE, N=14): # 钱德动量振荡器:(涨幅和-跌幅和)/总波幅*100,比RSI更锐利对称 + dif = DIFF(CLOSE) + up = SUM(MAX(dif, 0), N) + dn = SUM(MAX(-dif, 0), N) + total = up + dn + with np.errstate(divide="ignore", invalid="ignore"): + cmo = np.where(total > 0, (up - dn) / total * 100, 0.0) + return RD(np.where(np.isnan(total), np.nan, cmo)) + + +def TSI(CLOSE, R=25, S=13, M=13): # 真实强度指数:动量双重EMA平滑归一(William Blau) + m = DIFF(CLOSE) + num = EMA(EMA(m, R), S) + den = EMA(EMA(ABS(m), R), S) + with np.errstate(divide="ignore", invalid="ignore"): + tsi = np.where(den != 0, 100 * num / den, 0.0) + tsi = np.where(np.isnan(num) | np.isnan(den), np.nan, tsi) + return RD(tsi), RD(EMA(tsi, M)) + + +def FISHER(HIGH, LOW, N=9): # 费雪变换(John Ehlers):区间位置正态化,拐点尖锐 + price = (HIGH + LOW) / 2.0 + highest = HHV(price, N) + lowest = LLV(price, N) + rng = highest - lowest + with np.errstate(divide="ignore", invalid="ignore"): + raw = np.where(rng > 0, 2.0 * (price - lowest) / rng - 1.0, 0.0) # 归一化到 -1~1 + norm = np.where(np.isnan(rng), np.nan, raw) + n = len(price) + value = np.full(n, np.nan) + fisher = np.full(n, np.nan) + for i in range(n): + if np.isnan(norm[i]): + continue + prev_v = 0.0 if np.isnan(value[i - 1]) else value[i - 1] + prev_f = 0.0 if np.isnan(fisher[i - 1]) else fisher[i - 1] + v = 0.33 * norm[i] + 0.67 * prev_v + v = max(-0.999, min(0.999, v)) # 钳制避免 ln 发散 + value[i] = v + fisher[i] = 0.5 * np.log((1 + v) / (1 - v)) + 0.5 * prev_f + return fisher, REF(fisher, 1) # 第二返回值为触发线(前一期值) + + +def SQUEEZE(CLOSE, HIGH, LOW, N=20, BB=2.0, KC=1.5): # TTM 挤压动量:布林收进肯特纳,回归动量定方向 + mid = MA(CLOSE, N) + dev = STD(CLOSE, N) * BB + tr = MAX(MAX(HIGH - LOW, ABS(HIGH - REF(CLOSE, 1))), ABS(LOW - REF(CLOSE, 1))) + range_ma = MA(tr, N) + # 挤压判定:布林带(±dev)整体落在肯特纳通道(±KC×range_ma)内部(NaN 预热期比较为 False) + sqz_on = dev < KC * range_ma + # 动量:中价偏离 (HH+LL)/2 与 SMA(C,N) 的均值,取线性回归当前拟合值 + delta = CLOSE - ((HHV(HIGH, N) + LLV(LOW, N)) / 2 + mid) / 2 + mom = FORCAST(delta, N) + return sqz_on, RD(mom) + + +def CHOP(HIGH, LOW, CLOSE, N=14): # 盘整指数:>61.8 震荡 / <38.2 趋势(ΣTR/区间 对数缩放到 0~100) + tr = MAX(MAX(HIGH - LOW, ABS(HIGH - REF(CLOSE, 1))), ABS(LOW - REF(CLOSE, 1))) + tr_sum = SUM(tr, N) + rng = HHV(HIGH, N) - LLV(LOW, N) + with np.errstate(divide="ignore", invalid="ignore"): + ratio = np.where((tr_sum > 0) & (rng > 0), tr_sum / rng, np.nan) + return 100 * np.log10(ratio) / np.log10(N) + + +def AD(CLOSE, HIGH, LOW, VOL): # 累积/派发线(Marc Chaikin):CLV*VOL 累计,OBV 的精细化版 + rng = HIGH - LOW + with np.errstate(divide="ignore", invalid="ignore"): + clv = np.where(rng > 0, ((CLOSE - LOW) - (HIGH - CLOSE)) / rng, 0.0) # 收盘位置 -1~1 + return SUM(clv * VOL, 0) + + +def CMF(CLOSE, HIGH, LOW, VOL, N=20): # 佳庆资金流量:N日 CLV*VOL 之和 / N日成交量之和 + rng = HIGH - LOW + with np.errstate(divide="ignore", invalid="ignore"): + clv = np.where(rng > 0, ((CLOSE - LOW) - (HIGH - CLOSE)) / rng, 0.0) + mfv_sum = SUM(clv * VOL, N) + vol_sum = SUM(VOL, N) + with np.errstate(divide="ignore", invalid="ignore"): + cmf = np.where(vol_sum > 0, mfv_sum / vol_sum, 0.0) + return np.where(np.isnan(vol_sum) | np.isnan(mfv_sum), np.nan, cmf) + + +def EFI(CLOSE, VOL, N=13): # 艾尔德强力指数:EMA(ΔC×V),同时融合方向/幅度/成交量三维 + return EMA(DIFF(CLOSE) * VOL, N) + + +def BBP(CLOSE, N=20, P=2): # 布林位置 %B:收盘在带内位置百分比(0=下轨,50=中轨,100=上轨) + mid = MA(CLOSE, N) + dev = STD(CLOSE, N) * P + width = 2 * dev + with np.errstate(divide="ignore", invalid="ignore"): + pos = np.where(width > 0, (CLOSE - (mid - dev)) / width * 100, 50.0) + return RD(np.where(np.isnan(width), np.nan, pos)) + + +def BBW(CLOSE, N=20, P=2): # 布林带宽:(上轨-下轨)/中轨*100,收窄=波动挤压/变盘预警 + mid = MA(CLOSE, N) + dev = STD(CLOSE, N) * P + with np.errstate(divide="ignore", invalid="ignore"): + width = np.where(mid > 0, 2 * dev / mid * 100, np.nan) + return RD(width) + + # 望大家能提交更多指标和函数 https://github.com/mpquant/MyTT diff --git a/src/easy_tdx/MyTT.pyi b/src/easy_tdx/MyTT.pyi index 5d60624..10acdf4 100644 --- a/src/easy_tdx/MyTT.pyi +++ b/src/easy_tdx/MyTT.pyi @@ -110,7 +110,76 @@ def FSL( VOL: npt.ArrayLike, CAPITAL: float, ) -> tuple[NDArray, NDArray]: ... -def ZIG(S: npt.ArrayLike, X: float = ...) -> NDArray: ... + +# ── V4.3 无未来函数指标 ───────────────────────────────────────────────────── + +def HMA(S: npt.ArrayLike, N: int = ...) -> NDArray: ... +def KAMA(S: npt.ArrayLike, N: int = ..., FAST: int = ..., SLOW: int = ...) -> NDArray: ... +def SUPERTREND( + CLOSE: npt.ArrayLike, + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + N: int = ..., + M: float = ..., +) -> tuple[NDArray, npt.NDArray[np.int32]]: ... +def CHANDELIER( + CLOSE: npt.ArrayLike, + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + N: int = ..., + M: int = ..., + K: float = ..., +) -> tuple[NDArray, NDArray]: ... +def ICHIMOKU( + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + CLOSE: npt.ArrayLike, + P1: int = ..., + P2: int = ..., + P3: int = ..., + SHIFT: int = ..., +) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]: ... +def UOS( + CLOSE: npt.ArrayLike, + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + P1: int = ..., + P2: int = ..., + P3: int = ..., + M: int = ..., +) -> tuple[NDArray, NDArray]: ... +def CMO(CLOSE: npt.ArrayLike, N: int = ...) -> NDArray: ... +def TSI( + CLOSE: npt.ArrayLike, + R: int = ..., + S: int = ..., + M: int = ..., +) -> tuple[NDArray, NDArray]: ... +def FISHER( + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + N: int = ..., +) -> tuple[NDArray, NDArray]: ... +def SQUEEZE( + CLOSE: npt.ArrayLike, + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + N: int = ..., + BB: float = ..., + KC: float = ..., +) -> tuple[npt.NDArray[np.bool_], NDArray]: ... +def CHOP(HIGH: npt.ArrayLike, LOW: npt.ArrayLike, CLOSE: npt.ArrayLike, N: int = ...) -> NDArray: ... +def AD(CLOSE: npt.ArrayLike, HIGH: npt.ArrayLike, LOW: npt.ArrayLike, VOL: npt.ArrayLike) -> NDArray: ... +def CMF( + CLOSE: npt.ArrayLike, + HIGH: npt.ArrayLike, + LOW: npt.ArrayLike, + VOL: npt.ArrayLike, + N: int = ..., +) -> NDArray: ... +def EFI(CLOSE: npt.ArrayLike, VOL: npt.ArrayLike, N: int = ...) -> NDArray: ... +def BBP(CLOSE: npt.ArrayLike, N: int = ..., P: float = ...) -> NDArray: ... +def BBW(CLOSE: npt.ArrayLike, N: int = ..., P: float = ...) -> NDArray: ... # ── Utility Functions ──────────────────────────────────────────────────────── diff --git a/src/easy_tdx/backtest/strategies/builtin.py b/src/easy_tdx/backtest/strategies/builtin.py index 3a7f750..29ca396 100644 --- a/src/easy_tdx/backtest/strategies/builtin.py +++ b/src/easy_tdx/backtest/strategies/builtin.py @@ -19,25 +19,63 @@ from easy_tdx.backtest.strategies.registry import ( register_strategy, ) from easy_tdx.MyTT import ( + AD, + AROON, + ASI, ATR, BBI, + BBP, + BBW, BIAS, + BIAS_SIGNAL, BOLL, + BRAR, CCI, + CHANDELIER, + CHOP, + CMF, + CMO, + CR, CROSS, + DFMA, DMI, DPO, + EFI, EMA, EMV, + EXPMA, + FISHER, + FK, FSL, + HHV, + HMA, + ICHIMOKU, + KAMA, KDJ, KTN, + LLV, MA, MACD, + MASS, + MFI, + MTM, + OBV, + PSY, + REF, + ROC, RSI, + SAR, + SQUEEZE, + SUPERTREND, TAQ, TRIX, + TSI, + UOS, + VR, + VWAP, WR, + XSII, + ZHUOYAO, ) __all__: list[str] = [] # 注册副作用即可,无需导出符号 @@ -691,3 +729,1267 @@ class FslStrategy(ParametrizedStrategy): def entry_exit_masks(self) -> tuple[Any, Any]: """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" return self.gold, self.dead + + +# ═══════════════════════════════════════════════════════════════════════════ +# V4.3 补齐:存量指标补策略(此前 MyTT 有指标但回测无对应内置策略) +# ═══════════════════════════════════════════════════════════════════════════ + + +# ── PSY 心理线 ───────────────────────────────────────────────────────────────── + + +@register_strategy( + name="psy_reversal", + label="PSY 心理线超卖", + description="心理线跌破超卖线买入(人气冰点),涨破超买线卖出(人气过热)。", +) +class PsyReversalStrategy(ParametrizedStrategy): + """PSY 超卖买入、超买卖出。""" + + params = [ + Param("n", int, default=12, min_value=2, max_value=60, label="统计周期"), + Param("m", int, default=6, min_value=2, max_value=30, label="信号线周期"), + Param("oversold", int, default=25, min_value=5, max_value=45, label="超卖线"), + Param("overbought", int, default=75, min_value=55, max_value=95, label="超买线"), + ] + param_constraints = [("oversold", "overbought")] + + def init(self) -> None: + self.psy, self._psy_ma = self.I(PSY, self.data.close, self.p["n"], self.p["m"]) + + def next(self) -> None: + i = self._bar_index + if self.psy[i] <= self.p["oversold"] and self.position["size"] == 0: + self.buy() + elif self.psy[i] >= self.p["overbought"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """PSY 跌破超卖线进 / 涨破超买线出(与 next() 同一比较)。""" + psy = np.asarray(self.psy, dtype=np.float64) + return psy <= self.p["oversold"], psy >= self.p["overbought"] + + +# ── MTM 动量指标 ─────────────────────────────────────────────────────────────── + + +@register_strategy( + name="mtm_cross", + label="MTM 动量金叉", + description="MTM 上穿其均线买入(动量转强),下穿卖出(动量转弱)。", +) +class MtmCrossStrategy(ParametrizedStrategy): + """MTM/MTMMA 金叉死叉。""" + + params = [ + Param("n", int, default=12, min_value=2, max_value=60, label="动量周期"), + Param("m", int, default=6, min_value=2, max_value=30, label="均线周期"), + ] + + def init(self) -> None: + self.mtm, self.mtmma = self.I(MTM, self.data.close, self.p["n"], self.p["m"]) + self.gold = self.I(CROSS, self.mtm, self.mtmma) + self.dead = self.I(CROSS, self.mtmma, self.mtm) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── ROC 变动率 ───────────────────────────────────────────────────────────────── + + +@register_strategy( + name="roc_zero", + label="ROC 零轴动量", + description="ROC 转正买入(涨速转正),转负卖出(涨速转负)。", +) +class RocZeroStrategy(ParametrizedStrategy): + """ROC 0 轴多空切换。""" + + params = [ + Param("n", int, default=12, min_value=2, max_value=60, label="ROC周期"), + ] + + def init(self) -> None: + self.roc, self._maroc = self.I(ROC, self.data.close, self.p["n"], 6) + + def next(self) -> None: + i = self._bar_index + if self.roc[i] > 0 and self.position["size"] == 0: + self.buy() + elif self.roc[i] < 0 and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """ROC 上穿 0 轴进 / 下穿 0 轴出(与 next() 同一比较)。""" + roc = np.asarray(self.roc, dtype=np.float64) + return roc > 0, roc < 0 + + +# ── EXPMA 指数平均数 ────────────────────────────────────────────────────────── + + +@register_strategy( + name="expma_cross", + label="EXPMA 双线交叉", + description="快线 EXPMA 上穿慢线买入,下穿卖出(参数惯用 12/50)。", +) +class ExpmaCrossStrategy(ParametrizedStrategy): + """EXPMA 12/50 金叉死叉。""" + + params = [ + Param("n1", int, default=12, min_value=2, max_value=60, label="快线周期"), + Param("n2", int, default=50, min_value=5, max_value=120, label="慢线周期"), + ] + param_constraints = [("n1", "n2")] + + def init(self) -> None: + self.fast, self.slow = self.I(EXPMA, self.data.close, self.p["n1"], self.p["n2"]) + self.gold = self.I(CROSS, self.fast, self.slow) + self.dead = self.I(CROSS, self.slow, self.fast) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── DFMA 平行线差 ───────────────────────────────────────────────────────────── + + +@register_strategy( + name="dfma_cross", + label="DFMA 平行线差金叉", + description="DIF 上穿 DIFMA 买入,下穿卖出(双均线差的趋势确认版)。", +) +class DfmaCrossStrategy(ParametrizedStrategy): + """DFMA DIF/DIFMA 金叉死叉。""" + + params = [ + Param("n1", int, default=10, min_value=2, max_value=60, label="快均线"), + Param("n2", int, default=50, min_value=5, max_value=120, label="慢均线"), + Param("m", int, default=10, min_value=2, max_value=60, label="信号周期"), + ] + param_constraints = [("n1", "n2")] + + def init(self) -> None: + self.dif, self.difma = self.I( + DFMA, self.data.close, self.p["n1"], self.p["n2"], self.p["m"] + ) + self.gold = self.I(CROSS, self.dif, self.difma) + self.dead = self.I(CROSS, self.difma, self.dif) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── CR 能量指标 ─────────────────────────────────────────────────────────────── + + +@register_strategy( + name="cr_reversal", + label="CR 能量超卖", + description="CR 跌破 40(能量冰点)买入,涨破 300(能量过热)卖出。", +) +class CrReversalStrategy(ParametrizedStrategy): + """CR 超卖买入、超买卖出。""" + + params = [ + Param("n", int, default=20, min_value=5, max_value=60, label="统计周期"), + Param("oversold", int, default=40, min_value=10, max_value=80, label="超卖线"), + Param("overbought", int, default=300, min_value=150, max_value=500, label="超买线"), + ] + param_constraints = [("oversold", "overbought")] + + def init(self) -> None: + self.cr = self.I(CR, self.data.close, self.data.high, self.data.low, self.p["n"]) + + def next(self) -> None: + i = self._bar_index + if self.cr[i] <= self.p["oversold"] and self.position["size"] == 0: + self.buy() + elif self.cr[i] >= self.p["overbought"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """CR 跌破超卖线进 / 涨破超买线出(与 next() 同一比较)。""" + cr = np.asarray(self.cr, dtype=np.float64) + return cr <= self.p["oversold"], cr >= self.p["overbought"] + + +# ── XSII 薛斯通道II ─────────────────────────────────────────────────────────── + + +@register_strategy( + name="xsii_breakout", + label="XSII 薛斯通道突破", + description="收盘价突破薛斯通道上轨 TD1 买入,跌破下轨 TD2 卖出。", +) +class XsiiBreakoutStrategy(ParametrizedStrategy): + """薛斯通道II 上下轨突破。""" + + params = [ + Param("n", int, default=102, min_value=50, max_value=150, label="通道宽度‰"), + Param("m", int, default=7, min_value=1, max_value=20, label="动态通道%"), + ] + + def init(self) -> None: + self.td1, self.td2, self._td3, self._td4 = self.I( + XSII, self.data.close, self.data.high, self.data.low, self.p["n"], self.p["m"] + ) + + def next(self) -> None: + i = self._bar_index + close = self.data.close[0] + if close >= self.td1[i] and self.position["size"] == 0: + self.buy() + elif close <= self.td2[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """突破 TD1 上轨进 / 跌破 TD2 下轨出(NaN 预热期比较为 False,与 next() 一致)。""" + close = self.data.close.raw + return close >= self.td1, close <= self.td2 + + +# ── OBV 能量潮 ──────────────────────────────────────────────────────────────── + + +@register_strategy( + name="obv_cross", + label="OBV 能量潮金叉", + description="OBV 上穿其均线买入(量能先行转强),下穿卖出。", +) +class ObvCrossStrategy(ParametrizedStrategy): + """OBV 与其均线金叉死叉。""" + + params = [ + Param("m", int, default=30, min_value=5, max_value=120, label="均线周期"), + ] + + def init(self) -> None: + self.obv = self.I(OBV, self.data.close, self.data.vol) + self.obv_ma = self.I(MA, self.obv, self.p["m"]) + self.gold = self.I(CROSS, self.obv, self.obv_ma) + self.dead = self.I(CROSS, self.obv_ma, self.obv) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── VR 容量比率 ─────────────────────────────────────────────────────────────── + + +@register_strategy( + name="vr_reversal", + label="VR 容量超卖", + description="VR 跌破 40(底部区)买入,涨破 160(过热区)卖出。", +) +class VrReversalStrategy(ParametrizedStrategy): + """VR 超卖买入、超买卖出。""" + + params = [ + Param("m1", int, default=26, min_value=5, max_value=60, label="统计周期"), + Param("oversold", int, default=40, min_value=10, max_value=70, label="超卖线"), + Param("overbought", int, default=160, min_value=120, max_value=400, label="超买线"), + ] + param_constraints = [("oversold", "overbought")] + + def init(self) -> None: + self.vr = self.I(VR, self.data.close, self.data.vol, self.p["m1"]) + + def next(self) -> None: + i = self._bar_index + if self.vr[i] <= self.p["oversold"] and self.position["size"] == 0: + self.buy() + elif self.vr[i] >= self.p["overbought"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """VR 跌破超卖线进 / 涨破超买线出(与 next() 同一比较)。""" + vr = np.asarray(self.vr, dtype=np.float64) + return vr <= self.p["oversold"], vr >= self.p["overbought"] + + +# ── MASS 梅斯线 ─────────────────────────────────────────────────────────────── + + +@register_strategy( + name="mass_cross", + label="MASS 梅斯线金叉", + description="MASS 上穿其均线买入,下穿卖出(波幅挤压释放的节奏判定)。", +) +class MassCrossStrategy(ParametrizedStrategy): + """MASS/MA 金叉死叉。""" + + params = [ + Param("n1", int, default=9, min_value=2, max_value=30, label="窄波幅周期"), + Param("n2", int, default=25, min_value=5, max_value=60, label="累计周期"), + Param("m", int, default=6, min_value=2, max_value=30, label="信号周期"), + ] + + def init(self) -> None: + self.mass, self.mass_ma = self.I( + MASS, self.data.high, self.data.low, self.p["n1"], self.p["n2"], self.p["m"] + ) + self.gold = self.I(CROSS, self.mass, self.mass_ma) + self.dead = self.I(CROSS, self.mass_ma, self.mass) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── MFI 资金流量 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="mfi_reversal", + label="MFI 资金流超卖", + description="MFI 跌破 20(资金流枯竭)买入,涨破 80(资金流过热)卖出。", +) +class MfiReversalStrategy(ParametrizedStrategy): + """MFI 超卖买入、超买卖出。""" + + params = [ + Param("n", int, default=14, min_value=2, max_value=60, label="MFI周期"), + Param("oversold", int, default=20, min_value=5, max_value=35, label="超卖线"), + Param("overbought", int, default=80, min_value=65, max_value=95, label="超买线"), + ] + param_constraints = [("oversold", "overbought")] + + def init(self) -> None: + self.mfi = self.I( + MFI, self.data.close, self.data.high, self.data.low, self.data.vol, self.p["n"] + ) + + def next(self) -> None: + i = self._bar_index + if self.mfi[i] <= self.p["oversold"] and self.position["size"] == 0: + self.buy() + elif self.mfi[i] >= self.p["overbought"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """MFI 跌破超卖线进 / 涨破超买线出(与 next() 同一比较)。""" + mfi = np.asarray(self.mfi, dtype=np.float64) + return mfi <= self.p["oversold"], mfi >= self.p["overbought"] + + +# ── BRAR 情绪指标 ───────────────────────────────────────────────────────────── + + +@register_strategy( + name="brar_reversal", + label="ARBR 情绪冰点", + description="AR 跌破 40(市场情绪冰点)买入,AR 涨破 180(情绪过热)卖出。", +) +class BrarReversalStrategy(ParametrizedStrategy): + """AR 情绪超卖买入、超买卖出。""" + + params = [ + Param("m1", int, default=26, min_value=5, max_value=60, label="统计周期"), + Param("oversold", int, default=40, min_value=10, max_value=60, label="超卖线"), + Param("overbought", int, default=180, min_value=120, max_value=300, label="超买线"), + ] + param_constraints = [("oversold", "overbought")] + + def init(self) -> None: + self.ar, self._br = self.I( + BRAR, self.data.open, self.data.close, self.data.high, self.data.low, self.p["m1"] + ) + + def next(self) -> None: + i = self._bar_index + if self.ar[i] <= self.p["oversold"] and self.position["size"] == 0: + self.buy() + elif self.ar[i] >= self.p["overbought"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """AR 跌破超卖线进 / 涨破超买线出(与 next() 同一比较)。""" + ar = np.asarray(self.ar, dtype=np.float64) + return ar <= self.p["oversold"], ar >= self.p["overbought"] + + +# ── ASI 振动升降 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="asi_cross", + label="ASI 振动升降金叉", + description="ASI 上穿其均线买入(真实动能转强),下穿卖出。", +) +class AsiCrossStrategy(ParametrizedStrategy): + """ASI/ASIT 金叉死叉。""" + + params = [ + Param("m1", int, default=26, min_value=5, max_value=60, label="ASI累计周期"), + Param("m2", int, default=10, min_value=2, max_value=30, label="信号周期"), + ] + + def init(self) -> None: + self.asi, self.asit = self.I( + ASI, + self.data.open, + self.data.close, + self.data.high, + self.data.low, + self.p["m1"], + self.p["m2"], + ) + self.gold = self.I(CROSS, self.asi, self.asit) + self.dead = self.I(CROSS, self.asit, self.asi) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── ZHUOYAO 多周期共振 ──────────────────────────────────────────────────────── + + +@register_strategy( + name="zhuoyao_trend", + label="ZHUOYAO 多周期共振", + description="短/中线与趋势线同向为正(多头共振)买入,短/中线同向为负(空头共振)卖出。", +) +class ZhuoyaoTrendStrategy(ParametrizedStrategy): + """多周期涨幅共振排列。""" + + params = [ + Param("n1", int, default=120, min_value=60, max_value=250, label="长线周期"), + Param("n2", int, default=60, min_value=20, max_value=120, label="中线周期"), + Param("n3", int, default=20, min_value=5, max_value=60, label="短线周期"), + Param("m", int, default=10, min_value=2, max_value=30, label="平滑周期"), + ] + param_constraints = [("n3", "n2"), ("n2", "n1")] + + def init(self) -> None: + self.zy_long, self.zy_mid, self.zy_short, self.zy_trend = self.I( + ZHUOYAO, self.data.close, self.p["n1"], self.p["n2"], self.p["n3"], self.p["m"] + ) + + def next(self) -> None: + i = self._bar_index + if ( + self.zy_short[i] > 0 + and self.zy_mid[i] > 0 + and self.zy_trend[i] > 0 + and self.position["size"] == 0 + ): + self.buy() + elif self.zy_short[i] < 0 and self.zy_mid[i] < 0 and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """短/中线与趋势线同向为正进 / 短/中线同向为负出(链式比较逐元素展开,与 next() 一致)。""" + return ( + (self.zy_short > 0) & (self.zy_mid > 0) & (self.zy_trend > 0), + (self.zy_short < 0) & (self.zy_mid < 0), + ) + + +# ── BIAS_SIGNAL 乖离信号 ────────────────────────────────────────────────────── + + +@register_strategy( + name="bias_signal_cross", + label="BIAS_SIGNAL 乖离信号金叉", + description="短信号线上穿长信号线买入(乖离拐头向上),下穿卖出。", +) +class BiasSignalCrossStrategy(ParametrizedStrategy): + """乖离率短/长信号线金叉死叉。""" + + params = [ + Param("p", int, default=10, min_value=2, max_value=30, label="短信号周期"), + Param("m", int, default=30, min_value=5, max_value=90, label="长信号周期"), + ] + param_constraints = [("p", "m")] + + def init(self) -> None: + self._x, self.s_short, self.s_long = self.I( + BIAS_SIGNAL, self.data.close, self.p["p"], self.p["m"] + ) + self.gold = self.I(CROSS, self.s_short, self.s_long) + self.dead = self.I(CROSS, self.s_long, self.s_short) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── SAR 抛物线跟随 ──────────────────────────────────────────────────────────── + + +@register_strategy( + name="sar_follow", + label="SAR 抛物线跟随", + description="收盘价上穿 SAR 买入,下穿 SAR 卖出(趋势跟随,SAR 即移动止损位)。", +) +class SarFollowStrategy(ParametrizedStrategy): + """价格与 SAR 交叉的抛物线跟随。""" + + params = [ + Param("af_step", float, default=0.02, min_value=0.005, max_value=0.2, label="加速步长"), + Param("af_max", float, default=0.2, min_value=0.05, max_value=0.5, label="加速上限"), + ] + param_constraints = [("af_step", "af_max")] + + def init(self) -> None: + self.sar = self.I(SAR, self.data.high, self.data.low, self.p["af_step"], self.p["af_max"]) + self.gold = self.I(CROSS, self.data.close, self.sar) + self.dead = self.I(CROSS, self.sar, self.data.close) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── VWAP 成本线 ─────────────────────────────────────────────────────────────── + + +@register_strategy( + name="vwap_cross", + label="VWAP 成本线穿越", + description="收盘价上穿 N 日 VWAP 买入(站上机构成本),下穿卖出。", +) +class VwapCrossStrategy(ParametrizedStrategy): + """价格与滚动 VWAP 交叉。""" + + params = [ + Param("n", int, default=20, min_value=5, max_value=60, label="VWAP周期"), + ] + + def init(self) -> None: + self.vwap = self.I( + VWAP, self.data.close, self.data.high, self.data.low, self.data.vol, self.p["n"] + ) + self.gold = self.I(CROSS, self.data.close, self.vwap) + self.dead = self.I(CROSS, self.vwap, self.data.close) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── AROON 阿隆 ──────────────────────────────────────────────────────────────── + + +@register_strategy( + name="aroon_cross", + label="AROON 阿隆金叉", + description="阿隆上线(创新高动能)上穿下线(创新低动能)买入,反向卖出。", +) +class AroonCrossStrategy(ParametrizedStrategy): + """AROON 上/下线金叉死叉。""" + + params = [ + Param("n", int, default=25, min_value=5, max_value=60, label="回看周期"), + ] + + def init(self) -> None: + self.up, self.down, self._osc = self.I(AROON, self.data.high, self.data.low, self.p["n"]) + self.gold = self.I(CROSS, self.up, self.down) + self.dead = self.I(CROSS, self.down, self.up) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── FK 超卖反弹 ─────────────────────────────────────────────────────────────── + + +@register_strategy( + name="fk_reversal", + label="FK 超卖反弹", + description="FK 转为 True(价格相对趋势外推线超卖)买入,转回 False 卖出。", +) +class FkReversalStrategy(ParametrizedStrategy): + """FK 布尔信号的边沿触发。""" + + params = [] # 无参数策略(基类默认即空 schema,显式声明便于阅读) + + def init(self) -> None: + fk = np.asarray(self.I(FK, self.data.close), dtype=bool) + prev = np.concatenate(([False], fk[:-1])) + self.sig_on = fk & ~prev # FK 变 True:超卖反弹信号出现 + self.sig_off = ~fk & prev # FK 变 False:反弹动能消退 + + def next(self) -> None: + i = self._bar_index + if self.sig_on[i]: + self.buy() + elif self.sig_off[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(sig_on/sig_off 即 next() 判定用的同一组掩码数组)。""" + return self.sig_on, self.sig_off + + +# ═══════════════════════════════════════════════════════════════════════════ +# V4.3 新指标策略(MyTT 新增 16 个无未来函数指标的首发策略) +# ═══════════════════════════════════════════════════════════════════════════ + + +# ── SuperTrend 超级趋势 ─────────────────────────────────────────────────────── + + +@register_strategy( + name="supertrend", + label="SuperTrend 超级趋势", + description="趋势方向翻多买入(价格上穿带),翻空卖出(价格下穿带)。带线即移动止损位。", +) +class SupertrendStrategy(ParametrizedStrategy): + """SuperTrend 方向翻转跟随。""" + + params = [ + Param("n", int, default=10, min_value=5, max_value=50, label="ATR周期"), + Param("m", float, default=3.0, min_value=1.0, max_value=6.0, label="ATR倍数"), + ] + + def init(self) -> None: + self.st, self.st_dir = self.I( + SUPERTREND, self.data.close, self.data.high, self.data.low, self.p["n"], self.p["m"] + ) + d = np.asarray(self.st_dir, dtype=float) + prev = REF(d, 1) # 首根为 NaN → 首根不产生信号 + self.gold = (d == 1) & (prev == -1) # 翻多 + self.dead = (d == -1) & (prev == 1) # 翻空 + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """方向 翻多进 / 翻空出(与 next() 同一比较)。""" + return self.gold, self.dead + + +# ── KAMA 自适应均线 ─────────────────────────────────────────────────────────── + + +@register_strategy( + name="kama_cross", + label="KAMA 自适应均线穿越", + description="收盘价上穿 KAMA 买入,下穿卖出(震荡期均线自动走平,减少假信号)。", +) +class KamaCrossStrategy(ParametrizedStrategy): + """价格与 KAMA 交叉。""" + + params = [ + Param("n", int, default=10, min_value=5, max_value=60, label="效率比周期"), + Param("fast", int, default=2, min_value=2, max_value=10, label="快平滑常数"), + Param("slow", int, default=30, min_value=10, max_value=100, label="慢平滑常数"), + ] + param_constraints = [("fast", "slow")] + + def init(self) -> None: + self.kama = self.I(KAMA, self.data.close, self.p["n"], self.p["fast"], self.p["slow"]) + self.gold = self.I(CROSS, self.data.close, self.kama) + self.dead = self.I(CROSS, self.kama, self.data.close) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── HMA 赫尔均线 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="hma_cross", + label="HMA 赫尔均线交叉", + description="快 HMA 上穿慢 HMA 买入,下穿卖出(低滞后均线的经典双线用法)。", +) +class HmaCrossStrategy(ParametrizedStrategy): + """快/慢 HMA 金叉死叉。""" + + params = [ + Param("fast", int, default=10, min_value=2, max_value=30, label="快线周期"), + Param("slow", int, default=30, min_value=10, max_value=120, label="慢线周期"), + ] + param_constraints = [("fast", "slow")] + + def init(self) -> None: + self.hma_fast = self.I(HMA, self.data.close, self.p["fast"]) + self.hma_slow = self.I(HMA, self.data.close, self.p["slow"]) + self.gold = self.I(CROSS, self.hma_fast, self.hma_slow) + self.dead = self.I(CROSS, self.hma_slow, self.hma_fast) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── 吊灯止损系统 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="chandelier", + label="吊灯止损系统", + description="突破 N 日最高价买入(通道突破进场),跌破吊灯止损线 HHV-K×ATR 卖出。", +) +class ChandelierStrategy(ParametrizedStrategy): + """通道突破进场 + 吊灯止损离场(LeBeau 经典组合)。""" + + params = [ + Param("n", int, default=22, min_value=10, max_value=100, label="突破/止损周期"), + Param("m", int, default=22, min_value=5, max_value=50, label="ATR周期"), + Param("k", float, default=3.0, min_value=1.0, max_value=6.0, label="ATR倍数"), + ] + + def init(self) -> None: + self.upper = self.I(HHV, self.data.high, self.p["n"]) + self.long_stop, self._short_stop = self.I( + CHANDELIER, + self.data.close, + self.data.high, + self.data.low, + self.p["n"], + self.p["m"], + self.p["k"], + ) + + def next(self) -> None: + i = self._bar_index + close = self.data.close[0] + if close >= self.upper[i] and self.position["size"] == 0: + self.buy() + elif close <= self.long_stop[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """突破 N 日高进 / 跌破吊灯止损线出(NaN 预热期比较为 False,与 next() 一致)。""" + close = self.data.close.raw + return close >= self.upper, close <= self.long_stop + + +# ── ICHIMOKU 一目均衡 ───────────────────────────────────────────────────────── + + +@register_strategy( + name="ichimoku_cross", + label="ICHIMOKU 一目均衡金叉", + description="转换线上穿基准线且收盘在云层上方买入;转换线下穿基准线且收盘在云层下方卖出。", +) +class IchimokuCrossStrategy(ParametrizedStrategy): + """转换/基准线交叉 + 云层位置确认。""" + + params = [ + Param("p1", int, default=9, min_value=2, max_value=30, label="转换线周期"), + Param("p2", int, default=26, min_value=5, max_value=60, label="基准线/位移周期"), + Param("p3", int, default=52, min_value=10, max_value=120, label="先行带B周期"), + ] + param_constraints = [("p1", "p2"), ("p2", "p3")] + + def init(self) -> None: + self.tenkan, self.kijun, self.span_a, self.span_b, self._chikou = self.I( + ICHIMOKU, + self.data.high, + self.data.low, + self.data.close, + self.p["p1"], + self.p["p2"], + self.p["p3"], + ) + # 云顶/云底取先行带 A/B 的包络(先行带为 SHIFT 期前的值画到当前,仅引用过去数据) + cloud_top = np.maximum(self.span_a, self.span_b) + cloud_bot = np.minimum(self.span_a, self.span_b) + close = self.data.close.raw + self.gold = np.asarray(CROSS(self.tenkan, self.kijun)) & (close > cloud_top) + self.dead = np.asarray(CROSS(self.kijun, self.tenkan)) & (close < cloud_bot) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """金叉且价在云上进 / 死叉且价在云下出(与 next() 同一比较)。""" + return self.gold, self.dead + + +# ── UOS 终极指标 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="uos_reversal", + label="UOS 终极超卖", + description="UOS 跌破 30(三周期动量全面超卖)买入,涨破 70 卖出。", +) +class UosReversalStrategy(ParametrizedStrategy): + """UOS 超卖买入、超买卖出。""" + + params = [ + Param("p1", int, default=7, min_value=2, max_value=14, label="短周期"), + Param("p2", int, default=14, min_value=5, max_value=21, label="中周期"), + Param("p3", int, default=28, min_value=10, max_value=60, label="长周期"), + Param("oversold", int, default=30, min_value=5, max_value=45, label="超卖线"), + Param("overbought", int, default=70, min_value=55, max_value=95, label="超买线"), + ] + param_constraints = [("p1", "p2"), ("p2", "p3"), ("oversold", "overbought")] + + def init(self) -> None: + self.uos, self._uos_ma = self.I( + UOS, + self.data.close, + self.data.high, + self.data.low, + self.p["p1"], + self.p["p2"], + self.p["p3"], + ) + + def next(self) -> None: + i = self._bar_index + if self.uos[i] <= self.p["oversold"] and self.position["size"] == 0: + self.buy() + elif self.uos[i] >= self.p["overbought"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """UOS 跌破超卖线进 / 涨破超买线出(与 next() 同一比较)。""" + uos = np.asarray(self.uos, dtype=np.float64) + return uos <= self.p["oversold"], uos >= self.p["overbought"] + + +# ── CMO 钱德动量 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="cmo_reversal", + label="CMO 钱德动量超卖", + description="CMO 跌破 -阈值(纯下跌动能极值)买入,涨破 +阈值 卖出。", +) +class CmoReversalStrategy(ParametrizedStrategy): + """CMO 对称阈值反转。""" + + params = [ + Param("n", int, default=14, min_value=2, max_value=60, label="CMO周期"), + Param("threshold", float, default=50.0, min_value=10.0, max_value=90.0, label="阈值"), + ] + + def init(self) -> None: + self.cmo = self.I(CMO, self.data.close, self.p["n"]) + + def next(self) -> None: + i = self._bar_index + if self.cmo[i] <= -self.p["threshold"] and self.position["size"] == 0: + self.buy() + elif self.cmo[i] >= self.p["threshold"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """CMO 跌破 -阈值进 / 涨破 +阈值出(与 next() 同一比较)。""" + cmo = np.asarray(self.cmo, dtype=np.float64) + return cmo <= -self.p["threshold"], cmo >= self.p["threshold"] + + +# ── TSI 真实强度 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="tsi_cross", + label="TSI 真实强度金叉", + description="TSI 上穿信号线买入(双平滑动量转强),下穿卖出。", +) +class TsiCrossStrategy(ParametrizedStrategy): + """TSI/信号线金叉死叉。""" + + params = [ + Param("r", int, default=25, min_value=5, max_value=60, label="一阶平滑"), + Param("s", int, default=13, min_value=2, max_value=40, label="二阶平滑"), + Param("m", int, default=13, min_value=2, max_value=40, label="信号周期"), + ] + + def init(self) -> None: + self.tsi, self.tsi_signal = self.I( + TSI, self.data.close, self.p["r"], self.p["s"], self.p["m"] + ) + self.gold = self.I(CROSS, self.tsi, self.tsi_signal) + self.dead = self.I(CROSS, self.tsi_signal, self.tsi) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── FISHER 费雪变换 ─────────────────────────────────────────────────────────── + + +@register_strategy( + name="fisher_cross", + label="FISHER 费雪拐点", + description="Fisher 线上穿其触发线(前一期值)买入,下穿卖出(拐点尖锐、无钝化)。", +) +class FisherCrossStrategy(ParametrizedStrategy): + """Fisher/触发线金叉死叉。""" + + params = [ + Param("n", int, default=9, min_value=2, max_value=30, label="归一化周期"), + ] + + def init(self) -> None: + self.fisher, self.trigger = self.I(FISHER, self.data.high, self.data.low, self.p["n"]) + self.gold = self.I(CROSS, self.fisher, self.trigger) + self.dead = self.I(CROSS, self.trigger, self.fisher) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── SQUEEZE TTM 挤压突破 ────────────────────────────────────────────────────── + + +@register_strategy( + name="squeeze_breakout", + label="TTM 挤压突破", + description="波动挤压(布林收进肯特纳)解除且动量为正时买入,动量转负卖出。", +) +class SqueezeBreakoutStrategy(ParametrizedStrategy): + """挤压释放 + 动量方向确认。""" + + params = [ + Param("n", int, default=20, min_value=5, max_value=60, label="通道周期"), + Param("bb", float, default=2.0, min_value=0.5, max_value=4.0, label="布林倍数"), + Param("kc", float, default=1.5, min_value=0.5, max_value=4.0, label="肯特纳倍数"), + ] + + def init(self) -> None: + self.sqz, self.mom = self.I( + SQUEEZE, + self.data.close, + self.data.high, + self.data.low, + self.p["n"], + self.p["bb"], + self.p["kc"], + ) + sqz = np.asarray(self.sqz, dtype=bool) + prev = np.concatenate(([False], sqz[:-1])) + self.release = prev & ~sqz # 挤压解除(当期脱离挤压态) + self.mom_arr = np.asarray(self.mom, dtype=np.float64) + + def next(self) -> None: + i = self._bar_index + if self.release[i] and self.mom_arr[i] > 0 and self.position["size"] == 0: + self.buy() + elif self.mom_arr[i] < 0 and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """挤压解除且动量为正进 / 动量转负出(与 next() 同一比较)。""" + return self.release & (self.mom_arr > 0), self.mom_arr < 0 + + +# ── CHOP 趋态过滤 ───────────────────────────────────────────────────────────── + + +@register_strategy( + name="chop_trend", + label="CHOP 趋态过滤", + description="盘整指数跌破趋势线且价格在均线上方买入(趋势启动);盘整指数升破震荡线卖出。", +) +class ChopTrendStrategy(ParametrizedStrategy): + """CHOP 状态开关 + 均线方向过滤。""" + + params = [ + Param("n", int, default=14, min_value=5, max_value=40, label="CHOP周期"), + Param("n_ma", int, default=20, min_value=5, max_value=120, label="方向均线周期"), + Param("trend_th", float, default=38.2, min_value=20.0, max_value=50.0, label="趋势阈值"), + Param("range_th", float, default=61.8, min_value=55.0, max_value=90.0, label="震荡阈值"), + ] + param_constraints = [("trend_th", "range_th")] + + def init(self) -> None: + self.chop = self.I(CHOP, self.data.close, self.data.high, self.data.low, self.p["n"]) + self.ma = self.I(MA, self.data.close, self.p["n_ma"]) + + def next(self) -> None: + i = self._bar_index + close = self.data.close[0] + if self.chop[i] <= self.p["trend_th"] and close > self.ma[i] and self.position["size"] == 0: + self.buy() + elif self.chop[i] >= self.p["range_th"] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """CHOP 进入趋势态且价在均线上进 / CHOP 进入震荡态出(与 next() 同一比较)。""" + chop = np.asarray(self.chop, dtype=np.float64) + close = self.data.close.raw + return (chop <= self.p["trend_th"]) & (close > self.ma), chop >= self.p["range_th"] + + +# ── AD 累积/派发线 ──────────────────────────────────────────────────────────── + + +@register_strategy( + name="ad_cross", + label="AD 累派线金叉", + description="累积/派发线上穿其均线买入(吸筹转强),下穿卖出(派发占优)。", +) +class AdCrossStrategy(ParametrizedStrategy): + """AD 与其均线金叉死叉。""" + + params = [ + Param("m", int, default=30, min_value=5, max_value=120, label="均线周期"), + ] + + def init(self) -> None: + self.ad = self.I(AD, self.data.close, self.data.high, self.data.low, self.data.vol) + self.ad_ma = self.I(MA, self.ad, self.p["m"]) + self.gold = self.I(CROSS, self.ad, self.ad_ma) + self.dead = self.I(CROSS, self.ad_ma, self.ad) + + def next(self) -> None: + i = self._bar_index + if self.gold[i]: + self.buy() + elif self.dead[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" + return self.gold, self.dead + + +# ── CMF 佳庆资金流 ──────────────────────────────────────────────────────────── + + +@register_strategy( + name="cmf_zero", + label="CMF 资金流零轴", + description="CMF 转正买入(资金净流入),转负卖出(资金净流出)。", +) +class CmfZeroStrategy(ParametrizedStrategy): + """CMF 0 轴多空切换。""" + + params = [ + Param("n", int, default=20, min_value=5, max_value=60, label="CMF周期"), + ] + + def init(self) -> None: + self.cmf = self.I( + CMF, self.data.close, self.data.high, self.data.low, self.data.vol, self.p["n"] + ) + + def next(self) -> None: + i = self._bar_index + if self.cmf[i] > 0 and self.position["size"] == 0: + self.buy() + elif self.cmf[i] < 0 and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """CMF 上穿 0 轴进 / 下穿 0 轴出(与 next() 同一比较)。""" + cmf = np.asarray(self.cmf, dtype=np.float64) + return cmf > 0, cmf < 0 + + +# ── EFI 艾尔德强力指数 ──────────────────────────────────────────────────────── + + +@register_strategy( + name="efi_zero", + label="EFI 强力指数零轴", + description="强力指数转正买入(多方力量占优),转负卖出(空方力量占优)。", +) +class EfiZeroStrategy(ParametrizedStrategy): + """EFI 0 轴多空切换。""" + + params = [ + Param("n", int, default=13, min_value=2, max_value=60, label="平滑周期"), + ] + + def init(self) -> None: + self.efi = self.I(EFI, self.data.close, self.data.vol, self.p["n"]) + + def next(self) -> None: + i = self._bar_index + if self.efi[i] > 0 and self.position["size"] == 0: + self.buy() + elif self.efi[i] < 0 and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """EFI 上穿 0 轴进 / 下穿 0 轴出(与 next() 同一比较)。""" + efi = np.asarray(self.efi, dtype=np.float64) + return efi > 0, efi < 0 + + +# ── BBP 布林位置 ────────────────────────────────────────────────────────────── + + +@register_strategy( + name="bbp_reversal", + label="BBP 布林位置超卖", + description="%B 跌破 0(收盘跌破布林下轨)买入,涨破 100(升破上轨)卖出。", +) +class BbpReversalStrategy(ParametrizedStrategy): + """%B 0/100 上下轨反转。""" + + params = [ + Param("n", int, default=20, min_value=5, max_value=60, label="布林周期"), + Param("p", float, default=2.0, min_value=0.5, max_value=4.0, label="标准差倍数"), + ] + + def init(self) -> None: + self.bbp = self.I(BBP, self.data.close, self.p["n"], self.p["p"]) + + def next(self) -> None: + i = self._bar_index + if self.bbp[i] <= 0 and self.position["size"] == 0: + self.buy() + elif self.bbp[i] >= 100 and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """%B 跌破 0 进 / 涨破 100 出(与 next() 同一比较)。""" + bbp = np.asarray(self.bbp, dtype=np.float64) + return bbp <= 0, bbp >= 100 + + +# ── BBW 布林带宽挤压 ────────────────────────────────────────────────────────── + + +@register_strategy( + name="bbw_squeeze", + label="BBW 带宽挤压突破", + description="带宽收敛至 N 日最低(波动挤压)且价格站上均线买入;跌破均线卖出。", +) +class BbwSqueezeStrategy(ParametrizedStrategy): + """带宽极值挤压 + 均线方向突破。""" + + params = [ + Param("n", int, default=20, min_value=5, max_value=60, label="带宽/挤压周期"), + Param("p", float, default=2.0, min_value=0.5, max_value=4.0, label="标准差倍数"), + Param("n_ma", int, default=20, min_value=5, max_value=120, label="方向均线周期"), + ] + + def init(self) -> None: + self.bbw = self.I(BBW, self.data.close, self.p["n"], self.p["p"]) + self.bbw_low = self.I(LLV, self.bbw, self.p["n"]) + self.ma = self.I(MA, self.data.close, self.p["n_ma"]) + + def next(self) -> None: + i = self._bar_index + close = self.data.close[0] + if self.bbw[i] <= self.bbw_low[i] and close > self.ma[i] and self.position["size"] == 0: + self.buy() + elif close < self.ma[i] and self.position["size"] > 0: + self.sell() + + def entry_exit_masks(self) -> tuple[Any, Any]: + """带宽触 N 日低且价在均线上进 / 价跌破均线出(NaN 预热期比较为 False,与 next() 一致)。""" + close = self.data.close.raw + return (self.bbw <= self.bbw_low) & (close > self.ma), close < self.ma diff --git a/src/easy_tdx/indicator.py b/src/easy_tdx/indicator.py index 2b97cea..6bbef1c 100644 --- a/src/easy_tdx/indicator.py +++ b/src/easy_tdx/indicator.py @@ -239,6 +239,97 @@ _reg( "FK 趋势指标(EMA(2) 突破斜率外推 EMA(42),动量偏离检测)", ) +# ── V4.3 无未来函数指标(全部只引用当期及历史数据,信号不漂移) ────────── + +# 趋势/止损类 +_reg( + "SUPERTREND", + ("close", "high", "low"), + ("SUPERTREND", "ST_DIR"), + MyTT.SUPERTREND, + {"N": 10, "M": 3.0}, + "SUPERTREND 超级趋势(ATR 通道趋势跟踪,兼移动止损;ST_DIR=1多/-1空)", +) +_reg( + "CHANDELIER", + ("close", "high", "low"), + ("CHDL_LONG", "CHDL_SHORT"), + MyTT.CHANDELIER, + {"N": 22, "M": 22, "K": 3.0}, + "CHANDELIER 吊灯止损(多头/空头 ATR 跟踪止损位)", +) +_reg("HMA", ("close",), ("HMA",), MyTT.HMA, {"N": 16}, "HMA 赫尔均线(低滞后不重绘)") +_reg( + "KAMA", + ("close",), + ("KAMA",), + MyTT.KAMA, + {"N": 10, "FAST": 2, "SLOW": 30}, + "KAMA 考夫曼自适应均线(趋势市贴价/震荡市走平)", +) +_reg( + "ICHIMOKU", + ("high", "low", "close"), + ("IC_TENKAN", "IC_KIJUN", "IC_SPAN_A", "IC_SPAN_B", "IC_CHIKOU"), + MyTT.ICHIMOKU, + {"P1": 9, "P2": 26, "P3": 52, "SHIFT": 26}, + "ICHIMOKU 一目均衡表(迟行带 IC_CHIKOU 仅作图示,当期信号请用转换/基准线与先行带)", +) + +# 动量/振荡类 +_reg( + "UOS", + ("close", "high", "low"), + ("UOS", "UOS_MA"), + MyTT.UOS, + {"P1": 7, "P2": 14, "P3": 28, "M": 6}, + "UOS 终极指标(7/14/28 三周期加权动量)", +) +_reg("CMO", ("close",), ("CMO",), MyTT.CMO, {"N": 14}, "CMO 钱德动量振荡器") +_reg( + "TSI", ("close",), ("TSI", "TSI_MA"), MyTT.TSI, {"R": 25, "S": 13, "M": 13}, "TSI 真实强度指数" +) +_reg( + "FISHER", + ("high", "low"), + ("FISHER", "FISHER_TRG"), + MyTT.FISHER, + {"N": 9}, + "FISHER 费雪变换(区间位置正态化,拐点尖锐)", +) + +# 波动率/状态识别类 +_reg( + "SQUEEZE", + ("close", "high", "low"), + ("SQZ_ON", "SQZ_MOM"), + MyTT.SQUEEZE, + {"N": 20, "BB": 2.0, "KC": 1.5}, + "SQUEEZE TTM 挤压动量(布林收进肯特纳=挤压态,SQZ_MOM 为线性回归动量)", +) +_reg( + "CHOP", + ("close", "high", "low"), + ("CHOP",), + MyTT.CHOP, + {"N": 14}, + "CHOP 盘整指数(>61.8 震荡 / <38.2 趋势)", +) +_reg( + "BBP", + ("close",), + ("BBP",), + MyTT.BBP, + {"N": 20, "P": 2}, + "BBP 布林位置 %B(0=下轨,50=中轨,100=上轨)", +) +_reg("BBW", ("close",), ("BBW",), MyTT.BBW, {"N": 20, "P": 2}, "BBW 布林带宽(收窄=变盘预警)") + +# 量能/资金类 +_reg("AD", ("close", "high", "low", "vol"), ("AD",), MyTT.AD, {}, "AD 累积/派发线") +_reg("CMF", ("close", "high", "low", "vol"), ("CMF",), MyTT.CMF, {"N": 20}, "CMF 佳庆资金流量") +_reg("EFI", ("close", "vol"), ("EFI",), MyTT.EFI, {"N": 13}, "EFI 艾尔德强力指数") + def list_indicators() -> list[dict[str, object]]: """返回所有可用指标的元数据。""" diff --git a/tests/golden/backtest_metrics.json b/tests/golden/backtest_metrics.json index 088cd88..ed9d558 100644 --- a/tests/golden/backtest_metrics.json +++ b/tests/golden/backtest_metrics.json @@ -90,6 +90,45 @@ } }, "strategies": { + "ad_cross": { + "cvar_95": 0.024286411493930567, + "max_consecutive_losses": 2, + "max_consecutive_wins": 7, + "max_drawdown": 0.12904549392252831, + "sharpe": 1.413959529316044, + "sqn": 1.9143440657904827, + "total_return": 0.5433184647040252, + "total_trades": 16, + "ulcer_index": 0.04114422319311987, + "var_95": 0.018706317214833772, + "win_rate": 0.75 + }, + "aroon_cross": { + "cvar_95": 0.029266225981937833, + "max_consecutive_losses": 1, + "max_consecutive_wins": 1, + "max_drawdown": 0.23484266406886486, + "sharpe": -0.1318253497365565, + "sqn": -0.02853515598272609, + "total_return": -0.026907343025617925, + "total_trades": 8, + "ulcer_index": 0.11536620900391767, + "var_95": 0.02282875179249166, + "win_rate": 0.5 + }, + "asi_cross": { + "cvar_95": 0.02981959329970092, + "max_consecutive_losses": 3, + "max_consecutive_wins": 7, + "max_drawdown": 0.10955462389011326, + "sharpe": 0.7869328181478238, + "sqn": 1.285955774339363, + "total_return": 0.31630572526536627, + "total_trades": 37, + "ulcer_index": 0.051165506301142354, + "var_95": 0.020699239050410923, + "win_rate": 0.6216216216216216 + }, "atr_breakout": { "cvar_95": 0.029816014464276553, "max_consecutive_losses": 2, @@ -116,6 +155,32 @@ "var_95": 0.02185464362709669, "win_rate": 0.43243243243243246 }, + "bbp_reversal": { + "cvar_95": 0.025123143703972416, + "max_consecutive_losses": 1, + "max_consecutive_wins": 2, + "max_drawdown": 0.12529045393048568, + "sharpe": -0.08270429911478913, + "sqn": 0.984709712259246, + "total_return": 0.00949379410277551, + "total_trades": 3, + "ulcer_index": 0.039203351555811915, + "var_95": 0.015350573492733575, + "win_rate": 0.6666666666666666 + }, + "bbw_squeeze": { + "cvar_95": 0.024332883581541946, + "max_consecutive_losses": 6, + "max_consecutive_wins": 3, + "max_drawdown": 0.15870878255389867, + "sharpe": -0.4216428614980978, + "sqn": -0.5443884480781064, + "total_return": -0.059449894173721374, + "total_trades": 14, + "ulcer_index": 0.07851315060195874, + "var_95": 0.014460430360468098, + "win_rate": 0.35714285714285715 + }, "bias_reversal": { "cvar_95": 0.031800618199536355, "max_consecutive_losses": 4, @@ -129,6 +194,19 @@ "var_95": 0.02439437484336824, "win_rate": 0.5111111111111111 }, + "bias_signal_cross": { + "cvar_95": 0.02748879702227868, + "max_consecutive_losses": 4, + "max_consecutive_wins": 3, + "max_drawdown": 0.2938157158635232, + "sharpe": -0.17985871928985922, + "sqn": -0.03800371473682254, + "total_return": -0.03295246594055856, + "total_trades": 10, + "ulcer_index": 0.1651695141252374, + "var_95": 0.021475132232438594, + "win_rate": 0.5 + }, "boll_breakout": { "cvar_95": 0.025123143703972416, "max_consecutive_losses": 1, @@ -142,6 +220,19 @@ "var_95": 0.015350573492733575, "win_rate": 0.6666666666666666 }, + "brar_reversal": { + "cvar_95": -0.0, + "max_consecutive_losses": 0, + "max_consecutive_wins": 0, + "max_drawdown": 0.0, + "sharpe": 0.0, + "sqn": 0.0, + "total_return": 0.0, + "total_trades": 0, + "ulcer_index": 0.0, + "var_95": -0.0, + "win_rate": 0.0 + }, "cci": { "cvar_95": 0.028065506761634905, "max_consecutive_losses": 1, @@ -155,6 +246,84 @@ "var_95": 0.019114854583397116, "win_rate": 0.6363636363636364 }, + "chandelier": { + "cvar_95": -0.0, + "max_consecutive_losses": 0, + "max_consecutive_wins": 0, + "max_drawdown": 0.0, + "sharpe": 0.0, + "sqn": 0.0, + "total_return": 0.0, + "total_trades": 0, + "ulcer_index": 0.0, + "var_95": -0.0, + "win_rate": 0.0 + }, + "chop_trend": { + "cvar_95": 0.016521228683029757, + "max_consecutive_losses": 2, + "max_consecutive_wins": 1, + "max_drawdown": 0.1197429207500985, + "sharpe": 0.23777478167962413, + "sqn": 0.8450262324842597, + "total_return": 0.08186349084625855, + "total_trades": 4, + "ulcer_index": 0.02939434967310675, + "var_95": 0.007369600793270218, + "win_rate": 0.5 + }, + "cmf_zero": { + "cvar_95": 0.024141735522224646, + "max_consecutive_losses": 2, + "max_consecutive_wins": 5, + "max_drawdown": 0.14344144457956093, + "sharpe": 0.9133320420008789, + "sqn": 1.5782396113650308, + "total_return": 0.32189557881670994, + "total_trades": 20, + "ulcer_index": 0.05122369616579118, + "var_95": 0.0180655722025719, + "win_rate": 0.7 + }, + "cmo_reversal": { + "cvar_95": 0.026729960510057743, + "max_consecutive_losses": 1, + "max_consecutive_wins": 2, + "max_drawdown": 0.16495853483792264, + "sharpe": 0.6275440425539472, + "sqn": 1.8403059687214964, + "total_return": 0.2359516039506322, + "total_trades": 3, + "ulcer_index": 0.06452564310085397, + "var_95": 0.01934233590561444, + "win_rate": 0.6666666666666666 + }, + "cr_reversal": { + "cvar_95": 0.02369389369181201, + "max_consecutive_losses": 0, + "max_consecutive_wins": 0, + "max_drawdown": 0.12556824942616718, + "sharpe": 0.13618190785650225, + "sqn": 0.0, + "total_return": 0.06417970182278632, + "total_trades": 0, + "ulcer_index": 0.023595036704376637, + "var_95": 0.01255926432583018, + "win_rate": 0.0 + }, + "dfma_cross": { + "cvar_95": 0.0258934274012887, + "max_consecutive_losses": 4, + "max_consecutive_wins": 2, + "max_drawdown": 0.22430138432687624, + "sharpe": -0.2451029943989093, + "sqn": -0.21296181317887017, + "total_return": -0.047787872823673916, + "total_trades": 12, + "ulcer_index": 0.12547066912166988, + "var_95": 0.02065490866252133, + "win_rate": 0.4166666666666667 + }, "dmi": { "cvar_95": 0.029255190955404537, "max_consecutive_losses": 5, @@ -194,6 +363,19 @@ "var_95": 0.019685727992254924, "win_rate": 0.45 }, + "efi_zero": { + "cvar_95": 0.029368306602455728, + "max_consecutive_losses": 6, + "max_consecutive_wins": 3, + "max_drawdown": 0.12386834887621843, + "sharpe": 0.1310163040019758, + "sqn": 0.354718917262971, + "total_return": 0.058226194433868006, + "total_trades": 29, + "ulcer_index": 0.06909264613158413, + "var_95": 0.022919408952405854, + "win_rate": 0.3793103448275862 + }, "ema_cross": { "cvar_95": 0.030512787117540286, "max_consecutive_losses": 5, @@ -220,6 +402,45 @@ "var_95": 0.02157076164724241, "win_rate": 0.32142857142857145 }, + "expma_cross": { + "cvar_95": 0.032018722856295576, + "max_consecutive_losses": 4, + "max_consecutive_wins": 1, + "max_drawdown": 0.3291585898907232, + "sharpe": -1.1723060706959245, + "sqn": -2.9049331578730926, + "total_return": -0.30675148747892034, + "total_trades": 7, + "ulcer_index": 0.18255002434586737, + "var_95": 0.024497075615117908, + "win_rate": 0.14285714285714285 + }, + "fisher_cross": { + "cvar_95": 0.027142953781316027, + "max_consecutive_losses": 4, + "max_consecutive_wins": 6, + "max_drawdown": 0.13242479869280652, + "sharpe": 0.7747655122625644, + "sqn": 1.2258640743172122, + "total_return": 0.3000150148326932, + "total_trades": 39, + "ulcer_index": 0.050503189087610034, + "var_95": 0.019196177653336477, + "win_rate": 0.5128205128205128 + }, + "fk_reversal": { + "cvar_95": 0.027673675923482476, + "max_consecutive_losses": 3, + "max_consecutive_wins": 3, + "max_drawdown": 0.1374258713555942, + "sharpe": 0.05310588929298746, + "sqn": 0.2609297927675816, + "total_return": 0.03098826668855792, + "total_trades": 19, + "ulcer_index": 0.06681780689649293, + "var_95": 0.021675988751594637, + "win_rate": 0.42105263157894735 + }, "fsl": { "cvar_95": 0.028932815510588083, "max_consecutive_losses": 5, @@ -233,6 +454,45 @@ "var_95": 0.022744109351729155, "win_rate": 0.35714285714285715 }, + "hma_cross": { + "cvar_95": 0.02601608749612084, + "max_consecutive_losses": 4, + "max_consecutive_wins": 5, + "max_drawdown": 0.14013863361462378, + "sharpe": 0.9018510804449479, + "sqn": 1.3059591751970854, + "total_return": 0.32817705633540206, + "total_trades": 19, + "ulcer_index": 0.0731080920454242, + "var_95": 0.0191409819205396, + "win_rate": 0.631578947368421 + }, + "ichimoku_cross": { + "cvar_95": 0.030953331370946267, + "max_consecutive_losses": 1, + "max_consecutive_wins": 1, + "max_drawdown": 0.21803844858131277, + "sharpe": -0.1390386894303238, + "sqn": -0.09653804098207303, + "total_return": -0.02512157114818303, + "total_trades": 3, + "ulcer_index": 0.08946241642268053, + "var_95": 0.023037962387550195, + "win_rate": 0.3333333333333333 + }, + "kama_cross": { + "cvar_95": 0.029047612117214912, + "max_consecutive_losses": 4, + "max_consecutive_wins": 3, + "max_drawdown": 0.12857725100336337, + "sharpe": 0.32104883660224726, + "sqn": 0.7764180118662009, + "total_return": 0.12589848610890342, + "total_trades": 28, + "ulcer_index": 0.06905462712627519, + "var_95": 0.022124546340830314, + "win_rate": 0.42857142857142855 + }, "kdj_cross": { "cvar_95": 0.027898201822013535, "max_consecutive_losses": 3, @@ -285,6 +545,84 @@ "var_95": 0.02189769434012649, "win_rate": 0.35294117647058826 }, + "mass_cross": { + "cvar_95": 0.03054297901767067, + "max_consecutive_losses": 2, + "max_consecutive_wins": 3, + "max_drawdown": 0.18266006667782547, + "sharpe": -0.14300372942671147, + "sqn": -0.00020527251510869655, + "total_return": -0.027331098242567076, + "total_trades": 19, + "ulcer_index": 0.09032412136723328, + "var_95": 0.02275460045342278, + "win_rate": 0.5789473684210527 + }, + "mfi_reversal": { + "cvar_95": 0.03034753275884507, + "max_consecutive_losses": 0, + "max_consecutive_wins": 2, + "max_drawdown": 0.2549569044138692, + "sharpe": 0.44061001264676536, + "sqn": 2.139161183716703, + "total_return": 0.1876926316446088, + "total_trades": 2, + "ulcer_index": 0.13667491572629412, + "var_95": 0.02415090052701228, + "win_rate": 1.0 + }, + "mtm_cross": { + "cvar_95": 0.02750195617227611, + "max_consecutive_losses": 4, + "max_consecutive_wins": 4, + "max_drawdown": 0.11827463808180305, + "sharpe": 0.3325814486269132, + "sqn": 0.7509810732371223, + "total_return": 0.12604681721555666, + "total_trades": 41, + "ulcer_index": 0.06304678610789029, + "var_95": 0.019735845693466002, + "win_rate": 0.5365853658536586 + }, + "obv_cross": { + "cvar_95": 0.027762055897527498, + "max_consecutive_losses": 5, + "max_consecutive_wins": 3, + "max_drawdown": 0.23680264438544252, + "sharpe": -0.5559242026190786, + "sqn": -0.5854574307963847, + "total_return": -0.14813287593396007, + "total_trades": 28, + "ulcer_index": 0.12848631836406813, + "var_95": 0.02210768744086329, + "win_rate": 0.42857142857142855 + }, + "psy_reversal": { + "cvar_95": 0.02891784823229219, + "max_consecutive_losses": 2, + "max_consecutive_wins": 0, + "max_drawdown": 0.16281839827998396, + "sharpe": -0.1335645556612112, + "sqn": -3.036213974648354, + "total_return": -0.02547860131516233, + "total_trades": 2, + "ulcer_index": 0.07622136686886101, + "var_95": 0.02124766218780112, + "win_rate": 0.0 + }, + "roc_zero": { + "cvar_95": 0.028767825863300883, + "max_consecutive_losses": 7, + "max_consecutive_wins": 4, + "max_drawdown": 0.18493301021337408, + "sharpe": -0.31536772495096077, + "sqn": -0.4545879597619188, + "total_return": -0.08338098843843589, + "total_trades": 29, + "ulcer_index": 0.09798341426090898, + "var_95": 0.0218502235858578, + "win_rate": 0.3103448275862069 + }, "rsi_reversal": { "cvar_95": 0.030776659581515254, "max_consecutive_losses": 1, @@ -298,6 +636,45 @@ "var_95": 0.024060510820245292, "win_rate": 0.5 }, + "sar_follow": { + "cvar_95": 0.02857107698245897, + "max_consecutive_losses": 4, + "max_consecutive_wins": 3, + "max_drawdown": 0.1898358700030981, + "sharpe": -0.19331831081777617, + "sqn": -0.10641340723878887, + "total_return": -0.04272895317218284, + "total_trades": 18, + "ulcer_index": 0.09655157548485278, + "var_95": 0.021467949126669807, + "win_rate": 0.5 + }, + "squeeze_breakout": { + "cvar_95": 0.024742251658719575, + "max_consecutive_losses": 3, + "max_consecutive_wins": 1, + "max_drawdown": 0.21733278570085734, + "sharpe": -0.9308991323778134, + "sqn": -1.0480133746814557, + "total_return": -0.14111036967684343, + "total_trades": 4, + "ulcer_index": 0.1320941903542238, + "var_95": 0.013420945706258479, + "win_rate": 0.25 + }, + "supertrend": { + "cvar_95": 0.03072730738114505, + "max_consecutive_losses": 5, + "max_consecutive_wins": 1, + "max_drawdown": 0.28689785114697847, + "sharpe": -0.4953361337737291, + "sqn": -0.7107356522879448, + "total_return": -0.13704473609360412, + "total_trades": 9, + "ulcer_index": 0.1646826003824428, + "var_95": 0.02294677360521743, + "win_rate": 0.2222222222222222 + }, "triple_ma": { "cvar_95": 0.030748053405195853, "max_consecutive_losses": 2, @@ -324,6 +701,58 @@ "var_95": 0.021929213323307422, "win_rate": 0.5 }, + "tsi_cross": { + "cvar_95": 0.028194974350123875, + "max_consecutive_losses": 7, + "max_consecutive_wins": 2, + "max_drawdown": 0.17090407134804525, + "sharpe": -0.4846436640713545, + "sqn": -0.7878323795262339, + "total_return": -0.13001127539228385, + "total_trades": 15, + "ulcer_index": 0.10515558844327079, + "var_95": 0.022322699465880603, + "win_rate": 0.3333333333333333 + }, + "uos_reversal": { + "cvar_95": 0.030193757775442014, + "max_consecutive_losses": 0, + "max_consecutive_wins": 1, + "max_drawdown": 0.16294320323774955, + "sharpe": 0.44386948720403563, + "sqn": 0.0, + "total_return": 0.1801330140672519, + "total_trades": 1, + "ulcer_index": 0.06537062768285902, + "var_95": 0.02347496839245481, + "win_rate": 1.0 + }, + "vr_reversal": { + "cvar_95": 0.0004713577549215952, + "max_consecutive_losses": 0, + "max_consecutive_wins": 1, + "max_drawdown": 0.06396617092173154, + "sharpe": -0.03368193863407347, + "sqn": 0.0, + "total_return": 0.04095785734355717, + "total_trades": 1, + "ulcer_index": 0.009850103065533909, + "var_95": -0.0, + "win_rate": 1.0 + }, + "vwap_cross": { + "cvar_95": 0.029302922491254713, + "max_consecutive_losses": 8, + "max_consecutive_wins": 3, + "max_drawdown": 0.16046654742962813, + "sharpe": -0.009907074763182101, + "sqn": 0.1445122693129962, + "total_return": 0.011710672435813363, + "total_trades": 21, + "ulcer_index": 0.08439886174470289, + "var_95": 0.022126808733294197, + "win_rate": 0.38095238095238093 + }, "wr_reversal": { "cvar_95": -0.0, "max_consecutive_losses": 0, @@ -336,6 +765,32 @@ "ulcer_index": 0.0, "var_95": -0.0, "win_rate": 0.0 + }, + "xsii_breakout": { + "cvar_95": 0.02864695342561932, + "max_consecutive_losses": 3, + "max_consecutive_wins": 3, + "max_drawdown": 0.1495877433127484, + "sharpe": -0.09431845461633159, + "sqn": 0.14548639609968736, + "total_return": -0.013129418686880778, + "total_trades": 16, + "ulcer_index": 0.07348301348613494, + "var_95": 0.021802116298984472, + "win_rate": 0.4375 + }, + "zhuoyao_trend": { + "cvar_95": 0.030356181136770456, + "max_consecutive_losses": 2, + "max_consecutive_wins": 2, + "max_drawdown": 0.25518084654756473, + "sharpe": -0.574715368538664, + "sqn": -1.2971641853791143, + "total_return": -0.12930241173833024, + "total_trades": 5, + "ulcer_index": 0.13655713750977927, + "var_95": 0.021555547544702937, + "win_rate": 0.4 } } } diff --git a/tests/unit/test_backtest_engine_vector.py b/tests/unit/test_backtest_engine_vector.py index 524adfb..5a2fdb4 100644 --- a/tests/unit/test_backtest_engine_vector.py +++ b/tests/unit/test_backtest_engine_vector.py @@ -3,7 +3,7 @@ 核心保证:**同一 df + 同参数下,向量化路径与逐 bar 路径的输出逐位一致** (performance / trades / equity_curve / positions 全比对)。 -- 对拍覆盖:全部 19 个内置策略(默认参数)+ ma_cross/macd/boll/rsi 的非默认 +- 对拍覆盖:全部内置策略(默认参数,当前 54 个)+ ma_cross/macd/boll/rsi 的非默认 参数组合 + warmup / 极低资金(买不足 1 手的退化路径)/ 非默认费率与成交价模式; - 约束检测:``_vectorize_eligibility`` 的显式约束(无掩码 / 缠论注入)与 ``signal_path`` 的 auto/vector/loop 语义。 @@ -115,7 +115,7 @@ def _run_both( return loop, vec -# ── 对拍:19 个内置策略 × 默认参数 ─────────────────────────────────────────── +# ── 对拍:全部内置策略 × 默认参数 ─────────────────────────────────────────── #: 已知「默认参数下不会交易」的策略:MyTT 的 WR 是 0~100 刻度(100=超卖), diff --git a/tests/unit/test_mytt.py b/tests/unit/test_mytt.py index 901e133..1b308dc 100644 --- a/tests/unit/test_mytt.py +++ b/tests/unit/test_mytt.py @@ -1,4 +1,4 @@ -"""MyTT.py 新增指标函数(SAR/VWAP/AROON/FK)的数值正确性与边界测试。 +"""MyTT.py 新增指标函数(SAR/VWAP/AROON/FK + V4.3 十六个无未来函数指标)的数值正确性与边界测试。 这些测试针对 MyTT.py 里函数本身,不经过 indicator.py 注册层。 注册层的端到端覆盖在 test_indicator.py::TestComputeIndicators::test_all_registered_indicators_run。 @@ -214,3 +214,346 @@ class TestFK: close = np.array([100 - i for i in range(100)], dtype=float) fk = MyTT.FK(close) assert bool(fk[-1]) is True + + +# ═══════════════════════════════════════════════════════════════════════════ +# V4.3 新增:16 个无未来函数指标 +# ═══════════════════════════════════════════════════════════════════════════ + +#: 新指标的构造器:统一接收 (open, high, low, close, vol) 五元组,返回输出元组。 +#: 用于「无未来函数」前缀一致性回归(见 TestNoLookahead)。 +_V43_INDICATORS = { + "HMA": lambda o, h, lo, c, v: (MyTT.HMA(c, 16),), + "KAMA": lambda o, h, lo, c, v: (MyTT.KAMA(c),), + "SUPERTREND": lambda o, h, lo, c, v: MyTT.SUPERTREND(c, h, lo), + "CHANDELIER": lambda o, h, lo, c, v: MyTT.CHANDELIER(c, h, lo), + "ICHIMOKU": lambda o, h, lo, c, v: MyTT.ICHIMOKU(h, lo, c), + "UOS": lambda o, h, lo, c, v: MyTT.UOS(c, h, lo), + "CMO": lambda o, h, lo, c, v: (MyTT.CMO(c),), + "TSI": lambda o, h, lo, c, v: MyTT.TSI(c), + "FISHER": lambda o, h, lo, c, v: MyTT.FISHER(h, lo), + "SQUEEZE": lambda o, h, lo, c, v: MyTT.SQUEEZE(c, h, lo), + "CHOP": lambda o, h, lo, c, v: (MyTT.CHOP(h, lo, c),), + "AD": lambda o, h, lo, c, v: (MyTT.AD(c, h, lo, v),), + "CMF": lambda o, h, lo, c, v: (MyTT.CMF(c, h, lo, v),), + "EFI": lambda o, h, lo, c, v: (MyTT.EFI(c, v),), + "BBP": lambda o, h, lo, c, v: (MyTT.BBP(c),), + "BBW": lambda o, h, lo, c, v: (MyTT.BBW(c),), +} + + +class TestNoLookahead: + """无未来函数回归:指标在全序列上前缀段输出 == 仅用前缀数据计算的输出。 + + 未来函数(如 ZIG)的致命特征是:后到的数据会改写历史输出。本测试 + 把 200 根 K 线截断到前 120 根,两组输出在重叠段必须逐位一致—— + 任何引用了 t+1 及之后数据的实现都会当场爆红。 + """ + + PREFIX = 120 + + @pytest.mark.parametrize("name", sorted(_V43_INDICATORS)) + def test_prefix_stability(self, name): + ohlcv = _ohlcv(200) + full = _V43_INDICATORS[name](*ohlcv) + part = _V43_INDICATORS[name](*[x[: self.PREFIX] for x in ohlcv]) + for j, (f, p) in enumerate(zip(full, part)): + # ICHIMOKU 迟行带引用未来数据画图(文档已声明仅作图示), + # 它是唯一允许前缀不一致的输出,单独跳过(见 TestICHIMOKU)。 + if name == "ICHIMOKU" and j == 4: + continue + assert np.allclose(f[: self.PREFIX], p, equal_nan=True), ( + f"{name} 输出#{j} 前缀不一致:疑似引用了未来数据" + ) + + +class TestHMA: + def test_warmup_and_length(self): + close = _ohlcv()[3] + hma = MyTT.HMA(close, 16) + assert len(hma) == len(close) + assert np.isnan(hma[:14]).all() # 最内层 WMA(16) 窗口预热 + assert not np.isnan(hma[19]) + + def test_rising_market_follows_price(self): + close = np.arange(100, dtype=float) * 0.5 + 10 + hma = MyTT.HMA(close, 16) + # 单边上涨中低滞后均线应贴在价格下方且不发散 + assert (hma[20:] <= close[20:] + 1e-6).all() + assert hma[-1] > close[-2] # 跟随上涨 + + +class TestKAMA: + def test_warmup_starts_at_n(self): + close = _ohlcv()[3] + kama = MyTT.KAMA(close, N=10) + assert np.isnan(kama[:10]).all() + assert not np.isnan(kama[10]) + + def test_strong_trend_hugs_price(self): + # 单边强趋势:效率比≈1,KAMA 平滑系数取快速极值,稳态滞后 ≈ (1-sc)/sc ≈ 1.25 根 + close = np.cumsum(np.ones(100)) # 每根 +1 的完美趋势 + kama = MyTT.KAMA(close, N=10) + assert np.abs(kama[-1] - close[-1]) < 2.0 + + def test_flat_market_flat_kama(self): + kama = MyTT.KAMA(np.full(60, 10.0), N=10) + assert np.allclose(kama[10:], 10.0) + + +class TestSUPERTREND: + def test_direction_values(self): + _, high, low, close, _ = _ohlcv() + st, direction = MyTT.SUPERTREND(close, high, low) + assert set(np.unique(direction).tolist()) <= {1, -1} + assert np.isfinite(st).all() + + def test_rising_market_st_below_price(self): + # 持续上涨:趋势为多,ST(下轨)应持续低于最低价 + high = np.arange(80, dtype=float) + 1 + low = np.arange(80, dtype=float) + close = high.copy() + st, direction = MyTT.SUPERTREND(close, high, low, N=10, M=3.0) + assert direction[-1] == 1 + assert (st[10:] <= low[10:] + 1e-6).all() + + def test_reversal_flips_direction(self): + # V 型反转:方向必须从 1 翻到 -1 + half = np.arange(40, dtype=float) + close = np.concatenate([half + 1, 40 - half]) + high = close + 0.5 + low = close - 0.5 + _, direction = MyTT.SUPERTREND(close, high, low) + assert direction[0] != direction[-1] + + def test_empty_input(self): + st, direction = MyTT.SUPERTREND(np.array([]), np.array([]), np.array([])) + assert len(st) == 0 and len(direction) == 0 + + +class TestCHANDELIER: + def test_stops_bracket_price(self): + _, high, low, close, _ = _ohlcv() + long_stop, short_stop = MyTT.CHANDELIER(close, high, low, N=22, M=22, K=3.0) + # 吊灯止损锚定通道极值:多头止损在 N 日最高价下方、空头止损在 N 日最低价上方 + # (下跌段中 long_stop 可以高于当根 high——这正是吊灯线滞后等待离场的行为) + valid = slice(22, None) # TR[0] 为 NaN(REF 前收盘缺失)→ ATR 自 22 起有效 + assert (long_stop[valid] < MyTT.HHV(high, 22)[valid]).all() + assert (short_stop[valid] > MyTT.LLV(low, 22)[valid]).all() + + def test_matches_manual_formula(self): + _, high, low, close, _ = _ohlcv() + long_stop, _ = MyTT.CHANDELIER(close, high, low, N=22, M=22, K=2.0) + expected = MyTT.HHV(high, 22) - MyTT.ATR(close, high, low, 22) * 2.0 + assert np.allclose(long_stop, expected, equal_nan=True) + + +class TestICHIMOKU: + def test_five_outputs_lengths(self): + _, high, low, close, _ = _ohlcv() + outs = MyTT.ICHIMOKU(high, low, close) + assert len(outs) == 5 + for arr in outs: + assert len(arr) == len(close) + + def test_tenkan_formula(self): + _, high, low, close, _ = _ohlcv() + tenkan, _, _, _, _ = MyTT.ICHIMOKU(high, low, close, P1=9, P2=26, P3=52) + expected = (MyTT.HHV(high, 9) + MyTT.LLV(low, 9)) / 2 + assert np.allclose(tenkan, expected, equal_nan=True) + + def test_span_is_shifted_past(self): + # 先行带 = 26 期前的 (转换线+基准线)/2:i 处的值来自 i-26(过去) + _, high, low, close, _ = _ohlcv() + _, _, span_a, _, _ = MyTT.ICHIMOKU(high, low, close, P1=9, P2=26, P3=52, SHIFT=26) + tenkan = (MyTT.HHV(high, 9) + MyTT.LLV(low, 9)) / 2 + kijun = (MyTT.HHV(high, 26) + MyTT.LLV(low, 26)) / 2 + raw = (tenkan + kijun) / 2 + assert np.allclose(span_a[26:], raw[:-26], equal_nan=True) + + def test_chikou_tail_nan(self): + # 迟行带 = 当前收盘画回 26 期前:末尾 26 个槽位无对应未来数据 → NaN + _, high, low, close, _ = _ohlcv() + *_, chikou = MyTT.ICHIMOKU(high, low, close) + assert np.isnan(chikou[-26:]).all() + assert not np.isnan(chikou[:-26]).any() + + +class TestUOS: + def test_range_zero_to_hundred(self): + _, high, low, close, _ = _ohlcv() + uos, uos_ma = MyTT.UOS(close, high, low) + valid = uos[28:] # bp[0] 为 NaN(REF 前收盘缺失)→ P3=28 窗口自 28 起有效 + assert (valid >= 0).all() and (valid <= 100).all() + + def test_new_low_oversold(self): + # 持续创新低 → UOS 应处于超卖区(<50) + low = np.linspace(50, 1, 60) + close = low.copy() + high = low + 0.5 + uos, _ = MyTT.UOS(close, high, low) + assert uos[-1] < 50 + + def test_flat_market_neutral(self): + flat = np.full(60, 10.0) + uos, _ = MyTT.UOS(flat, flat, flat) + assert np.allclose(uos[28:], 50.0, equal_nan=True) # 除零保护取中性 + + +class TestCMO: + def test_symmetric_range(self): + close = _ohlcv()[3] + cmo = MyTT.CMO(close, N=14) + valid = cmo[14:] + assert (valid >= -100).all() and (valid <= 100).all() + + def test_rising_positive_falling_negative(self): + rise = np.cumsum(np.ones(100)) + assert MyTT.CMO(rise, N=14)[-1] > 0 # 纯上涨 → +100 极值 + fall = 100 - np.cumsum(np.ones(100)) + assert MyTT.CMO(fall, N=14)[-1] < 0 # 纯下跌 → -100 极值 + + +class TestTSI: + def test_signal_line_follows(self): + close = _ohlcv()[3] + tsi, signal = MyTT.TSI(close) + assert len(tsi) == len(signal) == len(close) + valid = ~np.isnan(tsi) & ~np.isnan(signal) + assert valid.any() + + def test_strong_rise_positive(self): + close = np.cumsum(np.ones(100)) + tsi, _ = MyTT.TSI(close) + assert tsi[-1] > 0 + + +class TestFISHER: + def test_trigger_is_prev_value(self): + _, high, low, _, _ = _ohlcv() + fisher, trigger = MyTT.FISHER(high, low, N=9) + assert np.allclose(trigger[1:], fisher[:-1], equal_nan=True) + + def test_strong_rise_positive_sharply(self): + high = np.linspace(1, 50, 100) + low = high - 0.5 + fisher, _ = MyTT.FISHER(high, low, N=9) + assert fisher[-1] > 1.0 # 顶部区域输出尖峰 + assert np.isfinite(fisher[8:]).all() # 钳制保证无 inf + + def test_bounded_input_clamp(self): + # 归一化值被钳制在 ±0.999 → 输出有限 + _, high, low, _, _ = _ohlcv() + fisher, _ = MyTT.FISHER(high, low, N=3) + assert np.isfinite(fisher[2:]).all() + + +class TestSQUEEZE: + def test_bool_flag_and_mom_length(self): + _, high, low, close, _ = _ohlcv() + sqz, mom = MyTT.SQUEEZE(close, high, low) + assert sqz.dtype == bool + assert len(sqz) == len(mom) == len(close) + + def test_flat_market_mom_zero(self): + flat = np.full(60, 10.0) + _, mom = MyTT.SQUEEZE(flat, flat, flat) + # 双层 N=20 窗口(带宽层 + 回归层)→ 有效值自 2N-1 起 + assert np.allclose(mom[39:], 0.0, atol=1e-9) + + +class TestCHOP: + def test_range_and_warmup(self): + _, high, low, close, _ = _ohlcv() + chop = MyTT.CHOP(high, low, close, N=14) + assert np.isnan(chop[:14]).all() # TR[0] 为 NaN → 14 窗口自 14 起有效 + valid = chop[14:] + assert (valid >= 0).all() and (valid <= 100).all() + + def test_strong_trend_low_chop(self): + # 完美趋势:ΣTR ≈ 区间 → chop 趋近低值 + high = np.arange(1, 81, dtype=float) + low = high - 1 + close = high.copy() + chop = MyTT.CHOP(high, low, close, N=14) + assert chop[-1] < 30 + + def test_oscillation_high_chop(self): + # 剧烈往返震荡(窗口跨 2 个以上完整周期):路径远大于区间 → chop 高 + t = np.arange(200) + close = 10 + 5 * np.sin(2 * np.pi * t / 6) + high = close + 0.5 + low = close - 0.5 + chop = MyTT.CHOP(high, low, close, N=14) + assert chop[14:].max() > 55 + + +class TestADandCMF: + def test_ad_manual_clv(self): + # 单根:CLV=((C-L)-(H-C))/(H-L),AD=CLV×VOL 累计;C=11.5 → CLV=(1.5-0.5)/2=0.5 + close = np.array([11.5, 11.5]) + high = np.array([12.0, 12.0]) + low = np.array([10.0, 10.0]) + vol = np.array([100.0, 100.0]) + ad = MyTT.AD(close, high, low, vol) + assert ad[0] == pytest.approx(0.5 * 100) + assert ad[1] == pytest.approx(100.0) + + def test_cmf_range_and_warmup(self): + _, high, low, close, vol = _ohlcv() + cmf = MyTT.CMF(close, high, low, vol, N=20) + assert np.isnan(cmf[:19]).all() + valid = cmf[19:] + assert (valid >= -1).all() and (valid <= 1).all() + + def test_cmf_sign_matches_close_position(self): + # 收盘持续靠近最高价(吸筹)→ CMF 为正 + n = 60 + close = np.linspace(10, 20, n) + high = close + 0.1 + low = close - 1.0 # 收盘贴近最高 + vol = np.full(n, 1000.0) + cmf = MyTT.CMF(close, high, low, vol, N=20) + assert cmf[-1] > 0 + + +class TestEFI: + def test_rising_with_volume_positive(self): + n = 100 + close = np.cumsum(np.ones(n)) + vol = np.full(n, 1000.0) + efi = MyTT.EFI(close, vol, N=13) + assert efi[-1] > 0 + + def test_length_and_warmup(self): + close = _ohlcv()[3] + vol = _ohlcv()[4] + efi = MyTT.EFI(close, vol, N=13) + assert len(efi) == len(close) + assert np.isnan(efi[0]) # DIFF 首位 NaN + + +class TestBBPandBBW: + def test_bbp_position_semantics(self): + # N=3 手工窗口 [10, 14, x]:mid=12、std=sqrt(8/3);close=mid → %B 恰为 50 + c_mid = np.array([10.0, 14.0, 12.0]) + assert MyTT.BBP(c_mid, N=3, P=2)[-1] == pytest.approx(50.0, abs=1e-6) + # 位置单调:同一窗口形态下,收盘越高 %B 越大 + lo = MyTT.BBP(np.array([10.0, 14.0, 11.0]), N=3, P=2)[-1] + hi = MyTT.BBP(np.array([10.0, 14.0, 13.0]), N=3, P=2)[-1] + assert lo < 50.0 < hi + # 公式口径:直接用 numpy 独立重算 (C-(mid-2sd))/(4sd)*100(RD 三位小数舍入) + window = np.array([10.0, 14.0, 13.0]) + mid, sd = window.mean(), window.std() + expected = (13.0 - (mid - 2 * sd)) / (4 * sd) * 100 + assert MyTT.BBP(window, N=3, P=2)[-1] == pytest.approx(expected, abs=1e-3) + + def test_bbw_zero_when_flat(self): + bbw = MyTT.BBW(np.full(60, 10.0), N=20, P=2) + assert np.allclose(bbw[19:], 0.0) + + def test_bbw_grows_with_volatility(self): + rng = np.random.default_rng(7) + quiet = 100 + rng.standard_normal(60) * 0.1 + wild = 100 + rng.standard_normal(60) * 5.0 + assert MyTT.BBW(wild)[-1] > MyTT.BBW(quiet)[-1]