Files
2026-07-09 05:08:16 +08:00

126 lines
4.3 KiB
Python

"""Unit tests for ferro_ta.indicators.volatility"""
import numpy as np
from ferro_ta.indicators.volatility import ATR, NATR, TRANGE
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
RNG = np.random.default_rng(3)
N = 100
_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N))
_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N))
_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N))
# Simple 5-bar data with constant range
SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
# ---------------------------------------------------------------------------
# TRANGE
# ---------------------------------------------------------------------------
class TestTRANGE:
def test_known_values_constant_range(self):
result = TRANGE(SMALL_H, SMALL_L, SMALL_C)
# First bar: only high-low = 3 (no prior close)
np.testing.assert_allclose(result[0], 3.0, rtol=1e-10)
np.testing.assert_allclose(result[1], 3.0, rtol=1e-10)
def test_no_nan(self):
result = TRANGE(SMALL_H, SMALL_L, SMALL_C)
assert np.all(np.isfinite(result))
def test_always_positive(self):
result = TRANGE(_HIGH, _LOW, _CLOSE)
assert np.all(result > 0)
def test_length(self):
assert len(TRANGE(_HIGH, _LOW, _CLOSE)) == N
def test_formula_first_bar(self):
h = np.array([15.0, 16.0, 17.0])
l = np.array([10.0, 11.0, 12.0])
c = np.array([13.0, 14.0, 15.0])
result = TRANGE(h, l, c)
# bar 0: TRANGE = h[0] - l[0] = 5
np.testing.assert_allclose(result[0], 5.0, rtol=1e-10)
# bar 1: max(h[1]-l[1], |h[1]-c[0]|, |l[1]-c[0]|)
# = max(5, |16-13|, |11-13|) = max(5, 3, 2) = 5
np.testing.assert_allclose(result[1], 5.0, rtol=1e-10)
def test_with_gap(self):
# Gap up: prev close=10, curr high=20, curr low=15
h = np.array([10.0, 20.0])
l = np.array([8.0, 15.0])
c = np.array([10.0, 18.0])
result = TRANGE(h, l, c)
# bar 1: max(20-15, |20-10|, |15-10|) = max(5, 10, 5) = 10
np.testing.assert_allclose(result[1], 10.0, rtol=1e-10)
# ---------------------------------------------------------------------------
# ATR
# ---------------------------------------------------------------------------
class TestATR:
def test_timeperiod_1_equals_trange(self):
atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1)
trange = TRANGE(SMALL_H, SMALL_L, SMALL_C)
# ATR(1) first bar is NaN, subsequent equal TRANGE
np.testing.assert_allclose(atr[1:], trange[1:], rtol=1e-10)
def test_nan_warmup(self):
result = ATR(_HIGH, _LOW, _CLOSE, timeperiod=14)
assert np.all(np.isnan(result[:14]))
def test_length(self):
assert len(ATR(_HIGH, _LOW, _CLOSE, 14)) == N
def test_always_positive(self):
result = ATR(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid > 0)
def test_constant_range_converges(self):
# Constant TRANGE=3 → ATR should converge to 3
h = np.full(100, 12.0) + np.arange(100) * 0.0
l = np.full(100, 9.0) + np.arange(100) * 0.0
c = np.full(100, 11.0) + np.arange(100) * 0.0
result = ATR(h, l, c, timeperiod=5)
valid = result[~np.isnan(result)]
np.testing.assert_allclose(valid[-1], 3.0, atol=0.01)
# ---------------------------------------------------------------------------
# NATR
# ---------------------------------------------------------------------------
class TestNATR:
def test_nan_warmup(self):
result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14)
assert np.all(np.isnan(result[:14]))
def test_length(self):
assert len(NATR(_HIGH, _LOW, _CLOSE, 14)) == N
def test_positive(self):
result = NATR(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid > 0)
def test_relation_to_atr(self):
# NATR = ATR / close * 100
atr = ATR(_HIGH, _LOW, _CLOSE, 14)
natr = NATR(_HIGH, _LOW, _CLOSE, 14)
valid = ~np.isnan(atr) & ~np.isnan(natr)
expected = atr[valid] / _CLOSE[valid] * 100
np.testing.assert_allclose(natr[valid], expected, rtol=1e-5)