"""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
