mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-27 17:27:43 +00:00
feat(python): add pure-Python EMA fallback per PYTHON_FALLBACK_SPEC
Single-function API: ema(source, period=10, *, alpha=None) - Bias-compensated EMA matching C# Ema.Batch exactly - NaN/Inf safe with last-valid substitution - 65 lines, pseudocode-readable reference implementation - Co-located ema_test.py (15 tests) + tests/test_ema.py (37 tests) - All 50 tests pass, 2 cross-validation skipped (no native lib)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""Exponential Moving Average — bias-compensated, NaN-safe.
|
||||
|
||||
Algorithm (mirrors Ema.cs):
|
||||
|
||||
alpha = 2 / (period + 1)
|
||||
decay = 1 - alpha
|
||||
|
||||
For each bar:
|
||||
ema = ema * decay + alpha * value
|
||||
E *= decay
|
||||
result = ema / (1 - E) while E > epsilon
|
||||
result = ema after warmup
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
__all__ = ["ema"]
|
||||
|
||||
EPSILON = 1e-10 # bias-compensator cutoff
|
||||
|
||||
|
||||
def ema(source, period: int = 10, *, alpha: float | None = None) -> np.ndarray:
|
||||
"""Bias-compensated EMA.
|
||||
|
||||
Use *period* (default) or explicit *alpha* (keyword-only).
|
||||
If *alpha* is given, *period* is ignored.
|
||||
"""
|
||||
if alpha is not None:
|
||||
if not (0.0 < alpha <= 1.0):
|
||||
raise ValueError(f"alpha must be in (0, 1], got {alpha}")
|
||||
else:
|
||||
if period <= 0:
|
||||
raise ValueError(f"period must be > 0, got {period}")
|
||||
alpha = 2.0 / (period + 1)
|
||||
|
||||
src = np.asarray(source, dtype=np.float64)
|
||||
if src.ndim == 0 or src.size == 0:
|
||||
raise ValueError("source must not be empty")
|
||||
src = src.ravel()
|
||||
|
||||
out = np.empty(len(src), dtype=np.float64)
|
||||
decay = 1.0 - alpha
|
||||
ema_val = 0.0
|
||||
e = 1.0
|
||||
last_valid = 0.0
|
||||
has_valid = False
|
||||
|
||||
for i, v in enumerate(src):
|
||||
if math.isfinite(v):
|
||||
last_valid = v
|
||||
has_valid = True
|
||||
elif has_valid:
|
||||
v = last_valid
|
||||
else:
|
||||
out[i] = math.nan
|
||||
continue
|
||||
|
||||
ema_val = ema_val * decay + alpha * v
|
||||
|
||||
if e > EPSILON:
|
||||
e *= decay
|
||||
out[i] = ema_val / (1.0 - e)
|
||||
else:
|
||||
out[i] = ema_val
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Unit tests for pure-Python EMA implementation.
|
||||
|
||||
Per PYTHON_FALLBACK_SPEC §5: co-located <indicator>_test.py
|
||||
|
||||
Run::
|
||||
|
||||
pytest lib/trends_IIR/ema/ema_test.py -v
|
||||
"""
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Import from co-located ema.py
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from ema import ema # noqa: E402
|
||||
|
||||
|
||||
# ── basic correctness ──
|
||||
|
||||
def test_known_values():
|
||||
"""Hand-calculated EMA(3) with bias compensation.
|
||||
|
||||
alpha=0.5, decay=0.5:
|
||||
Bar 0: acc = 0.5*10 = 5, E = 0.5, result = 5/0.5 = 10.0
|
||||
Bar 1: acc = 5*0.5 + 0.5*20 = 12.5, E = 0.25, result = 12.5/0.75 ≈ 16.667
|
||||
Bar 2: acc = 12.5*0.5 + 0.5*30 = 21.25, E = 0.125, result = 21.25/0.875 ≈ 24.286
|
||||
"""
|
||||
data = np.array([10.0, 20.0, 30.0, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
assert result[0] == pytest.approx(10.0, rel=1e-12)
|
||||
assert result[1] == pytest.approx(16.666666666666668, rel=1e-10)
|
||||
assert result[2] == pytest.approx(24.285714285714285, rel=1e-10)
|
||||
|
||||
|
||||
def test_period_1():
|
||||
"""Period=1 → alpha=1.0 → output equals input (no smoothing)."""
|
||||
data = np.array([10.0, 20.0, 30.0, 40.0, 50.0])
|
||||
result = ema(data, period=1)
|
||||
np.testing.assert_array_equal(result, data)
|
||||
|
||||
|
||||
def test_constant_input():
|
||||
"""Constant 100.0 through any EMA → always 100.0."""
|
||||
data = np.full(100, 100.0)
|
||||
result = ema(data, period=20)
|
||||
np.testing.assert_allclose(result, 100.0, rtol=1e-12)
|
||||
|
||||
|
||||
# ── edge cases ──
|
||||
|
||||
def test_empty_array():
|
||||
"""Empty input → raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
ema(np.array([]), period=5)
|
||||
|
||||
|
||||
def test_invalid_period():
|
||||
"""Period ≤ 0 → raises ValueError."""
|
||||
with pytest.raises(ValueError, match="period must be > 0"):
|
||||
ema(np.array([1.0, 2.0, 3.0]), period=0)
|
||||
with pytest.raises(ValueError, match="period must be > 0"):
|
||||
ema(np.array([1.0, 2.0, 3.0]), period=-1)
|
||||
|
||||
|
||||
def test_single_element():
|
||||
"""Single-element input returns that element."""
|
||||
result = ema([42.0], period=10)
|
||||
assert len(result) == 1
|
||||
assert result[0] == pytest.approx(42.0, rel=1e-12)
|
||||
|
||||
|
||||
# ── NaN handling ──
|
||||
|
||||
def test_nan_in_input():
|
||||
"""NaN replaced with last valid value → output stays finite."""
|
||||
data = np.array([10.0, 20.0, np.nan, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isfinite(v) for v in result)
|
||||
|
||||
|
||||
def test_all_nan_returns_nan():
|
||||
"""All-NaN input → all-NaN output."""
|
||||
data = np.full(5, np.nan)
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isnan(v) for v in result)
|
||||
|
||||
|
||||
def test_inf_handled():
|
||||
"""Inf replaced with last valid value."""
|
||||
data = np.array([10.0, 20.0, np.inf, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isfinite(v) for v in result)
|
||||
|
||||
|
||||
# ── output shape & warmup ──
|
||||
|
||||
def test_output_length_matches_input():
|
||||
"""len(output) == len(input)."""
|
||||
data = np.random.default_rng(42).normal(100, 5, size=200)
|
||||
result = ema(data, period=14)
|
||||
assert len(result) == 200
|
||||
|
||||
|
||||
def test_first_bar_always_valid():
|
||||
"""Bias compensation means EMA produces valid output from bar 0."""
|
||||
data = np.random.default_rng(42).normal(100, 5, size=50)
|
||||
result = ema(data, period=50)
|
||||
assert math.isfinite(result[0])
|
||||
|
||||
|
||||
# ── numerical precision ──
|
||||
|
||||
def test_large_values():
|
||||
"""No overflow with 1e300 values."""
|
||||
data = np.full(100, 1e300)
|
||||
result = ema(data, period=10)
|
||||
np.testing.assert_allclose(result, 1e300, rtol=1e-10)
|
||||
|
||||
|
||||
def test_tiny_values():
|
||||
"""No underflow with 1e-300 values."""
|
||||
data = np.full(100, 1e-300)
|
||||
result = ema(data, period=10)
|
||||
np.testing.assert_allclose(result, 1e-300, rtol=1e-10)
|
||||
|
||||
|
||||
def test_10k_series_all_finite():
|
||||
"""Long series stays finite (no drift)."""
|
||||
data = np.random.default_rng(42).normal(100, 10, size=10_000)
|
||||
result = ema(data, period=20)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
|
||||
def test_alpha_from_period_equivalence():
|
||||
"""ema(period=N) == ema(alpha=2/(N+1))."""
|
||||
data = np.random.default_rng(42).normal(100, 5, size=200)
|
||||
r1 = ema(data, period=14)
|
||||
r2 = ema(data, alpha=2.0 / 15.0)
|
||||
np.testing.assert_allclose(r1, r2, rtol=1e-14)
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for the pure-Python EMA implementation.
|
||||
|
||||
Validates that ``ema.py`` produces results matching the C# ``Ema.Batch``
|
||||
algorithm, including bias compensation, NaN handling, and edge cases.
|
||||
|
||||
Run with::
|
||||
|
||||
python -m pytest lib/trends_IIR/ema/tests/test_ema.py -v
|
||||
"""
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# ── ensure the ema module is importable ──────────────────────────────────
|
||||
# The ema.py lives one directory up from tests/
|
||||
_EMA_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_EMA_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_EMA_DIR))
|
||||
|
||||
from ema import ema # noqa: E402
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Fixtures
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.fixture
|
||||
def constant_series() -> np.ndarray:
|
||||
"""100 bars of constant value 50.0."""
|
||||
return np.full(100, 50.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ramp_series() -> np.ndarray:
|
||||
"""50 bars ramping 1..50."""
|
||||
return np.arange(1.0, 51.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_series() -> np.ndarray:
|
||||
"""10 bars: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]."""
|
||||
return np.arange(10.0, 110.0, 10.0)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Input validation
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestInputValidation:
|
||||
"""Guard clauses match C# ArgumentOutOfRangeException behavior."""
|
||||
|
||||
def test_period_zero_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="period must be > 0"):
|
||||
ema([1.0, 2.0], period=0)
|
||||
|
||||
def test_period_negative_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="period must be > 0"):
|
||||
ema([1.0, 2.0], period=-5)
|
||||
|
||||
def test_alpha_zero_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="alpha must be in"):
|
||||
ema([1.0, 2.0], alpha=0.0)
|
||||
|
||||
def test_alpha_negative_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="alpha must be in"):
|
||||
ema([1.0, 2.0], alpha=-0.1)
|
||||
|
||||
def test_alpha_above_one_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="alpha must be in"):
|
||||
ema([1.0, 2.0], alpha=1.01)
|
||||
|
||||
def test_alpha_exactly_one_ok(self) -> None:
|
||||
result = ema([5.0, 10.0, 15.0], alpha=1.0)
|
||||
# alpha=1 means output == input (no smoothing)
|
||||
np.testing.assert_array_equal(result, [5.0, 10.0, 15.0])
|
||||
|
||||
def test_empty_source_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
ema([], period=10)
|
||||
|
||||
def test_scalar_source_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
ema(np.float64(5.0), period=10)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Output shape
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestOutputShape:
|
||||
"""Output array must match input length exactly."""
|
||||
|
||||
def test_length_equals_input(self, ramp_series: np.ndarray) -> None:
|
||||
result = ema(ramp_series, period=10)
|
||||
assert len(result) == len(ramp_series)
|
||||
|
||||
def test_single_element(self) -> None:
|
||||
result = ema([42.0], period=5)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_dtype_float64(self, ramp_series: np.ndarray) -> None:
|
||||
result = ema(ramp_series, period=10)
|
||||
assert result.dtype == np.float64
|
||||
|
||||
def test_accepts_list_input(self) -> None:
|
||||
result = ema([1.0, 2.0, 3.0], period=2)
|
||||
assert len(result) == 3
|
||||
|
||||
def test_accepts_tuple_input(self) -> None:
|
||||
result = ema((1.0, 2.0, 3.0), period=2)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Bias compensation correctness
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestBiasCompensation:
|
||||
"""Verify the warmup compensator E = (1-α)^t produces valid early values."""
|
||||
|
||||
def test_first_bar_equals_input(self) -> None:
|
||||
"""EMA(bar_0) must equal the input itself (compensated to 1×input)."""
|
||||
result = ema([100.0, 200.0, 300.0], period=10)
|
||||
# After compensation: ema_acc = alpha * 100, E = decay
|
||||
# result[0] = (alpha * 100) / (1 - decay) = (alpha * 100) / alpha = 100
|
||||
assert result[0] == pytest.approx(100.0, rel=1e-12)
|
||||
|
||||
def test_constant_series_converges_to_constant(
|
||||
self, constant_series: np.ndarray
|
||||
) -> None:
|
||||
"""On constant input, every bar should equal the constant."""
|
||||
result = ema(constant_series, period=10)
|
||||
np.testing.assert_allclose(result, 50.0, rtol=1e-12)
|
||||
|
||||
def test_compensator_eliminates_zero_bias(self) -> None:
|
||||
"""Without compensation, starting from ema=0 would bias downward.
|
||||
With compensation, bar 1 on a constant 100 series must be 100."""
|
||||
result = ema(np.full(5, 100.0), period=20)
|
||||
# Every value should be exactly 100.0 (constant input)
|
||||
np.testing.assert_allclose(result, 100.0, rtol=1e-12)
|
||||
|
||||
def test_period_1_passthrough(self) -> None:
|
||||
"""Period=1 → alpha=1.0 → output equals input."""
|
||||
data = np.array([10.0, 20.0, 30.0, 40.0, 50.0])
|
||||
result = ema(data, period=1)
|
||||
np.testing.assert_array_equal(result, data)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# EMA mathematical properties
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMathProperties:
|
||||
"""Verify EMA satisfies known mathematical properties."""
|
||||
|
||||
def test_monotone_input_monotone_output(self, ramp_series: np.ndarray) -> None:
|
||||
"""Strictly increasing input → strictly increasing EMA."""
|
||||
result = ema(ramp_series, period=10)
|
||||
diffs = np.diff(result)
|
||||
assert np.all(diffs > 0), "EMA of monotone-increasing input must increase"
|
||||
|
||||
def test_ema_lags_behind_ramp(self, ramp_series: np.ndarray) -> None:
|
||||
"""EMA of increasing ramp must be ≤ the source (lag property)."""
|
||||
result = ema(ramp_series, period=10)
|
||||
# After first bar, EMA should lag behind
|
||||
assert np.all(result[1:] <= ramp_series[1:] + 1e-10)
|
||||
|
||||
def test_ema_between_min_and_max(self, ramp_series: np.ndarray) -> None:
|
||||
"""EMA output must lie within [min(source), max(source)]."""
|
||||
result = ema(ramp_series, period=10)
|
||||
assert np.all(result >= ramp_series.min() - 1e-10)
|
||||
assert np.all(result <= ramp_series.max() + 1e-10)
|
||||
|
||||
def test_alpha_from_period(self) -> None:
|
||||
"""ema(period=N) must equal ema(alpha=2/(N+1))."""
|
||||
data = np.random.default_rng(42).normal(100, 5, size=200)
|
||||
r1 = ema(data, period=14)
|
||||
r2 = ema(data, alpha=2.0 / 15.0)
|
||||
np.testing.assert_allclose(r1, r2, rtol=1e-14)
|
||||
|
||||
def test_larger_period_smoother(self) -> None:
|
||||
"""Larger period → less variance in the output."""
|
||||
data = np.random.default_rng(99).normal(100, 10, size=500)
|
||||
r5 = ema(data, period=5)
|
||||
r50 = ema(data, period=50)
|
||||
# Skip warmup region; use last 300 bars
|
||||
assert np.std(r50[-300:]) < np.std(r5[-300:])
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# NaN / Inf handling
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestNanHandling:
|
||||
"""NaN/Inf inputs are replaced with last valid value (C# GetValidValue)."""
|
||||
|
||||
def test_nan_in_middle(self) -> None:
|
||||
data = np.array([10.0, 20.0, np.nan, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isfinite(v) for v in result)
|
||||
|
||||
def test_inf_in_middle(self) -> None:
|
||||
data = np.array([10.0, 20.0, np.inf, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isfinite(v) for v in result)
|
||||
|
||||
def test_neg_inf_in_middle(self) -> None:
|
||||
data = np.array([10.0, 20.0, -np.inf, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isfinite(v) for v in result)
|
||||
|
||||
def test_nan_at_start_skipped(self) -> None:
|
||||
"""Leading NaNs use last_valid = 0 until a finite value arrives."""
|
||||
data = np.array([np.nan, np.nan, 100.0, 200.0, 300.0])
|
||||
result = ema(data, period=3)
|
||||
# First two bars use last_valid=0 initially, then seed
|
||||
# After 100.0 arrives, behavior normalizes
|
||||
assert math.isfinite(result[2])
|
||||
assert math.isfinite(result[4])
|
||||
|
||||
def test_all_nan_returns_nan(self) -> None:
|
||||
"""If every value is NaN, output must be all NaN."""
|
||||
data = np.full(5, np.nan)
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isnan(v) for v in result)
|
||||
|
||||
def test_all_inf_returns_nan(self) -> None:
|
||||
"""If every value is Inf, no valid seed → all NaN."""
|
||||
data = np.full(5, np.inf)
|
||||
result = ema(data, period=3)
|
||||
assert all(math.isnan(v) for v in result)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Golden values (hand-calculated reference)
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestGoldenValues:
|
||||
"""Verify against hand-calculated EMA with bias compensation.
|
||||
|
||||
For period=3, alpha=0.5, decay=0.5:
|
||||
Bar 0: ema_acc = 0.5*10 = 5, E = 0.5, result = 5/(1-0.5) = 10.0
|
||||
Bar 1: ema_acc = 5*0.5 + 0.5*20 = 12.5, E = 0.25, result = 12.5/0.75 ≈ 16.6667
|
||||
Bar 2: ema_acc = 12.5*0.5 + 0.5*30 = 21.25, E = 0.125, result = 21.25/0.875 ≈ 24.2857
|
||||
"""
|
||||
|
||||
def test_period_3_first_three_bars(self) -> None:
|
||||
data = np.array([10.0, 20.0, 30.0, 40.0, 50.0])
|
||||
result = ema(data, period=3)
|
||||
|
||||
assert result[0] == pytest.approx(10.0, rel=1e-12)
|
||||
assert result[1] == pytest.approx(16.666666666666668, rel=1e-10)
|
||||
assert result[2] == pytest.approx(24.285714285714285, rel=1e-10)
|
||||
|
||||
def test_period_5_constant_100(self) -> None:
|
||||
"""Constant 100 through EMA(5) → all 100.0."""
|
||||
data = np.full(20, 100.0)
|
||||
result = ema(data, period=5)
|
||||
np.testing.assert_allclose(result, 100.0, rtol=1e-12)
|
||||
|
||||
def test_ema_alpha_direct(self) -> None:
|
||||
"""ema(alpha=0.5) on [10,20,30] → same as period=3."""
|
||||
data = np.array([10.0, 20.0, 30.0])
|
||||
r1 = ema(data, period=3)
|
||||
r2 = ema(data, alpha=0.5)
|
||||
np.testing.assert_allclose(r1, r2, rtol=1e-14)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Stability and performance
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestStability:
|
||||
"""Long series shouldn't drift or produce non-finite values."""
|
||||
|
||||
def test_10k_bars_all_finite(self) -> None:
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(100, 10, size=10_000)
|
||||
result = ema(data, period=20)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_large_values_no_overflow(self) -> None:
|
||||
data = np.full(100, 1e300)
|
||||
result = ema(data, period=10)
|
||||
np.testing.assert_allclose(result, 1e300, rtol=1e-10)
|
||||
|
||||
def test_tiny_values_no_underflow(self) -> None:
|
||||
data = np.full(100, 1e-300)
|
||||
result = ema(data, period=10)
|
||||
np.testing.assert_allclose(result, 1e-300, rtol=1e-10)
|
||||
|
||||
def test_alternating_sign(self) -> None:
|
||||
"""Alternating +100 / -100 should converge toward 0 for large period."""
|
||||
data = np.array([100.0, -100.0] * 500)
|
||||
result = ema(data, period=100)
|
||||
# Last few values should be near zero
|
||||
assert abs(result[-1]) < 20.0
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Cross-validation with C# native (when available)
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCrossValidation:
|
||||
"""Compare pure Python EMA against NativeAOT EMA (skipped if unavailable)."""
|
||||
|
||||
@pytest.fixture
|
||||
def native_ema(self):
|
||||
"""Try to import the native EMA wrapper."""
|
||||
try:
|
||||
from quantalib.trends_iir import ema as native_ema_fn
|
||||
return native_ema_fn
|
||||
except (ImportError, OSError):
|
||||
pytest.skip("quantalib native lib not available")
|
||||
|
||||
def test_matches_native_random_data(self, native_ema) -> None:
|
||||
rng = np.random.default_rng(12345)
|
||||
data = rng.normal(100, 10, size=500)
|
||||
py_result = ema(data, period=14)
|
||||
native_result = native_ema(data, period=14)
|
||||
|
||||
# Convert native result to numpy if needed
|
||||
native_arr = np.asarray(native_result, dtype=np.float64)
|
||||
np.testing.assert_allclose(py_result, native_arr, rtol=1e-10,
|
||||
err_msg="Python EMA diverges from native")
|
||||
|
||||
def test_matches_native_with_nans(self, native_ema) -> None:
|
||||
data = np.array([10.0, np.nan, 30.0, 40.0, np.nan, 60.0, 70.0])
|
||||
py_result = ema(data, period=3)
|
||||
native_result = native_ema(data, period=3)
|
||||
native_arr = np.asarray(native_result, dtype=np.float64)
|
||||
np.testing.assert_allclose(py_result, native_arr, rtol=1e-10,
|
||||
err_msg="NaN handling diverges from native")
|
||||
Reference in New Issue
Block a user