"""Tests for alerts, crypto helpers, chunked processing, regime detection, performance attribution, and dashboard helpers. """ from __future__ import annotations import importlib import runpy import numpy as np import pytest # --------------------------------------------------------------------------- # Synthetic helpers # --------------------------------------------------------------------------- RNG = np.random.default_rng(31415) def _make_close(n: int = 200) -> np.ndarray: return np.cumprod(1 + RNG.normal(0, 0.01, n)) * 100.0 def _make_ohlcv(n: int = 200): close = _make_close(n) open_ = close * RNG.uniform(0.995, 1.005, n) high = np.maximum(close, open_) + RNG.uniform(0, 0.5, n) low = np.minimum(close, open_) - RNG.uniform(0, 0.5, n) volume = RNG.uniform(500, 5000, n) return open_, high, low, close, volume # =========================================================================== # Alerts # =========================================================================== class TestAlertsLowLevel: """Tests for low-level alert condition functions.""" def test_check_threshold_cross_above(self): from ferro_ta.tools.alerts import check_threshold series = np.array([20.0, 25.0, 30.0, 35.0, 28.0]) mask = check_threshold(series, level=29.0, direction=1) # Cross above fires when series was <= level and now > level. # Bar 0: no prior bar, always 0. # Bar 2: prev=25 <= 29, curr=30 > 29 → fires assert mask[0] == 0 assert mask[2] == 1 assert mask[1] == 0 assert mask[3] == 0 # 30 > 29 previously, so no new crossing def test_check_threshold_cross_below(self): from ferro_ta.tools.alerts import check_threshold series = np.array([70.0, 65.0, 28.0, 25.0, 35.0]) mask = check_threshold(series, level=30.0, direction=-1) # index 2: 65 >= 30 → 28 < 30: cross below assert mask[2] == 1 assert mask[0] == 0 assert mask[4] == 0 # 25 < 30 already, 35 > 30 is not a cross-below def test_check_threshold_invalid_direction(self): from ferro_ta.tools.alerts import check_threshold with pytest.raises(Exception): check_threshold(np.array([1.0, 2.0]), level=1.5, direction=0) def test_check_cross_bullish(self): from ferro_ta.tools.alerts import check_cross fast = np.array([10.0, 12.0, 15.0, 14.0, 16.0]) slow = np.array([13.0, 13.0, 13.0, 13.0, 13.0]) mask = check_cross(fast, slow) # fast crosses above slow at index 2 (12 <= 13 → 15 > 13) assert mask[2] == 1 # bullish assert mask[0] == 0 def test_check_cross_bearish(self): from ferro_ta.tools.alerts import check_cross fast = np.array([15.0, 15.0, 12.0, 11.0]) slow = np.array([13.0, 13.0, 13.0, 13.0]) mask = check_cross(fast, slow) # fast crosses below slow at index 2 (15 >= 13 → 12 < 13) assert mask[2] == -1 # bearish def test_check_cross_length_mismatch_raises(self): from ferro_ta.tools.alerts import check_cross with pytest.raises(Exception): check_cross(np.array([1.0, 2.0]), np.array([1.0, 2.0, 3.0])) def test_collect_alert_bars(self): from ferro_ta.tools.alerts import collect_alert_bars mask = np.array([0, 1, 0, 0, 1, -1], dtype=np.int8) bars = collect_alert_bars(mask) assert list(bars) == [1, 4, 5] def test_collect_alert_bars_empty(self): from ferro_ta.tools.alerts import collect_alert_bars mask = np.zeros(10, dtype=np.int8) bars = collect_alert_bars(mask) assert len(bars) == 0 class TestAlertManager: """Tests for the AlertManager class.""" def test_run_backtest_returns_list(self): from ferro_ta import RSI from ferro_ta.tools.alerts import AlertManager close = _make_close(200) rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) am = AlertManager(symbol="TEST") am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1) events = am.run_backtest() assert isinstance(events, list) def test_backtest_no_external_calls_by_default(self): """Backtest mode must not invoke callback unless force_live=True.""" from ferro_ta import SMA from ferro_ta.tools.alerts import AlertManager close = _make_close(100) sma10 = np.asarray(SMA(close, timeperiod=10), dtype=np.float64) sma30 = np.asarray(SMA(close, timeperiod=30), dtype=np.float64) called = [] def cb(ev): called.append(ev) am = AlertManager() am.add_cross_condition("sma_x", sma10, sma30, callback=cb) events = am.run_backtest() # default live=False assert len(called) == 0, "callback must not fire in backtest mode" assert isinstance(events, list) def test_backtest_force_live_invokes_callback(self): from ferro_ta import RSI from ferro_ta.tools.alerts import AlertManager close = _make_close(300) rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) fired = [] def cb(ev): fired.append(ev) am = AlertManager() am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1, callback=cb) events = am.run_backtest(force_live=True) assert len(fired) == len(events) def test_event_payload_contains_symbol(self): from ferro_ta import RSI from ferro_ta.tools.alerts import AlertManager close = _make_close(200) rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) am = AlertManager(symbol="BTCUSD") am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1) events = am.run_backtest() for ev in events: assert ev.payload.get("symbol") == "BTCUSD" def test_event_bar_index_valid(self): from ferro_ta import SMA from ferro_ta.tools.alerts import AlertManager close = _make_close(100) sma5 = np.asarray(SMA(close, timeperiod=5), dtype=np.float64) sma20 = np.asarray(SMA(close, timeperiod=20), dtype=np.float64) am = AlertManager() am.add_cross_condition("x", sma5, sma20) events = am.run_backtest() for ev in events: assert 0 <= ev.bar_index < len(close) def test_alert_event_to_dict(self): from ferro_ta.tools.alerts import AlertEvent ev = AlertEvent("my_cond", 42, value=27.5, payload={"symbol": "X"}) d = ev.to_dict() assert d["condition_id"] == "my_cond" assert d["bar_index"] == 42 assert d["symbol"] == "X" # =========================================================================== # Crypto helpers # =========================================================================== class TestCryptoFunding: def test_funding_pnl_shape(self): from ferro_ta.analysis.crypto import funding_pnl pos = np.ones(100) rate = RNG.normal(0, 0.0001, 100) pnl = funding_pnl(pos, rate) assert pnl.shape == (100,) def test_funding_pnl_cumulative(self): from ferro_ta.analysis.crypto import funding_pnl pos = np.ones(5) rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001]) pnl = funding_pnl(pos, rate) expected = np.cumsum(-pos * rate) np.testing.assert_allclose(pnl, expected) def test_funding_pnl_long_pays_positive_rate(self): """Long position should pay (negative PnL) when funding rate > 0.""" from ferro_ta.analysis.crypto import funding_pnl pos = np.ones(1) rate = np.array([0.001]) # positive rate → long pays pnl = funding_pnl(pos, rate) assert pnl[0] < 0 def test_funding_pnl_short_receives_positive_rate(self): """Short position should receive (positive PnL) when funding rate > 0.""" from ferro_ta.analysis.crypto import funding_pnl pos = np.array([-1.0]) rate = np.array([0.001]) pnl = funding_pnl(pos, rate) assert pnl[0] > 0 def test_funding_pnl_length_mismatch_raises(self): from ferro_ta.analysis.crypto import funding_pnl with pytest.raises(Exception): funding_pnl(np.ones(5), np.ones(4)) class TestCryptoBarLabels: def test_continuous_bar_labels_shape(self): from ferro_ta.analysis.crypto import continuous_bar_labels labels = continuous_bar_labels(10, 3) assert labels.shape == (10,) def test_continuous_bar_labels_values(self): from ferro_ta.analysis.crypto import continuous_bar_labels labels = continuous_bar_labels(10, 3) expected = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3] np.testing.assert_array_equal(labels, expected) def test_continuous_bar_labels_period_one(self): from ferro_ta.analysis.crypto import continuous_bar_labels labels = continuous_bar_labels(5, 1) np.testing.assert_array_equal(labels, [0, 1, 2, 3, 4]) def test_session_boundaries_daily(self): from ferro_ta.analysis.crypto import session_boundaries NS_PER_HOUR = np.int64(3_600_000_000_000) # Use a UTC midnight timestamp as base: 1_699_920_000 seconds = Nov 14, 2023 00:00:00 UTC base = np.int64(1_699_920_000) * np.int64(1_000_000_000) # midnight UTC # 48 hourly bars = 2 full days ts = base + np.arange(48, dtype=np.int64) * NS_PER_HOUR bounds = session_boundaries(ts) assert bounds[0] == 0 # first bar always included # Should have exactly 2 boundaries (day 0 and day 1) assert len(bounds) == 2 assert bounds[1] == 24 # second day starts at bar 24 class TestResampleContinuous: def test_resample_continuous_shape(self): from ferro_ta.analysis.crypto import resample_continuous o, h, l, c, v = _make_ohlcv(100) ro, rh, rl, rc, rv = resample_continuous((o, h, l, c, v), period_bars=5) assert len(rc) == 20 # 100 / 5 def test_resample_continuous_high_ge_low(self): from ferro_ta.analysis.crypto import resample_continuous o, h, l, c, v = _make_ohlcv(100) _, rh, rl, _, _ = resample_continuous((o, h, l, c, v), period_bars=5) assert np.all(rh >= rl) def test_resample_continuous_invalid_period_raises(self): from ferro_ta.analysis.crypto import resample_continuous o, h, l, c, v = _make_ohlcv(10) with pytest.raises(ValueError): resample_continuous((o, h, l, c, v), period_bars=0) # =========================================================================== # Chunked processing # =========================================================================== class TestChunked: def test_make_chunk_ranges_shape(self): from ferro_ta.data.chunked import make_chunk_ranges ranges = make_chunk_ranges(100, 30, 10) assert ranges.ndim == 2 assert ranges.shape[1] == 2 def test_make_chunk_ranges_coverage(self): """All input indices must be covered by some range.""" from ferro_ta.data.chunked import make_chunk_ranges n = 97 ranges = make_chunk_ranges(n, 20, 5) covered = set() for start, end in ranges: covered.update(range(int(start), int(end))) assert 0 in covered assert (n - 1) in covered def test_trim_overlap_basic(self): from ferro_ta.data.chunked import trim_overlap arr = np.arange(10, dtype=np.float64) trimmed = trim_overlap(arr, overlap=3) np.testing.assert_array_equal(trimmed, arr[3:]) def test_trim_overlap_zero(self): from ferro_ta.data.chunked import trim_overlap arr = np.arange(5, dtype=np.float64) trimmed = trim_overlap(arr, overlap=0) np.testing.assert_array_equal(trimmed, arr) def test_stitch_chunks_basic(self): from ferro_ta.data.chunked import stitch_chunks a = np.array([1.0, 2.0, 3.0]) b = np.array([4.0, 5.0]) result = stitch_chunks([a, b]) np.testing.assert_array_equal(result, [1, 2, 3, 4, 5]) def test_chunk_apply_sma_matches_full(self): """chunk_apply(SMA, …) should produce the same result as SMA on the full series.""" from ferro_ta import SMA from ferro_ta.data.chunked import chunk_apply close = _make_close(500) full_out = np.asarray(SMA(close, timeperiod=20), dtype=np.float64) chunked_out = chunk_apply(SMA, close, chunk_size=100, overlap=30, timeperiod=20) # Compare non-NaN region valid = ~np.isnan(full_out) np.testing.assert_allclose( chunked_out[valid], full_out[valid], rtol=1e-10, err_msg="chunk_apply SMA must match full SMA for non-NaN bars", ) def test_chunk_apply_output_length(self): from ferro_ta import EMA from ferro_ta.data.chunked import chunk_apply close = _make_close(300) out = chunk_apply(EMA, close, chunk_size=80, overlap=20, timeperiod=10) assert len(out) == len(close) # =========================================================================== # Regime detection # =========================================================================== class TestRegimeDetection: def test_regime_adx_shape(self): from ferro_ta import ADX from ferro_ta.analysis.regime import regime_adx o, h, l, c, v = _make_ohlcv(200) adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) labels = regime_adx(adx, threshold=25.0) assert labels.shape == (200,) def test_regime_adx_values_valid(self): from ferro_ta import ADX from ferro_ta.analysis.regime import regime_adx o, h, l, c, v = _make_ohlcv(200) adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) labels = regime_adx(adx, threshold=25.0) # Values must be -1, 0, or 1 assert set(labels).issubset({-1, 0, 1}) def test_regime_adx_nan_bars_are_minus_one(self): from ferro_ta.analysis.regime import regime_adx adx = np.full(20, np.nan) adx[15:] = 30.0 # last 5 are trend labels = regime_adx(adx, threshold=25.0) assert np.all(labels[:15] == -1) assert np.all(labels[15:] == 1) def test_regime_combined_shape(self): from ferro_ta import ADX, ATR from ferro_ta.analysis.regime import regime_combined o, h, l, c, v = _make_ohlcv(200) adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) atr = np.asarray(ATR(h, l, c, timeperiod=14), dtype=np.float64) labels = regime_combined( adx, atr, c, adx_threshold=25.0, atr_pct_threshold=0.005 ) assert labels.shape == (200,) assert set(labels).issubset({-1, 0, 1}) def test_regime_high_level_adx(self): from ferro_ta.analysis.regime import regime o, h, l, c, v = _make_ohlcv(200) labels = regime((o, h, l, c, v), method="adx", adx_threshold=25.0) assert labels.shape == (200,) assert set(labels).issubset({-1, 0, 1}) def test_regime_high_level_combined(self): from ferro_ta.analysis.regime import regime o, h, l, c, v = _make_ohlcv(200) labels = regime((o, h, l, c, v), method="combined") assert labels.shape == (200,) def test_regime_unknown_method_raises(self): from ferro_ta.analysis.regime import regime o, h, l, c, v = _make_ohlcv(50) with pytest.raises(ValueError): regime((o, h, l, c, v), method="unknown") class TestStructuralBreaks: def test_detect_breaks_cusum_shape(self): from ferro_ta.analysis.regime import detect_breaks_cusum series = _make_close(200) mask = detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5) assert mask.shape == (200,) def test_detect_breaks_cusum_fires_near_break(self): """CUSUM should detect a level shift.""" from ferro_ta.analysis.regime import detect_breaks_cusum rng = np.random.default_rng(99) s1 = rng.normal(0, 1, 100) s2 = rng.normal(10, 1, 100) # large level shift series = np.concatenate([s1, s2]) mask = detect_breaks_cusum(series, window=20, threshold=2.0, slack=0.3) # Should fire somewhere near the shift assert mask[100:130].any() def test_rolling_variance_break_shape(self): from ferro_ta.analysis.regime import rolling_variance_break series = _make_close(200) mask = rolling_variance_break( series, short_window=10, long_window=50, threshold=2.0 ) assert mask.shape == (200,) def test_structural_breaks_cusum(self): from ferro_ta.analysis.regime import structural_breaks series = _make_close(200) mask = structural_breaks(series, method="cusum") assert mask.shape == (200,) def test_structural_breaks_variance(self): from ferro_ta.analysis.regime import structural_breaks series = _make_close(200) mask = structural_breaks(series, method="variance") assert mask.shape == (200,) def test_structural_breaks_unknown_method_raises(self): from ferro_ta.analysis.regime import structural_breaks with pytest.raises(ValueError): structural_breaks(_make_close(50), method="xyz") # =========================================================================== # Performance attribution # =========================================================================== class TestTradeStats: def test_basic_stats(self): from ferro_ta.analysis.attribution import trade_stats pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0]) hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0]) ts = trade_stats(pnl, hold) assert ts.n_trades == 6 assert abs(ts.win_rate - 0.5) < 1e-10 # 3 wins out of 6 assert ts.avg_win > 0 assert ts.avg_loss < 0 assert ts.profit_factor > 0 assert ts.avg_hold_bars == pytest.approx(4.67, abs=0.01) def test_all_wins(self): from ferro_ta.analysis.attribution import trade_stats pnl = np.array([5.0, 10.0, 3.0]) ts = trade_stats(pnl) assert ts.win_rate == 1.0 assert ts.avg_loss == 0.0 assert ts.profit_factor == float("inf") def test_all_losses(self): from ferro_ta.analysis.attribution import trade_stats pnl = np.array([-5.0, -3.0]) ts = trade_stats(pnl) assert ts.win_rate == 0.0 assert ts.avg_win == 0.0 assert ts.profit_factor == 0.0 def test_empty_raises(self): from ferro_ta.analysis.attribution import trade_stats with pytest.raises(Exception): trade_stats(np.array([])) def test_to_dict(self): from ferro_ta.analysis.attribution import trade_stats pnl = np.array([1.0, -1.0]) ts = trade_stats(pnl) d = ts.to_dict() assert "win_rate" in d assert "profit_factor" in d class TestFromBacktest: def test_from_backtest_returns_arrays(self): from ferro_ta.analysis.attribution import from_backtest from ferro_ta.analysis.backtest import backtest close = _make_close(200) result = backtest(close, strategy="rsi_30_70") pnl, hold = from_backtest(result) assert isinstance(pnl, np.ndarray) assert isinstance(hold, np.ndarray) assert len(pnl) == len(hold) # n_trades counts position *changes* (entries + exits); # from_backtest counts round-trips (position runs), so len(pnl) <= n_trades assert len(pnl) <= result.n_trades # Each hold duration should be >= 1 if len(hold) > 0: assert np.all(hold >= 1) def test_from_backtest_no_trades(self): from ferro_ta.analysis.attribution import from_backtest from ferro_ta.analysis.backtest import BacktestResult n = 50 result = BacktestResult( signals=np.zeros(n), positions=np.zeros(n), bar_returns=np.zeros(n), strategy_returns=np.zeros(n), equity=np.ones(n), ) pnl, hold = from_backtest(result) assert len(pnl) == 0 class TestAttribution: def test_attribution_by_signal_basic(self): from ferro_ta.analysis.attribution import attribution_by_signal ret = np.array([0.01, 0.02, -0.01, 0.03, -0.02]) labels = np.array([0, 0, 1, 1, -1], dtype=np.int64) contrib = attribution_by_signal(ret, labels) assert isinstance(contrib, dict) assert "signal_0" in contrib assert "signal_1" in contrib assert abs(contrib["signal_0"] - 0.03) < 1e-10 # 0.01 + 0.02 assert abs(contrib["signal_1"] - 0.02) < 1e-10 # -0.01 + 0.03 def test_attribution_by_month_returns_dict(self): from ferro_ta.analysis.attribution import attribution_by_month ret = RNG.normal(0, 0.01, 252) contrib = attribution_by_month(ret) assert isinstance(contrib, dict) assert len(contrib) > 0 def test_attribution_by_month_sum_close_to_total(self): """Sum of monthly contributions should approximate total strategy return.""" from ferro_ta.analysis.attribution import attribution_by_month ret = RNG.normal(0, 0.01, 252) contrib = attribution_by_month(ret) total_monthly = sum(contrib.values()) total_direct = float(np.sum(ret)) assert abs(total_monthly - total_direct) < 1e-8 # =========================================================================== # Dashboard (smoke tests, no display) # =========================================================================== class TestDashboard: def test_streamlit_app_import(self): """Module should import without errors even if streamlit not installed.""" try: from ferro_ta.tools import dashboard # noqa: F401 except ImportError: pytest.skip("dashboard module not importable") def test_indicator_widget_raises_without_ipywidgets(self, monkeypatch): from ferro_ta import SMA from ferro_ta.tools.dashboard import indicator_widget close = _make_close(50) # If ipywidgets not installed, should raise ImportError import sys fake_modules = dict(sys.modules) fake_modules["ipywidgets"] = None # type: ignore[assignment] fake_modules["matplotlib"] = None # type: ignore[assignment] fake_modules["matplotlib.pyplot"] = None # type: ignore[assignment] monkeypatch.setattr(sys, "modules", fake_modules) with pytest.raises((ImportError, TypeError)): indicator_widget(close, SMA, "timeperiod", range(5, 10)) # =========================================================================== # Web API (unit test with TestClient if fastapi is available) # =========================================================================== class TestWebAPI: @pytest.fixture(scope="class") def client(self): try: from fastapi.testclient import TestClient except ImportError: pytest.skip("fastapi not installed") import os import sys # Insert project root so that `api.main` is importable project_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) if project_root not in sys.path: sys.path.insert(0, project_root) try: from api.main import app except ImportError: pytest.skip("api/main.py not importable") return TestClient(app) def test_health(self, client): resp = client.get("/health") assert resp.status_code == 200 assert resp.json()["status"] == "ok" def test_sma_endpoint(self, client): close = list(np.linspace(100, 110, 30)) resp = client.post("/indicators/sma", json={"close": close, "timeperiod": 5}) assert resp.status_code == 200 result = resp.json()["result"] assert len(result) == 30 assert result[0] is None # warm-up is null def test_ema_endpoint(self, client): close = list(np.linspace(100, 110, 30)) resp = client.post("/indicators/ema", json={"close": close, "timeperiod": 5}) assert resp.status_code == 200 assert len(resp.json()["result"]) == 30 def test_rsi_endpoint(self, client): close = list(np.linspace(100, 110, 30)) resp = client.post("/indicators/rsi", json={"close": close, "timeperiod": 14}) assert resp.status_code == 200 def test_macd_endpoint(self, client): close = list(np.linspace(100, 120, 60)) resp = client.post("/indicators/macd", json={"close": close}) assert resp.status_code == 200 keys = resp.json()["result"].keys() assert {"macd", "signal", "hist"} == set(keys) def test_bbands_endpoint(self, client): close = list(np.linspace(100, 110, 30)) resp = client.post("/indicators/bbands", json={"close": close, "timeperiod": 5}) assert resp.status_code == 200 keys = resp.json()["result"].keys() assert {"upper", "middle", "lower"} == set(keys) def test_backtest_endpoint(self, client): close = list( np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 ) resp = client.post( "/backtest", json={"close": close, "strategy": "rsi_30_70"}, ) assert resp.status_code == 200 body = resp.json() assert "final_equity" in body assert "n_trades" in body def test_unknown_strategy_returns_422(self, client): close = list(np.linspace(100, 110, 30)) resp = client.post( "/backtest", json={"close": close, "strategy": "no_such_strategy"}, ) assert resp.status_code == 422 def test_too_short_series_returns_422(self, client): resp = client.post("/indicators/sma", json={"close": [100.0], "timeperiod": 5}) assert resp.status_code == 422 # =========================================================================== # Benchmark suite sanity # =========================================================================== class TestBenchmarkSuite: def test_canonical_fixture_exists(self): import pathlib fixture = ( pathlib.Path(__file__).parent.parent.parent / "benchmarks" / "fixtures" / "canonical_ohlcv.npz" ) assert fixture.exists(), f"Canonical fixture not found: {fixture}" def test_canonical_fixture_loadable(self): import pathlib fixture = ( pathlib.Path(__file__).parent.parent.parent / "benchmarks" / "fixtures" / "canonical_ohlcv.npz" ) if not fixture.exists(): pytest.skip("Canonical fixture not found") data = np.load(fixture) for key in ["open", "high", "low", "close", "volume"]: assert key in data.files, f"Missing key '{key}' in fixture" assert len(data["close"]) == 2000 def test_benchmark_indicators_run(self): import pathlib fixture = ( pathlib.Path(__file__).parent.parent.parent / "benchmarks" / "fixtures" / "canonical_ohlcv.npz" ) if not fixture.exists(): pytest.skip("Canonical fixture not found") import ferro_ta as ft data = np.load(fixture) close = data["close"] high = data["high"] low = data["low"] out_sma = np.asarray(ft.SMA(close, timeperiod=20)) out_rsi = np.asarray(ft.RSI(close, timeperiod=14)) out_atr = np.asarray(ft.ATR(high, low, close, timeperiod=14)) assert len(out_sma) == len(close) assert len(out_rsi) == len(close) assert len(out_atr) == len(close) # Last value should be finite assert np.isfinite(out_sma[-1]) assert np.isfinite(out_rsi[-1]) assert np.isfinite(out_atr[-1]) # =========================================================================== # Options / IV helpers # =========================================================================== class TestIVRank: def test_basic_shape(self): from ferro_ta.analysis.options import iv_rank iv = _make_close(100) result = iv_rank(iv, window=20) assert result.shape == (100,) def test_warmup_nan(self): from ferro_ta.analysis.options import iv_rank iv = _make_close(50) result = iv_rank(iv, window=10) assert np.all(np.isnan(result[:9])) assert not np.isnan(result[9]) def test_values_in_0_1(self): from ferro_ta.analysis.options import iv_rank iv = _make_close(100) result = iv_rank(iv, window=20) valid = result[~np.isnan(result)] assert np.all(valid >= 0.0) assert np.all(valid <= 1.0) def test_max_value_is_1(self): from ferro_ta.analysis.options import iv_rank # The maximum of a window should produce rank = 1 iv = np.array([10.0, 20.0, 30.0, 40.0, 50.0]) result = iv_rank(iv, window=5) assert result[4] == pytest.approx(1.0) def test_min_value_is_0(self): from ferro_ta.analysis.options import iv_rank iv = np.array([50.0, 40.0, 30.0, 20.0, 10.0]) result = iv_rank(iv, window=5) assert result[4] == pytest.approx(0.0) def test_empty_raises(self): from ferro_ta.analysis.options import iv_rank with pytest.raises(Exception): iv_rank(np.array([]), window=5) def test_window_1(self): from ferro_ta.analysis.options import iv_rank iv = np.array([10.0, 20.0, 30.0]) result = iv_rank(iv, window=1) # With window=1, all values are equal to min=max, so rank=0 assert np.all(result == 0.0) def test_invalid_window_raises(self): from ferro_ta.analysis.options import iv_rank with pytest.raises(Exception): iv_rank(np.array([1.0, 2.0]), window=0) def test_flat_series(self): from ferro_ta.analysis.options import iv_rank iv = np.ones(30) * 25.0 result = iv_rank(iv, window=10) valid = result[~np.isnan(result)] assert np.all(valid == 0.0) class TestIVPercentile: def test_basic_shape(self): from ferro_ta.analysis.options import iv_percentile iv = _make_close(100) result = iv_percentile(iv, window=20) assert result.shape == (100,) def test_warmup_nan(self): from ferro_ta.analysis.options import iv_percentile iv = _make_close(50) result = iv_percentile(iv, window=10) assert np.all(np.isnan(result[:9])) def test_values_in_0_1(self): from ferro_ta.analysis.options import iv_percentile iv = _make_close(100) result = iv_percentile(iv, window=20) valid = result[~np.isnan(result)] assert np.all(valid >= 0.0) assert np.all(valid <= 1.0) def test_empty_raises(self): from ferro_ta.analysis.options import iv_percentile with pytest.raises(Exception): iv_percentile(np.array([]), window=5) def test_known_value(self): from ferro_ta.analysis.options import iv_percentile iv = np.array([10.0, 20.0, 30.0, 15.0, 22.0]) result = iv_percentile(iv, window=3) # At index 2: window=[10,20,30], current=30. All 3 <= 30 → 3/3 = 1.0 assert result[2] == pytest.approx(1.0) # At index 3: window=[20,30,15], current=15. Only 15 <= 15 → 1/3 assert result[3] == pytest.approx(1.0 / 3.0) class TestIVZScore: def test_basic_shape(self): from ferro_ta.analysis.options import iv_zscore iv = _make_close(100) result = iv_zscore(iv, window=20) assert result.shape == (100,) def test_warmup_nan(self): from ferro_ta.analysis.options import iv_zscore iv = _make_close(50) result = iv_zscore(iv, window=10) assert np.all(np.isnan(result[:9])) def test_flat_is_nan(self): from ferro_ta.analysis.options import iv_zscore # Flat series has std=0, so z-score should be NaN iv = np.ones(30) * 20.0 result = iv_zscore(iv, window=10) valid = result[~np.isnan(result)] assert len(valid) == 0 or np.all(np.isnan(valid)) def test_empty_raises(self): from ferro_ta.analysis.options import iv_zscore with pytest.raises(Exception): iv_zscore(np.array([]), window=5) def test_known_value(self): from ferro_ta.analysis.options import iv_zscore iv = np.array([10.0, 20.0, 30.0]) result = iv_zscore(iv, window=3) # mean=20, std=std([10,20,30],ddof=0)=8.165... expected = (30.0 - 20.0) / np.std([10.0, 20.0, 30.0], ddof=0) assert result[2] == pytest.approx(expected, rel=1e-6) # =========================================================================== # Agentic tools and workflow # =========================================================================== class TestComputeIndicator: def test_sma_basic(self): from ferro_ta.tools import compute_indicator close = np.linspace(100, 110, 20) result = compute_indicator("SMA", close, timeperiod=5) assert isinstance(result, np.ndarray) assert result.shape == (20,) def test_rsi_basic(self): from ferro_ta.tools import compute_indicator close = _make_close(100) result = compute_indicator("RSI", close, timeperiod=14) assert isinstance(result, np.ndarray) assert result.shape == (100,) def test_bbands_multi_output(self): from ferro_ta.tools import compute_indicator close = _make_close(50) result = compute_indicator("BBANDS", close, timeperiod=10) assert isinstance(result, dict) assert "upper" in result assert "middle" in result assert "lower" in result def test_macd_multi_output(self): from ferro_ta.tools import compute_indicator close = _make_close(100) result = compute_indicator( "MACD", close, fastperiod=5, slowperiod=10, signalperiod=3 ) assert isinstance(result, dict) assert "macd" in result assert "signal" in result assert "hist" in result def test_unknown_indicator_raises(self): from ferro_ta.tools import compute_indicator with pytest.raises(Exception): compute_indicator("NO_SUCH_INDICATOR", np.ones(20)) class TestRunBacktest: def test_basic_result_shape(self): from ferro_ta.tools import run_backtest close = _make_close(200) summary = run_backtest("rsi_30_70", close) assert isinstance(summary, dict) assert "final_equity" in summary assert "n_trades" in summary assert "n_bars" in summary assert "equity" in summary assert "signals" in summary assert "max_drawdown" in summary assert summary["n_bars"] == 200 assert isinstance(summary["final_equity"], float) def test_equity_list(self): from ferro_ta.tools import run_backtest close = _make_close(100) summary = run_backtest("rsi_30_70", close) assert isinstance(summary["equity"], list) assert len(summary["equity"]) == 100 def test_sma_crossover_strategy(self): from ferro_ta.tools import run_backtest close = _make_close(200) summary = run_backtest("sma_crossover", close, fast=5, slow=20) assert "final_equity" in summary def test_macd_crossover_strategy(self): from ferro_ta.tools import run_backtest close = _make_close(200) summary = run_backtest("macd_crossover", close) assert "final_equity" in summary def test_unknown_strategy_raises(self): from ferro_ta.tools import run_backtest with pytest.raises(Exception): run_backtest("no_such_strategy", _make_close(100)) def test_max_drawdown_non_negative(self): from ferro_ta.tools import run_backtest close = _make_close(200) summary = run_backtest("rsi_30_70", close) assert summary["max_drawdown"] >= 0.0 class TestListIndicators: def test_returns_list(self): from ferro_ta.tools import list_indicators names = list_indicators() assert isinstance(names, list) assert len(names) > 0 def test_contains_sma_rsi(self): from ferro_ta.tools import list_indicators names = list_indicators() assert "SMA" in names assert "RSI" in names def test_sorted(self): from ferro_ta.tools import list_indicators names = list_indicators() assert names == sorted(names) class TestDescribeIndicator: def test_returns_string(self): from ferro_ta.tools import describe_indicator desc = describe_indicator("SMA") assert isinstance(desc, str) assert len(desc) > 0 def test_unknown_raises(self): from ferro_ta.tools import describe_indicator with pytest.raises(Exception): describe_indicator("NO_SUCH_INDICATOR") class TestWorkflow: def test_basic_indicators(self): from ferro_ta.tools.workflow import Workflow close = _make_close(200) result = ( Workflow() .add_indicator("sma_20", "SMA", timeperiod=20) .add_indicator("rsi_14", "RSI", timeperiod=14) .run(close) ) assert "sma_20" in result assert "rsi_14" in result assert result["sma_20"].shape == (200,) assert result["rsi_14"].shape == (200,) def test_with_strategy(self): from ferro_ta.tools.workflow import Workflow close = _make_close(200) result = ( Workflow() .add_indicator("rsi_14", "RSI", timeperiod=14) .add_strategy("rsi_30_70") .run(close) ) assert "backtest" in result assert "final_equity" in result["backtest"] def test_with_alert(self): from ferro_ta.tools.workflow import Workflow close = _make_close(200) result = ( Workflow() .add_indicator("rsi_14", "RSI", timeperiod=14) .add_alert("rsi_14", level=30.0, direction=-1) .run(close) ) assert "rsi_14" in result # Alert key should be present alert_keys = [k for k in result if k.startswith("alert_")] assert len(alert_keys) > 0 def test_empty_workflow(self): from ferro_ta.tools.workflow import Workflow close = _make_close(50) result = Workflow().run(close) assert isinstance(result, dict) assert len(result) == 0 def test_multi_output_indicator(self): from ferro_ta.tools.workflow import Workflow close = _make_close(100) result = Workflow().add_indicator("bb", "BBANDS", timeperiod=10).run(close) assert "bb" in result # BBANDS returns dict from compute_indicator assert isinstance(result["bb"], dict) class TestRunPipeline: def test_basic_pipeline(self): from ferro_ta.tools.workflow import run_pipeline close = _make_close(200) result = run_pipeline( close, indicators={ "sma_20": {"name": "SMA", "timeperiod": 20}, "rsi_14": {"name": "RSI", "timeperiod": 14}, }, ) assert "sma_20" in result assert "rsi_14" in result def test_with_strategy(self): from ferro_ta.tools.workflow import run_pipeline close = _make_close(200) result = run_pipeline( close, indicators={"rsi_14": {"name": "RSI", "timeperiod": 14}}, strategy="rsi_30_70", ) assert "backtest" in result def test_no_indicators(self): from ferro_ta.tools.workflow import run_pipeline close = _make_close(100) result = run_pipeline(close) assert isinstance(result, dict) def test_with_alert(self): from ferro_ta.tools.workflow import run_pipeline close = _make_close(200) result = run_pipeline( close, indicators={"rsi_14": {"name": "RSI", "timeperiod": 14}}, alert_indicator="rsi_14", alert_level=30.0, alert_direction=-1, ) assert "rsi_14" in result alert_keys = [k for k in result if k.startswith("alert_")] assert len(alert_keys) > 0 # =========================================================================== # MCP server # =========================================================================== class TestMCPListTools: def test_list_tools_returns_dict(self): from ferro_ta.mcp import handle_list_tools result = handle_list_tools() assert isinstance(result, dict) assert "tools" in result def test_list_tools_has_required_tools(self): from ferro_ta.mcp import handle_list_tools result = handle_list_tools() names = [t["name"] for t in result["tools"]] assert len(names) > 250 for expected in ( "sma", "ema", "rsi", "macd", "backtest", "SMA", "compute_indicator", "about", "check_cross", "TickAggregator", "call_instance_method", "call_stored_callable", "delete_instance", ): assert expected in names, f"Expected tool '{expected}' not found" def test_each_tool_has_schema(self): from ferro_ta.mcp import handle_list_tools result = handle_list_tools() for tool in result["tools"]: assert "name" in tool assert "description" in tool assert "inputSchema" in tool class TestMCPCallTool: def test_sma_call(self): from ferro_ta.mcp import handle_call_tool close = list(np.linspace(100, 110, 30)) result = handle_call_tool("sma", {"close": close, "timeperiod": 5}) assert "content" in result import json payload = json.loads(result["content"][0]["text"]) assert len(payload) == 30 def test_ema_call(self): from ferro_ta.mcp import handle_call_tool close = list(np.linspace(100, 110, 30)) result = handle_call_tool("ema", {"close": close, "timeperiod": 5}) assert "content" in result def test_rsi_call(self): from ferro_ta.mcp import handle_call_tool close = list(_make_close(50)) result = handle_call_tool("rsi", {"close": close, "timeperiod": 14}) assert "content" in result def test_macd_call(self): import json from ferro_ta.mcp import handle_call_tool close = list(_make_close(100)) result = handle_call_tool("macd", {"close": close}) assert "content" in result payload = json.loads(result["content"][0]["text"]) assert "macd" in payload def test_backtest_call(self): import json from ferro_ta.mcp import handle_call_tool close = list(_make_close(200)) result = handle_call_tool("backtest", {"close": close, "strategy": "rsi_30_70"}) assert "content" in result payload = json.loads(result["content"][0]["text"]) assert "final_equity" in payload assert "n_trades" in payload def test_top_level_sma_call(self): import json from ferro_ta.mcp import handle_call_tool close = list(np.linspace(100, 110, 30)) result = handle_call_tool("SMA", {"close": close, "timeperiod": 5}) payload = json.loads(result["content"][0]["text"]) assert len(payload) == 30 def test_compute_indicator_call(self): import json from ferro_ta.mcp import handle_call_tool close = list(_make_close(100)) result = handle_call_tool( "compute_indicator", { "name": "MACD", "args": [close], }, ) payload = json.loads(result["content"][0]["text"]) assert "macd" in payload def test_about_call(self): import json from ferro_ta.mcp import handle_call_tool result = handle_call_tool("about", {}) payload = json.loads(result["content"][0]["text"]) assert payload["indicator_count"] >= 200 assert payload["method_count"] >= 400 def test_check_cross_call(self): import json from ferro_ta.mcp import handle_call_tool result = handle_call_tool( "check_cross", { "fast": [1.0, 2.0, 3.0, 2.0, 1.0], "slow": [2.0, 2.0, 2.0, 2.0, 2.0], }, ) payload = json.loads(result["content"][0]["text"]) assert len(payload) == 5 def test_list_indicators_call(self): import json from ferro_ta.mcp import handle_call_tool result = handle_call_tool("list_indicators", {}) assert "content" in result payload = json.loads(result["content"][0]["text"]) assert isinstance(payload, list) assert "SMA" in payload def test_describe_indicator_call(self): from ferro_ta.mcp import handle_call_tool result = handle_call_tool("describe_indicator", {"name": "SMA"}) assert "content" in result text = result["content"][0]["text"] assert isinstance(text, str) assert len(text) > 0 def test_unknown_tool_returns_error(self): from ferro_ta.mcp import handle_call_tool result = handle_call_tool("no_such_tool", {}) assert result.get("isError") is True def test_tool_error_handling(self): from ferro_ta.mcp import handle_call_tool # Pass an invalid series to trigger an error result = handle_call_tool("sma", {"close": [], "timeperiod": 5}) # Should return error content, not raise assert "content" in result or "isError" in result def test_backtest_unknown_strategy(self): from ferro_ta.mcp import handle_call_tool close = list(_make_close(100)) result = handle_call_tool( "backtest", {"close": close, "strategy": "no_strategy"} ) assert result.get("isError") is True or "content" in result def test_tick_aggregator_instance_lifecycle(self): import json from ferro_ta.mcp import handle_call_tool created = json.loads( handle_call_tool("TickAggregator", {"rule": "tick:2"})["content"][0]["text"] ) instance_id = created["instance_id"] described = json.loads( handle_call_tool("describe_instance", {"instance_id": instance_id})[ "content" ][0]["text"] ) method_names = [item["name"] for item in described["methods"]] assert "aggregate" in method_names aggregated = json.loads( handle_call_tool( "call_instance_method", { "instance_id": instance_id, "method": "aggregate", "args": [ { "price": [1.0, 2.0, 3.0, 4.0], "size": [1.0, 1.0, 1.0, 1.0], } ], }, )["content"][0]["text"] ) assert "open" in aggregated assert "close" in aggregated deleted = json.loads( handle_call_tool("delete_instance", {"instance_id": instance_id})[ "content" ][0]["text"] ) assert deleted["deleted"] is True def test_stored_callable_can_be_invoked(self): import json from ferro_ta.mcp import handle_call_tool wrapped = json.loads( handle_call_tool("traced", {"func": {"callable": "SMA"}})["content"][0][ "text" ] ) instance_id = wrapped["instance_id"] called = json.loads( handle_call_tool( "call_stored_callable", { "instance_id": instance_id, "args": [[1.0, 2.0, 3.0, 4.0, 5.0]], "kwargs": {"timeperiod": 3}, }, )["content"][0]["text"] ) assert len(called) == 5 handle_call_tool("delete_instance", {"instance_id": instance_id}) def test_benchmark_accepts_callable_reference(self): import json from ferro_ta.mcp import handle_call_tool result = handle_call_tool( "benchmark", { "func": {"callable": "SMA"}, "args": [[1.0, 2.0, 3.0, 4.0, 5.0]], "kwargs": {"timeperiod": 3}, "n": 2, "warmup": 0, }, ) payload = json.loads(result["content"][0]["text"]) assert payload["n"] == 2.0 assert "mean_ms" in payload class TestMCPServer: def test_create_server_requires_mcp_dependency(self, monkeypatch): import ferro_ta.mcp as mcp_mod real_import_module = importlib.import_module def fake_import_module(name, package=None): if name.startswith("mcp"): raise ImportError("No module named 'mcp'") return real_import_module(name, package) mcp_mod.create_server.cache_clear() monkeypatch.setattr(importlib, "import_module", fake_import_module) with pytest.raises(RuntimeError, match='pip install "ferro-ta\\[mcp\\]"'): mcp_mod.create_server() def test_main_entrypoint_invokes_run_server(self, monkeypatch): import ferro_ta.mcp as mcp_mod calls: list[str] = [] monkeypatch.setattr(mcp_mod, "run_server", lambda: calls.append("called")) runpy.run_module("ferro_ta.mcp.__main__", run_name="__main__") assert calls == ["called"] def test_create_server_registers_generated_tools(self): import ferro_ta.mcp as mcp_mod server = mcp_mod.create_server() tool_names = [tool.name for tool in server._tool_manager.list_tools()] assert "SMA" in tool_names assert "TickAggregator" in tool_names assert "call_instance_method" in tool_names