"""MyTT.py 新增指标函数(SAR/VWAP/AROON/FK + V4.3 十六个无未来函数指标)的数值正确性与边界测试。 这些测试针对 MyTT.py 里函数本身,不经过 indicator.py 注册层。 注册层的端到端覆盖在 test_indicator.py::TestComputeIndicators::test_all_registered_indicators_run。 """ from __future__ import annotations import numpy as np import pandas as pd import pytest from easy_tdx import MyTT def _ohlcv(n: int = 200, seed: int = 42) -> tuple[np.ndarray, ...]: rng = np.random.default_rng(seed) close = 100 + np.cumsum(rng.standard_normal(n) * 0.5) high = close + np.abs(rng.standard_normal(n)) low = close - np.abs(rng.standard_normal(n)) open_ = low + (high - low) * rng.random(n) vol = (rng.random(n) * 1e6 + 1.0).astype(float) # +1 避免全零 return open_, high, low, close, vol class TestSAR: """SAR 抛物线转向指标。""" def test_returns_same_length(self): _, high, low, _, _ = _ohlcv() sar = MyTT.SAR(high, low) assert len(sar) == len(high) def test_first_value_is_low(self): # 默认假设上涨趋势,SAR 起点取首根低点 _, high, low, _, _ = _ohlcv() sar = MyTT.SAR(high, low) assert sar[0] == pytest.approx(low[0]) def test_empty_input(self): sar = MyTT.SAR(np.array([]), np.array([])) assert len(sar) == 0 def test_flat_market_no_crash(self): # 一字板/停牌:高低价完全相同,不应崩溃或产生 inf flat = np.full(50, 10.0) sar = MyTT.SAR(flat, flat) assert len(sar) == 50 assert np.isfinite(sar[1:]).all(), "SAR 不应产生 inf/nan(首值外)" def test_rising_market_sar_below_price(self): # 持续上涨时 SAR 应在价格下方(上涨止损位) high = np.arange(50, dtype=float) + 1 low = np.arange(50, dtype=float) sar = MyTT.SAR(high, low) # 前 5 根建立趋势后,SAR 应低于对应低点 assert (sar[5:] <= low[5:] + 1e-6).all() def test_falling_market_sar_above_price(self): # 持续下跌时 SAR 应在价格上方(下跌止损位) low = np.array([100 - i for i in range(50)], dtype=float) high = low + 1 sar = MyTT.SAR(high, low) # 确认在某处发生反转(趋势从上涨初判切换) # 不强求全程在上方(初判是上涨),但尾部下跌段 SAR 应高于 low assert sar[-1] > low[-1] def test_reversal_resets_af(self): # 反转时加速因子应回到 AF_STEP(无法直接观测,间接验证:反转后第一步 SAR 等于前极值点) # 构造 V 型反转:先涨后跌 rise_h = np.arange(25, dtype=float) + 1 fall_h = np.array([25 - i + 1 for i in range(1, 25)]) high = np.concatenate([rise_h, fall_h]) rise_l = np.arange(25, dtype=float) fall_l = np.array([25 - i for i in range(1, 25)]) low = np.concatenate([rise_l, fall_l]) sar = MyTT.SAR(high, low) assert np.isfinite(sar).all() def test_acceleration_factor_capped(self): # 长期单边上涨,AF 不应超过 AF_MAX(通过 SAR 增量间接验证不发散) high = np.cumsum(np.ones(100)) + 1 # 每根 +1 low = np.cumsum(np.ones(100)) sar = MyTT.SAR(high, low, AF_STEP=0.02, AF_MAX=0.2) assert np.isfinite(sar).all() # SAR 全程应在 low 之下(持续上涨不反转) valid = sar[2:] assert (valid <= low[2:] + 1e-6).all() class TestVWAP: """VWAP 成交量加权均价。""" def test_returns_same_length(self): _, high, low, close, vol = _ohlcv() vwap = MyTT.VWAP(close, high, low, vol, N=20) assert len(vwap) == len(close) def test_leading_nan(self): # 前 N-1 根应为 nan(rolling 窗口未填满) _, high, low, close, vol = _ohlcv() vwap = MyTT.VWAP(close, high, low, vol, N=20) assert np.isnan(vwap[:19]).all() assert not np.isnan(vwap[19]) def test_constant_price(self): # 价格、量都恒定时,VWAP 应等于典型价格 n = 50 close = np.full(n, 10.0) high = np.full(n, 11.0) low = np.full(n, 9.0) vol = np.full(n, 1000.0) vwap = MyTT.VWAP(close, high, low, vol, N=20) expected_tp = (11 + 9 + 10) / 3.0 # =10.0 assert np.allclose(vwap[19:], expected_tp, equal_nan=True) def test_uniform_volume_equals_typical_price_mean(self): # 等量时 VWAP = 典型价格的 N 日均值 n = 100 rng = np.random.default_rng(1) close = 100 + rng.standard_normal(n) high = close + 1 low = close - 1 vol = np.full(n, 500.0) tp = (high + low + close) / 3.0 vwap = MyTT.VWAP(close, high, low, vol, N=10) tp_ma = pd.Series(tp).rolling(10).mean().values assert np.allclose(vwap, tp_ma, equal_nan=True) def test_zero_volume_returns_nan(self): # 全零成交量时,VWAP 应为 nan(除零保护) n = 30 close = np.full(n, 10.0) high = np.full(n, 11.0) low = np.full(n, 9.0) vol = np.zeros(n) vwap = MyTT.VWAP(close, high, low, vol, N=20) assert np.isnan(vwap[19:]).all() class TestAROON: """Aroon 阿隆指标。""" def test_returns_three_arrays(self): _, high, low, _, _ = _ohlcv() up, down, osc = MyTT.AROON(high, low, N=25) assert len(up) == len(high) assert len(down) == len(high) assert len(osc) == len(high) def test_range_zero_to_hundred(self): # AROON_UP/DOWN 应在 [0, 100] 区间 _, high, low, _, _ = _ohlcv() up, down, _ = MyTT.AROON(high, low, N=25) # 跳过 rolling 窗口前的 nan valid_up = up[24:] valid_down = down[24:] assert (valid_up >= 0).all() and (valid_up <= 100).all() assert (valid_down >= 0).all() and (valid_down <= 100).all() def test_new_high_gives_full_up(self): # 在窗口末端创新高时,AROON_UP 应 = 100 n = 50 high = np.linspace(1, 30, n) # 单调上升,末根创新高 low = high - 0.5 up, down, _ = MyTT.AROON(high, low, N=25) assert up[-1] == pytest.approx(100.0) def test_new_low_gives_full_down(self): # 在窗口末端创新低时,AROON_DOWN 应 = 100 n = 50 low = np.linspace(30, 1, n) # 单调下降 high = low + 0.5 _, down, _ = MyTT.AROON(high, low, N=25) assert down[-1] == pytest.approx(100.0) def test_osc_is_difference(self): # OSC = UP - DOWN _, high, low, _, _ = _ohlcv() up, down, osc = MyTT.AROON(high, low, N=25) assert np.allclose(osc[24:], (up - down)[24:], equal_nan=True) def test_leading_nan(self): _, high, low, _, _ = _ohlcv() up, down, _ = MyTT.AROON(high, low, N=25) # HHVBARS/LLVBARS 在 N-1 根前为 nan assert np.isnan(up[:24]).all() class TestFK: """FK 趋势指标(布尔输出)。 慢线用 SLOPE(CLOSE,21)*20 做斜率外推:上涨时慢线被正斜率推高, 下跌时被负斜率压低。FK = fast(EMA2) > slow(外推 EMA42), 语义是"价格是否突破趋势外推线",本质是动量/反转偏离检测: - 强下跌时 fast 相对外推慢线偏高 → FK=True(超卖/反弹信号) - 强上涨时慢线被推高,fast 难以超越 → FK=False(未超买或接近超买) """ def test_returns_boolean_array(self): close = _ohlcv()[3] fk = MyTT.FK(close) assert len(fk) == len(close) assert fk.dtype == bool def test_rising_market_returns_false(self): # 强上涨:正斜率外推把慢线推高,fast < slow → FK=False close = np.cumsum(np.ones(100)) # 每根 +1 fk = MyTT.FK(close) assert bool(fk[-1]) is False def test_falling_market_returns_true(self): # 强下跌:负斜率外推把慢线压低,fast > slow → FK=True 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]