Files
ferro-ta/tests/unit/test_infrastructure.py
Pratik Bhadane 436954138f chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
2026-03-30 12:45:52 +05:30

1061 lines
34 KiB
Python

"""Tests for exceptions, backtest, registry, release playbook, GPU backend, WASM."""
from __future__ import annotations
import numpy as np
import pytest
import ferro_ta
# ---------------------------------------------------------------------------
# Exception model & validation
# ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import (
FerroTAError,
FerroTAInputError,
FerroTAValueError,
check_equal_length,
check_finite,
check_timeperiod,
)
class TestExceptionHierarchy:
"""FerroTAError hierarchy and isinstance relationships."""
def test_ferro_ta_error_is_exception(self):
assert issubclass(FerroTAError, Exception)
def test_value_error_is_base_and_value_error(self):
assert issubclass(FerroTAValueError, FerroTAError)
assert issubclass(FerroTAValueError, ValueError)
def test_input_error_is_base_and_value_error(self):
assert issubclass(FerroTAInputError, FerroTAError)
assert issubclass(FerroTAInputError, ValueError)
def test_exported_from_ferro_ta(self):
assert ferro_ta.FerroTAError is FerroTAError
assert ferro_ta.FerroTAValueError is FerroTAValueError
assert ferro_ta.FerroTAInputError is FerroTAInputError
class TestCheckTimeperiod:
"""check_timeperiod raises FerroTAValueError with clear message."""
def test_valid_timeperiod_does_not_raise(self):
check_timeperiod(1)
check_timeperiod(14)
check_timeperiod(100)
def test_zero_raises_ferro_ta_value_error(self):
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1, got 0"):
check_timeperiod(0)
def test_negative_raises_ferro_ta_value_error(self):
with pytest.raises(FerroTAValueError) as exc_info:
check_timeperiod(-5, name="timeperiod")
assert "timeperiod" in str(exc_info.value)
assert "-5" in str(exc_info.value)
def test_custom_name_in_message(self):
with pytest.raises(FerroTAValueError, match="fastperiod"):
check_timeperiod(0, name="fastperiod")
def test_custom_minimum(self):
with pytest.raises(FerroTAValueError, match=">= 2"):
check_timeperiod(1, minimum=2)
class TestCheckEqualLength:
"""check_equal_length raises FerroTAInputError for mismatched arrays."""
def test_equal_lengths_pass(self):
a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])
check_equal_length(open=a, close=b) # no exception
def test_mismatched_lengths_raise(self):
a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0])
with pytest.raises(FerroTAInputError) as exc_info:
check_equal_length(open=a, close=b)
# message must mention the lengths
msg = str(exc_info.value)
assert "3" in msg
assert "2" in msg
def test_three_arrays_all_different(self):
with pytest.raises(FerroTAInputError):
check_equal_length(
open=np.array([1.0]),
high=np.array([1.0, 2.0]),
close=np.array([1.0, 2.0, 3.0]),
)
class TestCheckFinite:
"""check_finite raises FerroTAInputError for NaN/Inf."""
def test_all_finite_passes(self):
check_finite(np.array([1.0, 2.0, 3.0]))
def test_nan_raises(self):
with pytest.raises(FerroTAInputError, match="NaN or Inf"):
check_finite(np.array([1.0, float("nan"), 3.0]))
def test_inf_raises(self):
with pytest.raises(FerroTAInputError, match="NaN or Inf"):
check_finite(np.array([1.0, float("inf"), 3.0]))
def test_name_in_message(self):
with pytest.raises(FerroTAInputError, match="myarray"):
check_finite(np.array([float("nan")]), name="myarray")
# ---------------------------------------------------------------------------
# Backtesting utilities
# ---------------------------------------------------------------------------
from ferro_ta.analysis.backtest import (
BacktestResult,
backtest,
macd_crossover_strategy,
rsi_strategy,
sma_crossover_strategy,
)
def _make_close(n: int = 50, seed: int = 42) -> np.ndarray:
rng = np.random.default_rng(seed)
returns = rng.normal(0.001, 0.01, n)
return np.cumprod(1 + returns) * 100.0
class TestRsiStrategy:
"""rsi_strategy returns correct signal arrays."""
def test_output_shape(self):
close = _make_close(50)
signals = rsi_strategy(close, timeperiod=5)
assert signals.shape == close.shape
def test_only_valid_signal_values(self):
close = _make_close(50)
signals = rsi_strategy(close, timeperiod=5)
finite = signals[np.isfinite(signals)]
assert set(finite).issubset({-1.0, 0.0, 1.0})
def test_nan_during_warmup(self):
close = _make_close(20)
signals = rsi_strategy(close, timeperiod=5)
# First 5 values should be NaN (RSI warm-up)
assert np.all(np.isnan(signals[:5]))
def test_invalid_timeperiod(self):
with pytest.raises(FerroTAValueError):
rsi_strategy(_make_close(10), timeperiod=0)
class TestSmaCrossoverStrategy:
"""sma_crossover_strategy returns signals when fast < slow."""
def test_output_shape(self):
close = _make_close(60)
signals = sma_crossover_strategy(close, fast=5, slow=20)
assert signals.shape == close.shape
def test_only_valid_signal_values(self):
close = _make_close(60)
signals = sma_crossover_strategy(close, fast=5, slow=20)
finite = signals[np.isfinite(signals)]
assert set(finite).issubset({-1.0, 1.0})
def test_fast_must_be_less_than_slow(self):
with pytest.raises(FerroTAValueError):
sma_crossover_strategy(_make_close(60), fast=20, slow=10)
class TestMacdCrossoverStrategy:
"""macd_crossover_strategy returns signals from MACD line vs signal line."""
def test_output_shape(self):
close = _make_close(100)
signals = macd_crossover_strategy(
close, fastperiod=12, slowperiod=26, signalperiod=9
)
assert signals.shape == close.shape
def test_only_valid_signal_values(self):
close = _make_close(100)
signals = macd_crossover_strategy(
close, fastperiod=12, slowperiod=26, signalperiod=9
)
finite = signals[np.isfinite(signals)]
assert set(finite).issubset({-1.0, 1.0})
def test_fastperiod_must_be_less_than_slowperiod(self):
with pytest.raises(FerroTAValueError):
macd_crossover_strategy(_make_close(60), fastperiod=26, slowperiod=12)
class TestBacktest:
"""backtest() produces correct BacktestResult."""
def test_rsi_strategy_runs(self):
close = _make_close(100)
result = backtest(close, strategy="rsi_30_70", timeperiod=5)
assert isinstance(result, BacktestResult)
def test_output_lengths_match_input(self):
close = _make_close(80)
result = backtest(close, strategy="rsi_30_70", timeperiod=5)
n = len(close)
assert len(result.signals) == n
assert len(result.positions) == n
assert len(result.equity) == n
def test_equity_starts_near_one(self):
close = _make_close(50)
result = backtest(close, strategy="rsi_30_70", timeperiod=5)
assert abs(result.equity[0] - 1.0) < 0.01
def test_sma_crossover_strategy_runs(self):
close = _make_close(80)
result = backtest(close, strategy="sma_crossover", fast=5, slow=20)
assert isinstance(result, BacktestResult)
assert result.n_trades >= 0
def test_custom_callable_strategy(self):
def my_strategy(close, **_):
signals = np.zeros(len(close))
signals[len(close) // 2 :] = 1.0
return signals
close = _make_close(40)
result = backtest(close, strategy=my_strategy)
assert isinstance(result, BacktestResult)
assert len(result.signals) == len(close)
def test_unknown_strategy_raises(self):
with pytest.raises(FerroTAValueError, match="Unknown strategy"):
backtest(_make_close(30), strategy="nonexistent")
def test_too_short_input_raises(self):
with pytest.raises(FerroTAInputError):
backtest(np.array([1.0]))
def test_non_1d_input_raises(self):
with pytest.raises(FerroTAInputError):
backtest(np.array([[1.0, 2.0], [3.0, 4.0]]))
def test_n_trades_is_integer(self):
close = _make_close(60)
result = backtest(close, strategy="sma_crossover", fast=5, slow=15)
assert isinstance(result.n_trades, int)
assert result.n_trades >= 0
def test_macd_crossover_strategy_runs(self):
close = _make_close(100)
result = backtest(
close,
strategy="macd_crossover",
fastperiod=12,
slowperiod=26,
signalperiod=9,
)
assert isinstance(result, BacktestResult)
assert len(result.equity) == len(close)
def test_commission_reduces_equity(self):
close = _make_close(80)
result_no_comm = backtest(close, strategy="sma_crossover", fast=5, slow=20)
result_with_comm = backtest(
close,
strategy="sma_crossover",
fast=5,
slow=20,
commission_per_trade=0.01,
)
assert result_with_comm.final_equity <= result_no_comm.final_equity
assert result_with_comm.final_equity < result_no_comm.final_equity or (
result_no_comm.n_trades == 0
)
def test_slippage_reduces_equity(self):
close = _make_close(80)
result_no_slip = backtest(close, strategy="sma_crossover", fast=5, slow=20)
result_with_slip = backtest(
close,
strategy="sma_crossover",
fast=5,
slow=20,
slippage_bps=10.0,
)
assert result_with_slip.final_equity <= result_no_slip.final_equity
assert result_with_slip.final_equity < result_no_slip.final_equity or (
result_no_slip.n_trades == 0
)
def test_commission_matches_reference_loop(self):
from ferro_ta._ferro_ta import CommissionModel
from ferro_ta.analysis.backtest import BacktestEngine
close = np.array([100.0, 102.0, 101.0, 104.0, 103.0, 105.0], dtype=np.float64)
raw_signals = np.array([0.0, 1.0, 1.0, -1.0, -1.0, 0.0], dtype=np.float64)
def strategy(_, **__):
return raw_signals
initial_capital = 100_000.0
cm = CommissionModel.proportional(0.001) # 0.1% proportional commission
result = (
BacktestEngine()
.with_commission_model(cm)
.with_initial_capital(initial_capital)
.run(close, strategy=strategy)
)
expected_positions = np.array(
[0.0, 0.0, 1.0, 1.0, -1.0, -1.0], dtype=np.float64
)
np.testing.assert_allclose(result.positions, expected_positions)
# With commission, final equity should be less than without
result_no_comm = (
BacktestEngine()
.with_initial_capital(initial_capital)
.run(close, strategy=strategy)
)
assert result.final_equity <= result_no_comm.final_equity
# ---------------------------------------------------------------------------
# Plugin / Registry
# ---------------------------------------------------------------------------
from ferro_ta.core.registry import (
FerroTARegistryError,
get,
list_indicators,
register,
run,
unregister,
)
class TestRegistry:
"""Registry: register, get, run, unregister, list_indicators."""
def test_builtins_registered(self):
names = list_indicators()
assert "SMA" in names
assert "RSI" in names
assert "EMA" in names
assert "ATR" in names
def test_run_builtin_sma(self):
close = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
result = run("SMA", close, timeperiod=3)
# SMA(3) of [1,2,3,4,5]: valid at indices 2,3,4
assert result.shape == (5,)
assert np.isnan(result[0])
assert abs(float(result[2]) - 2.0) < 1e-8
def test_run_builtin_rsi(self):
close = 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,
]
)
result = run("RSI", close, timeperiod=14)
assert result.shape == (15,)
def test_get_returns_callable(self):
fn = get("EMA")
assert callable(fn)
def test_register_custom_indicator(self):
def DOUBLE_SMA(close, timeperiod=5):
return close * 2.0
register("DOUBLE_SMA", DOUBLE_SMA)
try:
close = np.array([1.0, 2.0, 3.0])
result = run("DOUBLE_SMA", close, timeperiod=2)
np.testing.assert_array_equal(result, np.array([2.0, 4.0, 6.0]))
finally:
unregister("DOUBLE_SMA")
def test_unregister_removes_indicator(self):
def TEMP_IND(close):
return close
register("TEMP_IND", TEMP_IND)
assert "TEMP_IND" in list_indicators()
unregister("TEMP_IND")
assert "TEMP_IND" not in list_indicators()
def test_unknown_indicator_raises(self):
with pytest.raises(FerroTARegistryError):
get("UNKNOWN_INDICATOR_XYZ")
def test_run_unknown_indicator_raises(self):
with pytest.raises(FerroTARegistryError):
run("NO_SUCH_IND", np.array([1.0, 2.0]))
def test_unregister_unknown_raises(self):
with pytest.raises(FerroTARegistryError):
unregister("NEVER_REGISTERED")
def test_register_non_callable_raises(self):
with pytest.raises(TypeError):
register("BAD", 42) # type: ignore[arg-type]
def test_list_indicators_is_sorted(self):
names = list_indicators()
assert names == sorted(names)
def test_all_builtins_are_callable(self):
for name in list_indicators():
fn = get(name)
assert callable(fn), f"{name} is not callable"
# ---------------------------------------------------------------------------
# New Extended Indicators (KELTNER_CHANNELS, HULL_MA,
# CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX)
# ---------------------------------------------------------------------------
from ferro_ta import (
CHANDELIER_EXIT,
CHOPPINESS_INDEX,
HULL_MA,
KELTNER_CHANNELS,
VWMA,
)
_N = 30
_C = np.cumsum(np.ones(_N)) + 40.0
_H = _C + 0.5
_L = _C - 0.5
_V = np.full(_N, 1_000_000.0)
class TestKeltnerChannels:
def test_output_shapes(self):
u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3)
assert len(u) == len(m) == len(lo) == _N
def test_upper_gt_middle_gt_lower(self):
u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3)
valid = ~np.isnan(u)
assert np.all(u[valid] > m[valid])
assert np.all(m[valid] > lo[valid])
class TestHullMA:
def test_output_length(self):
hull = HULL_MA(_C, timeperiod=4)
assert len(hull) == _N
def test_leading_nans(self):
hull = HULL_MA(_C, timeperiod=4)
assert int(np.sum(np.isnan(hull))) >= 1
def test_finite_after_warmup(self):
hull = HULL_MA(_C, timeperiod=4)
assert np.all(np.isfinite(hull[~np.isnan(hull)]))
class TestChandelierExit:
def test_output_shapes(self):
le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0)
assert len(le) == len(se) == _N
def test_long_lt_high_short_gt_low(self):
le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0)
# Both outputs should have valid values after warmup
valid_le = ~np.isnan(le)
valid_se = ~np.isnan(se)
assert valid_le.any()
assert valid_se.any()
# Long exit must be finite and positive
assert np.all(np.isfinite(le[valid_le]))
assert np.all(le[valid_le] > 0.0)
# Short exit must be finite and positive
assert np.all(np.isfinite(se[valid_se]))
assert np.all(se[valid_se] > 0.0)
class TestVWMA:
def test_output_length(self):
v = VWMA(_C, _V, timeperiod=5)
assert len(v) == _N
def test_leading_nans(self):
v = VWMA(_C, _V, timeperiod=5)
assert int(np.sum(np.isnan(v))) == 4
def test_uniform_volume_equals_sma(self):
"""With uniform volume, VWMA equals SMA."""
from ferro_ta import SMA
c = np.arange(1.0, 21.0)
v = np.ones(20)
vwma = VWMA(c, v, timeperiod=5)
sma = SMA(c, timeperiod=5)
valid = ~np.isnan(vwma) & ~np.isnan(sma)
assert np.allclose(vwma[valid], sma[valid], rtol=1e-9)
class TestChoppinessIndex:
def test_output_length(self):
ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5)
assert len(ci) == _N
def test_range_0_to_100(self):
ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5)
valid = ci[~np.isnan(ci)]
if len(valid) > 0:
assert np.all(valid >= 0.0)
assert np.all(valid <= 100.0)
# ---------------------------------------------------------------------------
# Batch execution API
# ---------------------------------------------------------------------------
from ferro_ta import EMA, RSI, SMA
from ferro_ta.data.batch import (
batch_apply,
batch_atr,
batch_ema,
batch_rsi,
batch_sma,
)
class TestBatchSMA:
C2D = np.random.default_rng(7).random((50, 3)) + 50.0
C1D = C2D[:, 0]
def test_output_shape_2d(self):
result = batch_sma(self.C2D, timeperiod=10)
assert result.shape == (50, 3)
def test_output_shape_1d_unchanged(self):
"""1-D input should return 1-D (backward compatible)."""
result = batch_sma(self.C1D, timeperiod=10)
assert result.ndim == 1
assert len(result) == 50
def test_column_matches_single_series(self):
"""Each column of batch_sma must match single-series SMA."""
result = batch_sma(self.C2D, timeperiod=10)
for j in range(3):
expected = SMA(self.C2D[:, j], timeperiod=10)
assert np.allclose(result[:, j], expected, equal_nan=True)
class TestBatchEMA:
C2D = np.random.default_rng(8).random((50, 4)) + 40.0
def test_output_shape(self):
result = batch_ema(self.C2D, timeperiod=5)
assert result.shape == (50, 4)
def test_column_matches_single_series(self):
result = batch_ema(self.C2D, timeperiod=5)
for j in range(4):
expected = EMA(self.C2D[:, j], timeperiod=5)
assert np.allclose(result[:, j], expected, equal_nan=True)
class TestBatchRSI:
C2D = np.random.default_rng(9).random((50, 2)) + 45.0
def test_output_shape(self):
result = batch_rsi(self.C2D, timeperiod=14)
assert result.shape == (50, 2)
def test_values_in_range(self):
result = batch_rsi(self.C2D, timeperiod=14)
valid = result[~np.isnan(result)]
if len(valid) > 0:
assert valid.min() >= 0.0
assert valid.max() <= 100.0
def test_column_matches_single_series(self):
result = batch_rsi(self.C2D, timeperiod=14)
for j in range(2):
expected = RSI(self.C2D[:, j], timeperiod=14)
assert np.allclose(result[:, j], expected, equal_nan=True)
class TestBatchApply:
C2D = np.random.default_rng(11).random((40, 3)) + 50.0
def test_custom_fn(self):
"""batch_apply should delegate to any single-series function."""
from ferro_ta import BBANDS
def mid(c, **kw):
return BBANDS(c, **kw)[1]
result = batch_apply(self.C2D, mid, timeperiod=5)
assert result.shape == (40, 3)
def test_3d_raises(self):
with pytest.raises(ValueError, match="1-D or 2-D"):
batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3)
def test_sma_fastpath_matches_batch_sma(self):
from ferro_ta.data.batch import batch_sma
fast = batch_apply(self.C2D, SMA, timeperiod=10)
direct = batch_sma(self.C2D, timeperiod=10)
assert np.allclose(fast, direct, equal_nan=True)
class TestBatchShapeValidation:
def test_batch_atr_shape_mismatch_raises(self):
high = np.ones((5, 2), dtype=np.float64)
low = np.ones((4, 2), dtype=np.float64)
close = np.ones((5, 2), dtype=np.float64)
with pytest.raises(ValueError, match="shape"):
batch_atr(high, low, close, timeperiod=3)
# ---------------------------------------------------------------------------
# Release playbook and version consistency
# ---------------------------------------------------------------------------
import os
import re
import runpy
import subprocess
try:
import tomllib # Python 3.11+
except ImportError:
try:
import tomli as tomllib # type: ignore[no-redef] # fallback for Python < 3.11
except ImportError:
tomllib = None # type: ignore[assignment]
def _read_cargo_version() -> str:
"""Extract version from root Cargo.toml."""
if tomllib is None:
raise ImportError("tomllib/tomli not available")
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
cargo_toml = os.path.join(root, "Cargo.toml")
with open(cargo_toml, "rb") as f:
data = tomllib.load(f)
return data["package"]["version"]
def _read_pyproject_version() -> str:
"""Extract version from pyproject.toml."""
if tomllib is None:
raise ImportError("tomllib/tomli not available")
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
pyproject_toml = os.path.join(root, "pyproject.toml")
with open(pyproject_toml, "rb") as f:
data = tomllib.load(f)
return data["project"]["version"]
def _read_conda_version() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
conda_meta = os.path.join(root, "conda", "meta.yaml")
text = open(conda_meta).read()
match = re.search(r'{% set version = "([^"]+)" %}', text)
if not match:
raise ValueError("Could not find conda version")
return match.group(1)
def _read_docs_release() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
conf_py = os.path.join(root, "docs", "conf.py")
old_env = os.environ.pop("FERRO_TA_VERSION", None)
try:
data = runpy.run_path(conf_py)
return data["release"]
finally:
if old_env is not None:
os.environ["FERRO_TA_VERSION"] = old_env
def _run_bump_version_check() -> subprocess.CompletedProcess[str]:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return subprocess.run(
["python3", "scripts/bump_version.py", "--check"],
cwd=root,
text=True,
capture_output=True,
check=False,
)
class TestVersionConsistency:
"""Public version strings should stay aligned with the package version."""
def test_versions_match(self):
try:
cargo_ver = _read_cargo_version()
pyproject_ver = _read_pyproject_version()
except Exception:
pytest.skip("tomllib unavailable or files not found")
assert cargo_ver == pyproject_ver, (
f"Version mismatch: Cargo.toml={cargo_ver!r}, "
f"pyproject.toml={pyproject_ver!r}"
)
def test_package_version_matches_project_version(self):
cargo_ver = _read_cargo_version()
assert ferro_ta.__version__ == cargo_ver
def test_conda_version_matches_project_version(self):
cargo_ver = _read_cargo_version()
conda_ver = _read_conda_version()
assert conda_ver == cargo_ver
def test_docs_release_matches_project_version(self):
cargo_ver = _read_cargo_version()
docs_release = _read_docs_release()
assert docs_release == cargo_ver
def test_docs_changelog_mentions_current_version(self):
cargo_ver = _read_cargo_version()
root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
changelog_rst = os.path.join(root, "docs", "changelog.rst")
text = open(changelog_rst).read()
assert cargo_ver in text
def test_api_version_matches_project_version(self):
cargo_ver = _read_cargo_version()
try:
from api.main import app
except Exception:
pytest.skip("api/main.py not importable")
assert app.version == cargo_ver
def test_bump_version_check_passes(self):
result = _run_bump_version_check()
assert result.returncode == 0, result.stdout + result.stderr
def test_release_md_exists(self):
"""RELEASE.md must exist in the repository root."""
root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
release_md = os.path.join(root, "RELEASE.md")
assert os.path.isfile(release_md), "RELEASE.md not found"
def test_release_md_has_key_sections(self):
"""RELEASE.md must mention tagging and PyPI."""
root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
release_md = os.path.join(root, "RELEASE.md")
if not os.path.isfile(release_md):
pytest.skip("RELEASE.md not found")
text = open(release_md).read()
assert "git tag" in text or "tag" in text.lower()
assert "pypi" in text.lower() or "PyPI" in text
# ---------------------------------------------------------------------------
# GPU backend (PyTorch, CPU fallback always available)
# ---------------------------------------------------------------------------
from ferro_ta.tools.gpu import ema as gpu_ema
from ferro_ta.tools.gpu import rsi as gpu_rsi
from ferro_ta.tools.gpu import sma as gpu_sma # noqa: E402
CLOSE_15 = 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,
]
)
class TestGPUCPUFallback:
"""GPU module falls back to CPU when CuPy is not available."""
def test_sma_cpu_fallback_length(self):
result = gpu_sma(CLOSE_15, timeperiod=5)
assert len(result) == len(CLOSE_15)
def test_sma_cpu_fallback_values(self):
from ferro_ta import SMA
result = gpu_sma(CLOSE_15, timeperiod=5)
expected = SMA(CLOSE_15, timeperiod=5)
np.testing.assert_allclose(result, expected, equal_nan=True)
def test_ema_cpu_fallback_values(self):
from ferro_ta import EMA
result = gpu_ema(CLOSE_15, timeperiod=5)
expected = EMA(CLOSE_15, timeperiod=5)
np.testing.assert_allclose(result, expected, equal_nan=True)
def test_rsi_cpu_fallback_values(self):
from ferro_ta import RSI
result = gpu_rsi(CLOSE_15, timeperiod=5)
expected = RSI(CLOSE_15, timeperiod=5)
np.testing.assert_allclose(result, expected, equal_nan=True)
def test_sma_returns_numpy_for_numpy_input(self):
result = gpu_sma(CLOSE_15, timeperiod=5)
assert isinstance(result, np.ndarray)
def test_rsi_finite_values_in_range(self):
result = gpu_rsi(CLOSE_15, timeperiod=5)
finite = result[np.isfinite(result)]
assert len(finite) > 0
assert np.all(finite >= 0.0)
assert np.all(finite <= 100.0)
def test_gpu_module_all_exports(self):
from ferro_ta.tools import gpu as gpu_mod
for name in gpu_mod.__all__:
assert callable(getattr(gpu_mod, name))
# ---------------------------------------------------------------------------
# Indicator pipeline
# ---------------------------------------------------------------------------
from ferro_ta import BBANDS # noqa: E402 (already imported)
from ferro_ta.tools.pipeline import Pipeline, make_pipeline # noqa: E402
CLOSE_20 = np.random.default_rng(99).random(20) * 100 + 50
class TestPipeline:
"""Tests for ferro_ta.pipeline.Pipeline."""
def test_pipeline_run_returns_dict(self):
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
result = pipe.run(CLOSE_20)
assert isinstance(result, dict)
assert "sma5" in result
def test_pipeline_result_length_matches_input(self):
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
result = pipe.run(CLOSE_20)
assert len(result["sma5"]) == len(CLOSE_20)
def test_pipeline_multiple_steps(self):
pipe = (
Pipeline()
.add("sma5", SMA, timeperiod=5)
.add("ema5", EMA, timeperiod=5)
.add("rsi7", RSI, timeperiod=7)
)
result = pipe.run(CLOSE_20)
assert set(result.keys()) == {"sma5", "ema5", "rsi7"}
def test_pipeline_multi_output_with_output_keys(self):
pipe = Pipeline().add(
"bb",
BBANDS,
timeperiod=5,
nbdevup=2.0,
nbdevdn=2.0,
output_keys=["upper", "mid", "lower"],
)
result = pipe.run(CLOSE_20)
assert "upper" in result
assert "mid" in result
assert "lower" in result
assert "bb" not in result
def test_pipeline_multi_output_without_output_keys(self):
pipe = Pipeline().add("bb", BBANDS, timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
result = pipe.run(CLOSE_20)
# Should auto-name as bb_0, bb_1, bb_2
assert "bb_0" in result
assert "bb_1" in result
assert "bb_2" in result
def test_pipeline_remove_step(self):
pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5)
pipe.remove("sma5")
assert pipe.steps() == ["ema5"]
def test_pipeline_len(self):
pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5)
assert len(pipe) == 2
def test_pipeline_duplicate_name_raises(self):
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
with pytest.raises(ValueError, match="sma5"):
pipe.add("sma5", SMA, timeperiod=10)
def test_make_pipeline_factory(self):
pipe = make_pipeline(
sma5=(SMA, {"timeperiod": 5}),
rsi7=(RSI, {"timeperiod": 7}),
)
result = pipe.run(CLOSE_20)
assert "sma5" in result
assert "rsi7" in result
def test_pipeline_sma_values_match_direct_call(self):
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
result = pipe.run(CLOSE_20)
direct = SMA(CLOSE_20, timeperiod=5)
np.testing.assert_allclose(result["sma5"], direct, equal_nan=True)
# ---------------------------------------------------------------------------
# Polars integration (skipped if polars not installed)
# ---------------------------------------------------------------------------
class TestPolarsIntegration:
"""Transparent polars.Series support via polars_wrap."""
@pytest.fixture(autouse=True)
def skip_if_no_polars(self):
pytest.importorskip("polars")
def test_sma_returns_polars_series(self):
import polars as pl
s = pl.Series("close", CLOSE_20.tolist())
result = SMA(s, timeperiod=5)
assert isinstance(result, pl.Series)
def test_sma_values_match_numpy(self):
import polars as pl
s = pl.Series("close", CLOSE_20.tolist())
result = SMA(s, timeperiod=5)
expected = SMA(CLOSE_20, timeperiod=5)
np.testing.assert_allclose(result.to_numpy(), expected, equal_nan=True)
def test_rsi_returns_polars_series(self):
import polars as pl
s = pl.Series("close", CLOSE_20.tolist())
result = RSI(s, timeperiod=5)
assert isinstance(result, pl.Series)
def test_numpy_input_still_returns_numpy(self):
result = SMA(CLOSE_20, timeperiod=5)
assert isinstance(result, np.ndarray)
# ---------------------------------------------------------------------------
# Configuration defaults
# ---------------------------------------------------------------------------
import ferro_ta.core.config as ftconfig # noqa: E402
class TestConfig:
"""Tests for ferro_ta.config module."""
def setup_method(self):
"""Reset config state before each test."""
ftconfig.reset()
def teardown_method(self):
"""Clean up after each test."""
ftconfig.reset()
def test_set_and_get_default(self):
ftconfig.set_default("timeperiod", 20)
assert ftconfig.get_default("timeperiod") == 20
def test_get_default_fallback(self):
assert ftconfig.get_default("nonexistent") is None
assert ftconfig.get_default("nonexistent", -1) == -1
def test_reset_single_key(self):
ftconfig.set_default("timeperiod", 20)
ftconfig.reset("timeperiod")
assert ftconfig.get_default("timeperiod") is None
def test_reset_all(self):
ftconfig.set_default("timeperiod", 20)
ftconfig.set_default("RSI.timeperiod", 14)
ftconfig.reset()
assert ftconfig.list_defaults() == {}
def test_list_defaults(self):
ftconfig.set_default("timeperiod", 20)
ftconfig.set_default("RSI.timeperiod", 14)
defaults = ftconfig.list_defaults()
assert defaults == {"timeperiod": 20, "RSI.timeperiod": 14}
def test_get_defaults_for_indicator(self):
ftconfig.set_default("timeperiod", 20)
ftconfig.set_default("RSI.timeperiod", 14)
rsi_defaults = ftconfig.get_defaults_for("RSI")
assert rsi_defaults == {"timeperiod": 14}
sma_defaults = ftconfig.get_defaults_for("SMA")
assert sma_defaults == {"timeperiod": 20}
def test_config_context_manager(self):
ftconfig.set_default("timeperiod", 20)
with ftconfig.Config(timeperiod=5):
assert ftconfig.get_default("timeperiod") == 5
assert ftconfig.get_default("timeperiod") == 20
def test_config_context_manager_restores_on_exception(self):
ftconfig.set_default("timeperiod", 20)
try:
with ftconfig.Config(timeperiod=5):
raise RuntimeError("test error")
except RuntimeError:
pass
assert ftconfig.get_default("timeperiod") == 20
def test_config_context_manager_new_key_removed_on_exit(self):
# Key doesn't exist before context
assert ftconfig.get_default("nbdevup") is None
with ftconfig.Config(nbdevup=2.5):
assert ftconfig.get_default("nbdevup") == 2.5
assert ftconfig.get_default("nbdevup") is None