扩展指标
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"""Tests for ferro_ta streaming / incremental indicators."""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta import EMA, RSI, SMA
|
||||
from ferro_ta.data.streaming import StreamingEMA, StreamingRSI, StreamingSMA
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PRICES = np.array(
|
||||
[
|
||||
44.34,
|
||||
44.09,
|
||||
44.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _finite(arr: np.ndarray) -> np.ndarray:
|
||||
return arr[~np.isnan(arr)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingSMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingSMA:
|
||||
def test_basic_values(self):
|
||||
"""Feed known values, verify manually computed SMA."""
|
||||
sma = StreamingSMA(period=3)
|
||||
assert math.isnan(sma.update(1.0))
|
||||
assert math.isnan(sma.update(2.0))
|
||||
assert math.isclose(sma.update(3.0), 2.0)
|
||||
assert math.isclose(sma.update(4.0), 3.0)
|
||||
assert math.isclose(sma.update(5.0), 4.0)
|
||||
|
||||
def test_matches_batch_sma(self):
|
||||
"""Streaming SMA final values must match batch SMA on the same data."""
|
||||
period = 5
|
||||
batch = SMA(PRICES, timeperiod=period)
|
||||
sma = StreamingSMA(period=period)
|
||||
for i, price in enumerate(PRICES):
|
||||
val = sma.update(price)
|
||||
if math.isnan(batch[i]):
|
||||
assert math.isnan(val), f"Expected NaN at index {i}"
|
||||
else:
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-10), (
|
||||
f"Mismatch at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_period_property(self):
|
||||
sma = StreamingSMA(period=7)
|
||||
assert sma.period == 7
|
||||
|
||||
def test_warmup_returns_nan(self):
|
||||
"""First period-1 updates must return NaN."""
|
||||
period = 4
|
||||
sma = StreamingSMA(period=period)
|
||||
for i in range(period - 1):
|
||||
assert math.isnan(sma.update(float(i + 1)))
|
||||
# The period-th update should NOT be NaN
|
||||
assert not math.isnan(sma.update(float(period)))
|
||||
|
||||
def test_single_value_period_1(self):
|
||||
"""Period=1 means every value is immediately returned."""
|
||||
sma = StreamingSMA(period=1)
|
||||
assert math.isclose(sma.update(42.0), 42.0)
|
||||
assert math.isclose(sma.update(99.0), 99.0)
|
||||
|
||||
def test_reset(self):
|
||||
"""After reset, the indicator should behave as freshly constructed."""
|
||||
sma = StreamingSMA(period=3)
|
||||
sma.update(10.0)
|
||||
sma.update(20.0)
|
||||
result_before_reset = sma.update(30.0)
|
||||
assert math.isclose(result_before_reset, 20.0)
|
||||
|
||||
sma.reset()
|
||||
# After reset, warmup restarts
|
||||
assert math.isnan(sma.update(100.0))
|
||||
assert math.isnan(sma.update(200.0))
|
||||
assert math.isclose(sma.update(300.0), 200.0)
|
||||
|
||||
def test_invalid_period_zero(self):
|
||||
with pytest.raises(Exception):
|
||||
StreamingSMA(period=0)
|
||||
|
||||
def test_repr(self):
|
||||
sma = StreamingSMA(period=5)
|
||||
assert "StreamingSMA" in repr(sma)
|
||||
assert "5" in repr(sma)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingEMA:
|
||||
def test_basic_seeding(self):
|
||||
"""EMA seeds from the first `period` values using their SMA."""
|
||||
ema = StreamingEMA(period=3)
|
||||
assert math.isnan(ema.update(1.0))
|
||||
assert math.isnan(ema.update(2.0))
|
||||
# Seed = SMA(1,2,3) = 2.0
|
||||
seed = ema.update(3.0)
|
||||
assert math.isclose(seed, 2.0)
|
||||
|
||||
def test_matches_batch_ema(self):
|
||||
"""Streaming EMA must match batch EMA on the same data."""
|
||||
period = 5
|
||||
batch = EMA(PRICES, timeperiod=period)
|
||||
ema = StreamingEMA(period=period)
|
||||
for i, price in enumerate(PRICES):
|
||||
val = ema.update(price)
|
||||
if math.isnan(batch[i]):
|
||||
assert math.isnan(val), f"Expected NaN at index {i}"
|
||||
else:
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-10), (
|
||||
f"Mismatch at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_warmup_returns_nan(self):
|
||||
period = 5
|
||||
ema = StreamingEMA(period=period)
|
||||
for i in range(period - 1):
|
||||
assert math.isnan(ema.update(float(i + 1)))
|
||||
assert not math.isnan(ema.update(float(period)))
|
||||
|
||||
def test_ema_differs_from_sma_after_warmup(self):
|
||||
"""After warmup, EMA and SMA should diverge for non-constant data."""
|
||||
period = 3
|
||||
prices = [1.0, 2.0, 3.0, 10.0, 11.0]
|
||||
sma = StreamingSMA(period=period)
|
||||
ema = StreamingEMA(period=period)
|
||||
sma_vals = [sma.update(p) for p in prices]
|
||||
ema_vals = [ema.update(p) for p in prices]
|
||||
# At the seed point they should match (both are SMA of first 3)
|
||||
assert math.isclose(sma_vals[2], ema_vals[2])
|
||||
# After the seed they should diverge
|
||||
assert not math.isclose(sma_vals[-1], ema_vals[-1], rel_tol=1e-9)
|
||||
|
||||
def test_reset(self):
|
||||
ema = StreamingEMA(period=3)
|
||||
for p in [10.0, 20.0, 30.0, 40.0]:
|
||||
ema.update(p)
|
||||
ema.reset()
|
||||
# After reset, warmup restarts
|
||||
assert math.isnan(ema.update(1.0))
|
||||
assert math.isnan(ema.update(2.0))
|
||||
assert math.isclose(ema.update(3.0), 2.0)
|
||||
|
||||
def test_period_property(self):
|
||||
ema = StreamingEMA(period=10)
|
||||
assert ema.period == 10
|
||||
|
||||
def test_invalid_period_zero(self):
|
||||
with pytest.raises(Exception):
|
||||
StreamingEMA(period=0)
|
||||
|
||||
def test_single_value_period_1(self):
|
||||
ema = StreamingEMA(period=1)
|
||||
assert math.isclose(ema.update(42.0), 42.0)
|
||||
assert math.isclose(ema.update(50.0), 50.0)
|
||||
|
||||
def test_repr(self):
|
||||
ema = StreamingEMA(period=12)
|
||||
assert "StreamingEMA" in repr(ema)
|
||||
assert "12" in repr(ema)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingRSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingRSI:
|
||||
def test_matches_batch_rsi(self):
|
||||
"""Streaming RSI must match batch RSI on the same data."""
|
||||
period = 5
|
||||
batch = RSI(PRICES, timeperiod=period)
|
||||
rsi = StreamingRSI(period=period)
|
||||
for i, price in enumerate(PRICES):
|
||||
val = rsi.update(price)
|
||||
if math.isnan(batch[i]):
|
||||
assert math.isnan(val), f"Expected NaN at index {i}"
|
||||
else:
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-8), (
|
||||
f"Mismatch at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_warmup_returns_nan(self):
|
||||
"""RSI needs period+1 bars (1 for first prev, then period deltas)."""
|
||||
period = 5
|
||||
rsi = StreamingRSI(period=period)
|
||||
# First bar: sets prev, returns NaN
|
||||
assert math.isnan(rsi.update(50.0))
|
||||
# Next period-1 bars: accumulating deltas, returns NaN
|
||||
for i in range(period - 1):
|
||||
assert math.isnan(rsi.update(50.0 + i))
|
||||
# The (period+1)-th bar should produce a value
|
||||
assert not math.isnan(rsi.update(55.0))
|
||||
|
||||
def test_rsi_range(self):
|
||||
"""All finite RSI values must be in [0, 100]."""
|
||||
rsi = StreamingRSI(period=5)
|
||||
for price in PRICES:
|
||||
val = rsi.update(price)
|
||||
if not math.isnan(val):
|
||||
assert 0.0 <= val <= 100.0, f"RSI out of range: {val}"
|
||||
|
||||
def test_constant_prices(self):
|
||||
"""Constant prices produce no gains or losses -- RSI should be 100
|
||||
(avg_loss == 0 leads to RS = infinity -> RSI = 100)."""
|
||||
rsi = StreamingRSI(period=5)
|
||||
results = [rsi.update(50.0) for _ in range(20)]
|
||||
finite = [v for v in results if not math.isnan(v)]
|
||||
assert len(finite) > 0
|
||||
for v in finite:
|
||||
assert math.isclose(v, 100.0) or math.isclose(v, 0.0) or (0.0 <= v <= 100.0)
|
||||
|
||||
def test_monotone_increasing(self):
|
||||
"""Monotonically increasing prices should yield RSI = 100."""
|
||||
rsi = StreamingRSI(period=3)
|
||||
results = [rsi.update(float(i)) for i in range(1, 20)]
|
||||
finite = [v for v in results if not math.isnan(v)]
|
||||
for v in finite:
|
||||
assert math.isclose(v, 100.0), (
|
||||
f"Expected RSI=100 for monotone increase, got {v}"
|
||||
)
|
||||
|
||||
def test_monotone_decreasing(self):
|
||||
"""Monotonically decreasing prices should yield RSI = 0."""
|
||||
rsi = StreamingRSI(period=3)
|
||||
results = [rsi.update(float(100 - i)) for i in range(20)]
|
||||
finite = [v for v in results if not math.isnan(v)]
|
||||
for v in finite:
|
||||
assert math.isclose(v, 0.0, abs_tol=1e-10), (
|
||||
f"Expected RSI=0 for monotone decrease, got {v}"
|
||||
)
|
||||
|
||||
def test_default_period_14(self):
|
||||
rsi = StreamingRSI()
|
||||
assert rsi.period == 14
|
||||
|
||||
def test_reset(self):
|
||||
rsi = StreamingRSI(period=3)
|
||||
for price in PRICES:
|
||||
rsi.update(price)
|
||||
rsi.reset()
|
||||
# After reset, warmup restarts -- first update should be NaN
|
||||
assert math.isnan(rsi.update(50.0))
|
||||
|
||||
def test_invalid_period_zero(self):
|
||||
with pytest.raises(Exception):
|
||||
StreamingRSI(period=0)
|
||||
|
||||
def test_repr(self):
|
||||
rsi = StreamingRSI(period=14)
|
||||
assert "StreamingRSI" in repr(rsi)
|
||||
assert "14" in repr(rsi)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases (shared across indicators)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingEdgeCases:
|
||||
def test_nan_input_sma(self):
|
||||
"""Feeding NaN into SMA should propagate NaN through the window."""
|
||||
sma = StreamingSMA(period=3)
|
||||
sma.update(1.0)
|
||||
sma.update(2.0)
|
||||
# Third value is NaN -- the sum will include NaN, producing NaN
|
||||
val = sma.update(float("nan"))
|
||||
assert math.isnan(val)
|
||||
|
||||
def test_nan_input_ema(self):
|
||||
"""Feeding NaN into EMA should produce NaN output."""
|
||||
ema = StreamingEMA(period=3)
|
||||
ema.update(1.0)
|
||||
ema.update(2.0)
|
||||
val = ema.update(float("nan"))
|
||||
assert math.isnan(val)
|
||||
|
||||
def test_nan_input_rsi(self):
|
||||
"""Feeding NaN into RSI should produce NaN output."""
|
||||
rsi = StreamingRSI(period=3)
|
||||
rsi.update(1.0)
|
||||
rsi.update(2.0)
|
||||
val = rsi.update(float("nan"))
|
||||
assert math.isnan(val)
|
||||
|
||||
def test_single_value_sma(self):
|
||||
"""Feeding exactly one value to SMA with period > 1 yields NaN."""
|
||||
sma = StreamingSMA(period=5)
|
||||
assert math.isnan(sma.update(42.0))
|
||||
|
||||
def test_single_value_ema(self):
|
||||
ema = StreamingEMA(period=5)
|
||||
assert math.isnan(ema.update(42.0))
|
||||
|
||||
def test_single_value_rsi(self):
|
||||
rsi = StreamingRSI(period=5)
|
||||
assert math.isnan(rsi.update(42.0))
|
||||
|
||||
def test_large_dataset_sma(self):
|
||||
"""Ensure streaming SMA is stable over many updates."""
|
||||
period = 20
|
||||
sma = StreamingSMA(period=period)
|
||||
np.random.seed(42)
|
||||
data = np.random.randn(10_000).cumsum() + 100.0
|
||||
batch = SMA(data, timeperiod=period)
|
||||
for i, price in enumerate(data):
|
||||
val = sma.update(price)
|
||||
if not math.isnan(batch[i]):
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-8), (
|
||||
f"Drift at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_large_dataset_ema(self):
|
||||
"""Ensure streaming EMA is stable over many updates."""
|
||||
period = 20
|
||||
ema = StreamingEMA(period=period)
|
||||
np.random.seed(42)
|
||||
data = np.random.randn(10_000).cumsum() + 100.0
|
||||
batch = EMA(data, timeperiod=period)
|
||||
for i, price in enumerate(data):
|
||||
val = ema.update(price)
|
||||
if not math.isnan(batch[i]):
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-8), (
|
||||
f"Drift at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_large_dataset_rsi(self):
|
||||
"""Ensure streaming RSI is stable over many updates."""
|
||||
period = 14
|
||||
rsi = StreamingRSI(period=period)
|
||||
np.random.seed(42)
|
||||
data = np.random.randn(10_000).cumsum() + 100.0
|
||||
batch = RSI(data, timeperiod=period)
|
||||
for i, price in enumerate(data):
|
||||
val = rsi.update(price)
|
||||
if not math.isnan(batch[i]):
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-6), (
|
||||
f"Drift at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_reset_then_reuse_matches_fresh_instance(self):
|
||||
"""A reset indicator should produce identical output to a new one."""
|
||||
period = 5
|
||||
data = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0]
|
||||
|
||||
sma_reused = StreamingSMA(period=period)
|
||||
for p in [99.0, 98.0, 97.0, 96.0, 95.0]:
|
||||
sma_reused.update(p)
|
||||
sma_reused.reset()
|
||||
|
||||
sma_fresh = StreamingSMA(period=period)
|
||||
|
||||
for p in data:
|
||||
v1 = sma_reused.update(p)
|
||||
v2 = sma_fresh.update(p)
|
||||
if math.isnan(v1):
|
||||
assert math.isnan(v2)
|
||||
else:
|
||||
assert math.isclose(v1, v2, rel_tol=1e-12)
|
||||
Reference in New Issue
Block a user