"""Additional tests to improve code coverage across all ferro_ta modules.

These tests target previously uncovered code paths including:
- Error-handling branches in indicator wrappers (except ValueError blocks)
- Utility helpers with untested code paths
- Module-level imports and edge cases
"""

from __future__ import annotations

import sys
from unittest.mock import patch

import numpy as np
import pytest

# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------

RNG = np.random.default_rng(42)
CLOSE = np.cumprod(1 + RNG.normal(0, 0.01, 200)) * 100.0
HIGH = CLOSE * RNG.uniform(1.001, 1.01, 200)
LOW = CLOSE * RNG.uniform(0.99, 0.999, 200)
OPEN = CLOSE * RNG.uniform(0.999, 1.001, 200)
VOLUME = RNG.uniform(500, 5000, 200)

# 2D array — triggers ValueError("Input must be a 1-D array") in _to_f64
_2D = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]])


# ===========================================================================
# _utils.py — uncovered paths
# ===========================================================================


class TestUtilsUncovered:
    """Cover uncovered paths in _utils.py."""

    def test_to_f64_pandas_series(self):
        """Line 43: pandas Series path in _to_f64."""
        pd = pytest.importorskip("pandas")
        from ferro_ta._utils import _to_f64

        s = pd.Series([1.0, 2.0, 3.0])
        result = _to_f64(s)
        assert result.dtype == np.float64
        np.testing.assert_array_equal(result, [1.0, 2.0, 3.0])

    def test_to_f64_polars_series(self):
        """Lines 47-50: polars Series path in _to_f64."""
        pl = pytest.importorskip("polars")
        from ferro_ta._utils import _to_f64

        s = pl.Series("close", [1.0, 2.0, 3.0])
        result = _to_f64(s)
        assert result.dtype == np.float64
        assert len(result) == 3

    def test_get_ohlcv_non_dataframe_raises(self):
        """Lines 100-101: get_ohlcv raises TypeError for non-DataFrame."""
        pytest.importorskip("pandas")
        from ferro_ta._utils import get_ohlcv

        with pytest.raises(TypeError, match="pandas.DataFrame"):
            get_ohlcv({"not": "a dataframe"})

    def test_get_ohlcv_missing_column_raises(self):
        """Line 104: get_ohlcv raises KeyError for missing column."""
        pd = pytest.importorskip("pandas")
        from ferro_ta._utils import get_ohlcv

        df = pd.DataFrame({"open": [1.0], "high": [1.1], "low": [0.9], "close": [1.0]})
        # Pass a custom close_col that doesn't exist → raises KeyError
        with pytest.raises(KeyError):
            get_ohlcv(df, close_col="nonexistent_col")

    def test_get_ohlcv_none_volume_col(self):
        """Lines 108-110: volume_col=None returns NaN array."""
        pd = pytest.importorskip("pandas")
        from ferro_ta._utils import get_ohlcv

        df = pd.DataFrame(
            {
                "open": [1.0, 2.0],
                "high": [1.1, 2.1],
                "low": [0.9, 1.9],
                "close": [1.0, 2.0],
            }
        )
        o, h, l, c, v = get_ohlcv(
            df,
            open_col="open",
            high_col="high",
            low_col="low",
            close_col="close",
            volume_col=None,
        )
        assert np.all(np.isnan(v))

    def test_pandas_wrap_dataframe_single_col(self):
        """Lines 160-161: pandas_wrap handles single-column DataFrame."""
        pd = pytest.importorskip("pandas")
        from ferro_ta import SMA

        df = pd.DataFrame({"close": CLOSE[:50]})
        result = SMA(df, timeperiod=5)
        assert isinstance(result, (pd.Series, np.ndarray))

    def test_pandas_wrap_tuple_output(self):
        """Lines 172-178: pandas_wrap wraps tuple output in Series."""
        pd = pytest.importorskip("pandas")
        from ferro_ta import BBANDS

        s = pd.Series(CLOSE[:50])
        upper, mid, lower = BBANDS(s, timeperiod=5)
        assert isinstance(upper, pd.Series)
        assert isinstance(mid, pd.Series)
        assert isinstance(lower, pd.Series)

    def test_polars_wrap_tuple_output(self):
        """Lines 233-234: polars_wrap wraps tuple output."""
        pl = pytest.importorskip("polars")
        from ferro_ta import BBANDS

        s = pl.Series("close", CLOSE[:50].tolist())
        upper, mid, lower = BBANDS(s, timeperiod=5)
        assert isinstance(upper, pl.Series)

    def test_polars_wrap_single_output(self):
        """Line 246: polars_wrap wraps single ndarray output."""
        pl = pytest.importorskip("polars")
        from ferro_ta import SMA

        s = pl.Series("close", CLOSE[:50].tolist())
        result = SMA(s, timeperiod=5)
        assert isinstance(result, pl.Series)

    def test_to_f64_polars_cast_exception_fallback(self):
        """Lines 49-50: polars Series with cast exception falls back to to_list."""
        from ferro_ta._utils import _to_f64

        # Create a mock that simulates a polars-like Series:
        # - no 'to_numpy' attribute (so the pandas path is skipped)
        # - has 'to_list()' method
        # - type name is 'Series' (polars Series match condition)
        # - has 'cast()' that raises an exception
        class _FakePolars:
            def to_list(self):
                return [1.0, 2.0, 3.0]

            def cast(self, *args, **kwargs):
                raise Exception("cast failed")

        _FakePolars.__name__ = "Series"

        result = _to_f64(_FakePolars())
        assert result.dtype == np.float64
        np.testing.assert_array_equal(result, [1.0, 2.0, 3.0])


# ===========================================================================
# _binding.py — full coverage
# ===========================================================================


class TestBindingCall:
    """Cover binding_call in _binding.py."""

    def test_basic_call_success(self):
        """Basic binding_call with timeperiod validation."""
        from ferro_ta._ferro_ta import sma as _sma

        from ferro_ta._binding import binding_call

        result = binding_call(
            _sma,
            array_params=["close"],
            timeperiod_param="timeperiod",
            close=CLOSE,
            timeperiod=5,
        )
        assert len(result) == len(CLOSE)

    def test_timeperiod_validation_raises(self):
        """binding_call raises FerroTAValueError for invalid timeperiod."""
        from ferro_ta._ferro_ta import sma as _sma

        from ferro_ta._binding import binding_call
        from ferro_ta.core.exceptions import FerroTAValueError

        with pytest.raises(FerroTAValueError):
            binding_call(
                _sma,
                array_params=["close"],
                timeperiod_param="timeperiod",
                close=CLOSE,
                timeperiod=0,
            )

    def test_equal_length_validation_raises(self):
        """binding_call raises FerroTAInputError for mismatched lengths."""
        from ferro_ta._ferro_ta import atr as _atr

        from ferro_ta._binding import binding_call
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            binding_call(
                _atr,
                array_params=["high", "low", "close"],
                equal_length_groups=[["high", "low", "close"]],
                timeperiod_param="timeperiod",
                high=HIGH,
                low=LOW[:10],  # mismatched
                close=CLOSE,
                timeperiod=14,
            )

    def test_rust_error_normalization(self):
        """binding_call normalizes Rust ValueError via _normalize_rust_error."""
        from ferro_ta._binding import binding_call
        from ferro_ta.core.exceptions import FerroTAValueError

        # Use a function that will raise ValueError from Rust (invalid timeperiod)
        def bad_fn(*args, **kwargs):
            raise ValueError("timeperiod must be >= 1")

        with pytest.raises(FerroTAValueError):
            binding_call(bad_fn, array_params=["close"], close=CLOSE)

    def test_no_timeperiod_param(self):
        """binding_call without timeperiod_param skips timeperiod check."""
        from ferro_ta._ferro_ta import sma as _sma

        from ferro_ta._binding import binding_call

        result = binding_call(_sma, array_params=["close"], close=CLOSE, timeperiod=5)
        assert len(result) == len(CLOSE)


# ===========================================================================
# raw.py — import coverage
# ===========================================================================


class TestRawImport:
    """Import ferro_ta.raw to cover the re-export statements."""

    def test_raw_import(self):
        """Importing ferro_ta.raw covers the re-export lines."""
        import ferro_ta.core.raw as raw

        assert hasattr(raw, "sma")
        assert hasattr(raw, "ema")
        assert hasattr(raw, "rsi")
        assert hasattr(raw, "batch_sma")

    def test_raw_sma(self):
        """ferro_ta.raw.sma works directly."""
        from ferro_ta.core.raw import sma

        result = sma(CLOSE, 5)
        assert len(result) == len(CLOSE)


# ===========================================================================
# mcp/__main__.py — import coverage
# ===========================================================================


class TestMCPMain:
    """Import ferro_ta.mcp.__main__ to cover module-level lines."""

    def test_main_import(self):
        """Importing mcp.__main__ covers lines 3-4."""
        import importlib

        mod = importlib.import_module("ferro_ta.mcp.__main__")
        assert hasattr(mod, "run_server")


# ===========================================================================
# cycle.py — error-path coverage
# ===========================================================================


class TestCycleErrorPaths:
    """Cover except ValueError branches in cycle.py."""

    def test_ht_trendline_2d_raises(self):
        from ferro_ta import HT_TRENDLINE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            HT_TRENDLINE(_2D)

    def test_ht_dcperiod_2d_raises(self):
        from ferro_ta import HT_DCPERIOD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            HT_DCPERIOD(_2D)

    def test_ht_dcphase_2d_raises(self):
        from ferro_ta import HT_DCPHASE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            HT_DCPHASE(_2D)

    def test_ht_phasor_2d_raises(self):
        from ferro_ta import HT_PHASOR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            HT_PHASOR(_2D)

    def test_ht_sine_2d_raises(self):
        from ferro_ta import HT_SINE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            HT_SINE(_2D)

    def test_ht_trendmode_2d_raises(self):
        from ferro_ta import HT_TRENDMODE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            HT_TRENDMODE(_2D)


# ===========================================================================
# statistic.py — error-path coverage
# ===========================================================================


class TestStatisticErrorPaths:
    """Cover except ValueError branches in statistic.py."""

    def test_stddev_2d_raises(self):
        from ferro_ta import STDDEV
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            STDDEV(_2D)

    def test_var_2d_raises(self):
        from ferro_ta import VAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            VAR(_2D)

    def test_linearreg_2d_raises(self):
        from ferro_ta import LINEARREG
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            LINEARREG(_2D)

    def test_linearreg_slope_2d_raises(self):
        from ferro_ta import LINEARREG_SLOPE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            LINEARREG_SLOPE(_2D)

    def test_linearreg_intercept_2d_raises(self):
        from ferro_ta import LINEARREG_INTERCEPT
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            LINEARREG_INTERCEPT(_2D)

    def test_linearreg_angle_2d_raises(self):
        from ferro_ta import LINEARREG_ANGLE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            LINEARREG_ANGLE(_2D)

    def test_tsf_2d_raises(self):
        from ferro_ta import TSF
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            TSF(_2D)

    def test_beta_2d_raises(self):
        from ferro_ta import BETA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            BETA(_2D, CLOSE)

    def test_correl_2d_raises(self):
        from ferro_ta import CORREL
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CORREL(_2D, CLOSE)


# ===========================================================================
# overlap.py — error-path coverage
# ===========================================================================


class TestOverlapErrorPaths:
    """Cover except ValueError branches in overlap.py."""

    def test_sma_2d_raises(self):
        from ferro_ta import SMA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            SMA(_2D)

    def test_ema_2d_raises(self):
        from ferro_ta import EMA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            EMA(_2D)

    def test_wma_2d_raises(self):
        from ferro_ta import WMA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            WMA(_2D)

    def test_trima_2d_raises(self):
        from ferro_ta import TRIMA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            TRIMA(_2D)

    def test_kama_2d_raises(self):
        from ferro_ta import KAMA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            KAMA(_2D)

    def test_t3_2d_raises(self):
        from ferro_ta import T3
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            T3(_2D)

    def test_bbands_2d_raises(self):
        from ferro_ta import BBANDS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            BBANDS(_2D)

    def test_macd_2d_raises(self):
        from ferro_ta import MACD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MACD(_2D)

    def test_macdfix_2d_raises(self):
        from ferro_ta import MACDFIX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MACDFIX(_2D)

    def test_sar_2d_raises(self):
        from ferro_ta import SAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            SAR(_2D, LOW)

    def test_midpoint_2d_raises(self):
        from ferro_ta import MIDPOINT
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MIDPOINT(_2D)

    def test_midprice_2d_raises(self):
        from ferro_ta import MIDPRICE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MIDPRICE(_2D, LOW)

    def test_mama_2d_raises(self):
        from ferro_ta import MAMA
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MAMA(_2D)

    def test_sarext_2d_raises(self):
        from ferro_ta import SAREXT
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            SAREXT(_2D, LOW)

    def test_macdext_2d_raises(self):
        from ferro_ta import MACDEXT
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MACDEXT(_2D)


# ===========================================================================
# momentum.py — error-path coverage
# ===========================================================================


class TestMomentumErrorPaths:
    """Cover except ValueError branches in momentum.py."""

    def test_rsi_2d_raises(self):
        from ferro_ta import RSI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            RSI(_2D)

    def test_mom_2d_raises(self):
        from ferro_ta import MOM
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MOM(_2D)

    def test_roc_2d_raises(self):
        from ferro_ta import ROC
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ROC(_2D)

    def test_rocp_2d_raises(self):
        from ferro_ta import ROCP
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ROCP(_2D)

    def test_rocr_2d_raises(self):
        from ferro_ta import ROCR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ROCR(_2D)

    def test_rocr100_2d_raises(self):
        from ferro_ta import ROCR100
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ROCR100(_2D)

    def test_willr_2d_raises(self):
        from ferro_ta import WILLR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            WILLR(_2D, LOW, CLOSE)

    def test_adx_2d_raises(self):
        from ferro_ta import ADX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ADX(_2D, LOW, CLOSE)

    def test_adxr_2d_raises(self):
        from ferro_ta import ADXR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ADXR(_2D, LOW, CLOSE)

    def test_apo_2d_raises(self):
        from ferro_ta import APO
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            APO(_2D)

    def test_ppo_2d_raises(self):
        from ferro_ta import PPO
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            PPO(_2D)

    def test_cci_2d_raises(self):
        from ferro_ta import CCI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CCI(_2D, LOW, CLOSE)

    def test_mfi_2d_raises(self):
        from ferro_ta import MFI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MFI(_2D, LOW, CLOSE, VOLUME)

    def test_bop_2d_raises(self):
        from ferro_ta import BOP
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            BOP(_2D, HIGH, LOW, CLOSE)

    def test_stochf_2d_raises(self):
        from ferro_ta import STOCHF
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            STOCHF(_2D, LOW, CLOSE)

    def test_stoch_2d_raises(self):
        from ferro_ta import STOCH
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            STOCH(_2D, LOW, CLOSE)

    def test_stochrsi_2d_raises(self):
        from ferro_ta import STOCHRSI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            STOCHRSI(_2D)

    def test_ultosc_2d_raises(self):
        from ferro_ta import ULTOSC
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ULTOSC(_2D, LOW, CLOSE)

    def test_dx_2d_raises(self):
        from ferro_ta import DX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            DX(_2D, LOW, CLOSE)

    def test_plus_di_2d_raises(self):
        from ferro_ta import PLUS_DI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            PLUS_DI(_2D, LOW, CLOSE)

    def test_minus_di_2d_raises(self):
        from ferro_ta import MINUS_DI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MINUS_DI(_2D, LOW, CLOSE)

    def test_plus_dm_2d_raises(self):
        from ferro_ta import PLUS_DM
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            PLUS_DM(_2D, LOW)

    def test_minus_dm_2d_raises(self):
        from ferro_ta import MINUS_DM
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MINUS_DM(_2D, LOW)

    def test_cmo_2d_raises(self):
        from ferro_ta import CMO
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CMO(_2D)

    def test_aroon_2d_raises(self):
        from ferro_ta import AROON
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            AROON(_2D, LOW)

    def test_aroonosc_2d_raises(self):
        from ferro_ta import AROONOSC
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            AROONOSC(_2D, LOW)

    def test_trix_2d_raises(self):
        from ferro_ta import TRIX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            TRIX(_2D)


# ===========================================================================
# price_transform.py — error-path coverage
# ===========================================================================


class TestPriceTransformErrorPaths:
    """Cover except ValueError branches in price_transform.py."""

    def test_avgprice_2d_raises(self):
        from ferro_ta import AVGPRICE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            AVGPRICE(_2D, HIGH, LOW, CLOSE)

    def test_medprice_2d_raises(self):
        from ferro_ta import MEDPRICE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MEDPRICE(_2D, LOW)

    def test_typprice_2d_raises(self):
        from ferro_ta import TYPPRICE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            TYPPRICE(_2D, LOW, CLOSE)

    def test_wclprice_2d_raises(self):
        from ferro_ta import WCLPRICE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            WCLPRICE(_2D, LOW, CLOSE)


# ===========================================================================
# volume.py — error-path coverage
# ===========================================================================


class TestVolumeErrorPaths:
    """Cover except ValueError branches in volume.py."""

    def test_ad_2d_raises(self):
        from ferro_ta import AD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            AD(_2D, LOW, CLOSE, VOLUME)

    def test_adosc_2d_raises(self):
        from ferro_ta import ADOSC
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ADOSC(_2D, LOW, CLOSE, VOLUME)

    def test_obv_2d_raises(self):
        from ferro_ta import OBV
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            OBV(_2D, VOLUME)


# ===========================================================================
# volatility.py — error-path coverage
# ===========================================================================


class TestVolatilityErrorPaths:
    """Cover except ValueError branches in volatility.py."""

    def test_atr_2d_raises(self):
        from ferro_ta import ATR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ATR(_2D, LOW, CLOSE)

    def test_natr_2d_raises(self):
        from ferro_ta import NATR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            NATR(_2D, LOW, CLOSE)


# ===========================================================================
# math_ops.py — error-path coverage
# ===========================================================================


class TestMathOpsErrorPaths:
    """Cover except ValueError branches in math_ops.py."""

    def test_add_2d_raises(self):
        from ferro_ta import ADD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            ADD(_2D, CLOSE)

    def test_sub_2d_raises(self):
        from ferro_ta import SUB
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            SUB(_2D, CLOSE)

    def test_mult_2d_raises(self):
        from ferro_ta import MULT
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MULT(_2D, CLOSE)

    def test_div_2d_raises(self):
        from ferro_ta import DIV
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            DIV(_2D, CLOSE)

    def test_sum_2d_raises(self):
        from ferro_ta import SUM
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            SUM(_2D)

    def test_max_2d_raises(self):
        from ferro_ta import MAX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MAX(_2D)

    def test_min_2d_raises(self):
        from ferro_ta import MIN
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MIN(_2D)

    def test_maxindex_2d_raises(self):
        from ferro_ta import MAXINDEX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MAXINDEX(_2D)

    def test_minindex_2d_raises(self):
        from ferro_ta import MININDEX
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            MININDEX(_2D)


# ===========================================================================
# pattern.py — error-path coverage (sample of CDL functions)
# ===========================================================================


class TestPatternErrorPaths:
    """Cover except ValueError branches in pattern.py for CDL functions."""

    def _ohlc(self):
        return OPEN, HIGH, LOW, CLOSE

    def test_cdl2crows_2d_raises(self):
        from ferro_ta import CDL2CROWS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL2CROWS(_2D, HIGH, LOW, CLOSE)

    def test_cdldoji_2d_raises(self):
        from ferro_ta import CDLDOJI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLDOJI(_2D, HIGH, LOW, CLOSE)

    def test_cdl3blackcrows_2d_raises(self):
        from ferro_ta import CDL3BLACKCROWS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL3BLACKCROWS(_2D, HIGH, LOW, CLOSE)

    def test_cdl3inside_2d_raises(self):
        from ferro_ta import CDL3INSIDE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL3INSIDE(_2D, HIGH, LOW, CLOSE)

    def test_cdlengulfing_2d_raises(self):
        from ferro_ta import CDLENGULFING
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLENGULFING(_2D, HIGH, LOW, CLOSE)

    def test_cdlhammer_2d_raises(self):
        from ferro_ta import CDLHAMMER
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHAMMER(_2D, HIGH, LOW, CLOSE)

    def test_cdlmarubozu_2d_raises(self):
        from ferro_ta import CDLMARUBOZU
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLMARUBOZU(_2D, HIGH, LOW, CLOSE)

    def test_cdlmorningstar_2d_raises(self):
        from ferro_ta import CDLMORNINGSTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLMORNINGSTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdleveningstar_2d_raises(self):
        from ferro_ta import CDLEVENINGSTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLEVENINGSTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdlshootingstar_2d_raises(self):
        from ferro_ta import CDLSHOOTINGSTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLSHOOTINGSTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdlharami_2d_raises(self):
        from ferro_ta import CDLHARAMI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHARAMI(_2D, HIGH, LOW, CLOSE)

    def test_cdldojistar_2d_raises(self):
        from ferro_ta import CDLDOJISTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLDOJISTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdlspinningtop_2d_raises(self):
        from ferro_ta import CDLSPINNINGTOP
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLSPINNINGTOP(_2D, HIGH, LOW, CLOSE)

    def test_cdlkicking_2d_raises(self):
        from ferro_ta import CDLKICKING
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLKICKING(_2D, HIGH, LOW, CLOSE)

    def test_cdlpiercing_2d_raises(self):
        from ferro_ta import CDLPIERCING
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLPIERCING(_2D, HIGH, LOW, CLOSE)

    def test_cdl3whitesoldiers_2d_raises(self):
        from ferro_ta import CDL3WHITESOLDIERS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL3WHITESOLDIERS(_2D, HIGH, LOW, CLOSE)

    def test_cdl3outside_2d_raises(self):
        from ferro_ta import CDL3OUTSIDE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL3OUTSIDE(_2D, HIGH, LOW, CLOSE)

    def test_cdlmorningdojistar_2d_raises(self):
        from ferro_ta import CDLMORNINGDOJISTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLMORNINGDOJISTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdleveningdojistar_2d_raises(self):
        from ferro_ta import CDLEVENINGDOJISTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLEVENINGDOJISTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdlharamicross_2d_raises(self):
        from ferro_ta import CDLHARAMICROSS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHARAMICROSS(_2D, HIGH, LOW, CLOSE)

    def test_cdl3linestrike_2d_raises(self):
        from ferro_ta import CDL3LINESTRIKE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL3LINESTRIKE(_2D, HIGH, LOW, CLOSE)

    def test_cdl3starsinsouth_2d_raises(self):
        from ferro_ta import CDL3STARSINSOUTH
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDL3STARSINSOUTH(_2D, HIGH, LOW, CLOSE)

    def test_cdlabandonedbaby_2d_raises(self):
        from ferro_ta import CDLABANDONEDBABY
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLABANDONEDBABY(_2D, HIGH, LOW, CLOSE)

    def test_cdladvanceblock_2d_raises(self):
        from ferro_ta import CDLADVANCEBLOCK
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLADVANCEBLOCK(_2D, HIGH, LOW, CLOSE)

    def test_cdlbelthold_2d_raises(self):
        from ferro_ta import CDLBELTHOLD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLBELTHOLD(_2D, HIGH, LOW, CLOSE)

    def test_cdlbreakaway_2d_raises(self):
        from ferro_ta import CDLBREAKAWAY
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLBREAKAWAY(_2D, HIGH, LOW, CLOSE)

    def test_cdlclosingmarubozu_2d_raises(self):
        from ferro_ta import CDLCLOSINGMARUBOZU
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLCLOSINGMARUBOZU(_2D, HIGH, LOW, CLOSE)

    def test_cdlconcealbabyswall_2d_raises(self):
        from ferro_ta import CDLCONCEALBABYSWALL
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLCONCEALBABYSWALL(_2D, HIGH, LOW, CLOSE)

    def test_cdlcounterattack_2d_raises(self):
        from ferro_ta import CDLCOUNTERATTACK
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLCOUNTERATTACK(_2D, HIGH, LOW, CLOSE)

    def test_cdldarkcloudcover_2d_raises(self):
        from ferro_ta import CDLDARKCLOUDCOVER
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLDARKCLOUDCOVER(_2D, HIGH, LOW, CLOSE)

    def test_cdldragonflydoji_2d_raises(self):
        from ferro_ta import CDLDRAGONFLYDOJI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLDRAGONFLYDOJI(_2D, HIGH, LOW, CLOSE)

    def test_cdlgapsidesidewhite_2d_raises(self):
        from ferro_ta import CDLGAPSIDESIDEWHITE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLGAPSIDESIDEWHITE(_2D, HIGH, LOW, CLOSE)

    def test_cdlgravestonedoji_2d_raises(self):
        from ferro_ta import CDLGRAVESTONEDOJI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLGRAVESTONEDOJI(_2D, HIGH, LOW, CLOSE)

    def test_cdlhangingman_2d_raises(self):
        from ferro_ta import CDLHANGINGMAN
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHANGINGMAN(_2D, HIGH, LOW, CLOSE)

    def test_cdlhighwave_2d_raises(self):
        from ferro_ta import CDLHIGHWAVE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHIGHWAVE(_2D, HIGH, LOW, CLOSE)

    def test_cdlhikkake_2d_raises(self):
        from ferro_ta import CDLHIKKAKE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHIKKAKE(_2D, HIGH, LOW, CLOSE)

    def test_cdlhikkakemod_2d_raises(self):
        from ferro_ta import CDLHIKKAKEMOD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHIKKAKEMOD(_2D, HIGH, LOW, CLOSE)

    def test_cdlhomingpigeon_2d_raises(self):
        from ferro_ta import CDLHOMINGPIGEON
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLHOMINGPIGEON(_2D, HIGH, LOW, CLOSE)

    def test_cdlidentical3crows_2d_raises(self):
        from ferro_ta import CDLIDENTICAL3CROWS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLIDENTICAL3CROWS(_2D, HIGH, LOW, CLOSE)

    def test_cdlinneck_2d_raises(self):
        from ferro_ta import CDLINNECK
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLINNECK(_2D, HIGH, LOW, CLOSE)

    def test_cdlinvertedhammer_2d_raises(self):
        from ferro_ta import CDLINVERTEDHAMMER
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLINVERTEDHAMMER(_2D, HIGH, LOW, CLOSE)

    def test_cdlkickingbylength_2d_raises(self):
        from ferro_ta import CDLKICKINGBYLENGTH
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLKICKINGBYLENGTH(_2D, HIGH, LOW, CLOSE)

    def test_cdlladderbottom_2d_raises(self):
        from ferro_ta import CDLLADDERBOTTOM
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLLADDERBOTTOM(_2D, HIGH, LOW, CLOSE)

    def test_cdllongleggeddoji_2d_raises(self):
        from ferro_ta import CDLLONGLEGGEDDOJI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLLONGLEGGEDDOJI(_2D, HIGH, LOW, CLOSE)

    def test_cdllongline_2d_raises(self):
        from ferro_ta import CDLLONGLINE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLLONGLINE(_2D, HIGH, LOW, CLOSE)

    def test_cdlmatchinglow_2d_raises(self):
        from ferro_ta import CDLMATCHINGLOW
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLMATCHINGLOW(_2D, HIGH, LOW, CLOSE)

    def test_cdlmathold_2d_raises(self):
        from ferro_ta import CDLMATHOLD
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLMATHOLD(_2D, HIGH, LOW, CLOSE)

    def test_cdlonneck_2d_raises(self):
        from ferro_ta import CDLONNECK
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLONNECK(_2D, HIGH, LOW, CLOSE)

    def test_cdlrickshawman_2d_raises(self):
        from ferro_ta import CDLRICKSHAWMAN
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLRICKSHAWMAN(_2D, HIGH, LOW, CLOSE)

    def test_cdlrisefall3methods_2d_raises(self):
        from ferro_ta import CDLRISEFALL3METHODS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLRISEFALL3METHODS(_2D, HIGH, LOW, CLOSE)

    def test_cdlseparatinglines_2d_raises(self):
        from ferro_ta import CDLSEPARATINGLINES
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLSEPARATINGLINES(_2D, HIGH, LOW, CLOSE)

    def test_cdlshortline_2d_raises(self):
        from ferro_ta import CDLSHORTLINE
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLSHORTLINE(_2D, HIGH, LOW, CLOSE)

    def test_cdlstalledpattern_2d_raises(self):
        from ferro_ta import CDLSTALLEDPATTERN
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLSTALLEDPATTERN(_2D, HIGH, LOW, CLOSE)

    def test_cdlsticksandwich_2d_raises(self):
        from ferro_ta import CDLSTICKSANDWICH
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLSTICKSANDWICH(_2D, HIGH, LOW, CLOSE)

    def test_cdltakuri_2d_raises(self):
        from ferro_ta import CDLTAKURI
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLTAKURI(_2D, HIGH, LOW, CLOSE)

    def test_cdltasukigap_2d_raises(self):
        from ferro_ta import CDLTASUKIGAP
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLTASUKIGAP(_2D, HIGH, LOW, CLOSE)

    def test_cdlthrusting_2d_raises(self):
        from ferro_ta import CDLTHRUSTING
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLTHRUSTING(_2D, HIGH, LOW, CLOSE)

    def test_cdltristar_2d_raises(self):
        from ferro_ta import CDLTRISTAR
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLTRISTAR(_2D, HIGH, LOW, CLOSE)

    def test_cdlunique3river_2d_raises(self):
        from ferro_ta import CDLUNIQUE3RIVER
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLUNIQUE3RIVER(_2D, HIGH, LOW, CLOSE)

    def test_cdlupsidegap2crows_2d_raises(self):
        from ferro_ta import CDLUPSIDEGAP2CROWS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLUPSIDEGAP2CROWS(_2D, HIGH, LOW, CLOSE)

    def test_cdlxsidegap3methods_2d_raises(self):
        from ferro_ta import CDLXSIDEGAP3METHODS
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            CDLXSIDEGAP3METHODS(_2D, HIGH, LOW, CLOSE)


# ===========================================================================
# exceptions.py — uncovered paths
# ===========================================================================


class TestExceptionsUncovered:
    """Cover uncovered lines in exceptions.py."""

    def test_check_equal_length_with_shape_attr(self):
        """Line 107-108: check_equal_length with object having .shape attr."""
        from ferro_ta.core.exceptions import check_equal_length

        class FakeArr:
            shape = (3,)

        arr = FakeArr()
        # Should not raise when both have same length via shape attribute
        check_equal_length(a=arr, b=arr)

    def test_check_equal_length_mismatched_shapes(self):
        """Lines 110-114: check_equal_length raises with mismatched .shape."""
        from ferro_ta.core.exceptions import FerroTAInputError, check_equal_length

        class FakeArr:
            def __init__(self, n):
                self.shape = (n,)

        with pytest.raises(FerroTAInputError, match="same length"):
            check_equal_length(a=FakeArr(3), b=FakeArr(5))

    def test_check_min_length_with_shape_attr(self):
        """Lines 167-168: check_min_length with .shape attribute."""
        from ferro_ta.core.exceptions import FerroTAInputError, check_min_length

        class FakeArr:
            shape = (2,)

        arr = FakeArr()
        # Should raise since len = 2 < min_len = 5
        with pytest.raises(FerroTAInputError, match="at least 5 elements"):
            check_min_length(arr, 5, name="input")


# ===========================================================================
# extended.py — uncovered path
# ===========================================================================


class TestExtendedUncovered:
    """Cover uncovered lines in extended.py."""

    def test_vwap_negative_timeperiod_raises(self):
        """Lines 107-109: VWAP raises FerroTAValueError for negative timeperiod."""
        from ferro_ta import VWAP
        from ferro_ta.core.exceptions import FerroTAValueError

        with pytest.raises(FerroTAValueError, match="timeperiod must be >= 0"):
            VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=-1)


# ===========================================================================
# options.py — uncovered path
# ===========================================================================


class TestOptionsUncovered:
    """Cover uncovered line in options.py."""

    def test_validate_iv_2d_raises(self):
        """Line 63: _validate_iv raises for 2D input."""
        from ferro_ta.analysis.options import iv_rank
        from ferro_ta.core.exceptions import FerroTAInputError

        with pytest.raises(FerroTAInputError):
            iv_rank(np.array([[0.2, 0.3]]), window=5)


# ===========================================================================
# adapters.py — uncovered paths
# ===========================================================================


class TestAdaptersUncovered:
    """Cover uncovered lines in adapters.py."""

    def test_register_non_subclass_raises(self):
        """Line 82: register_adapter raises TypeError for non-subclass."""
        from ferro_ta.data.adapters import register_adapter

        with pytest.raises(TypeError):
            register_adapter("bad", int)

    def test_dataadapter_repr(self):
        """Line 134: DataAdapter __repr__."""
        from ferro_ta.data.adapters import InMemoryAdapter

        adapter = InMemoryAdapter({"close": CLOSE})
        assert "InMemoryAdapter" in repr(adapter)

    def test_inmemory_adapter_fetch(self):
        """Lines 263: InMemoryAdapter.fetch returns wrapped data."""
        from ferro_ta.data.adapters import InMemoryAdapter

        data = {"close": CLOSE, "high": HIGH}
        adapter = InMemoryAdapter(data)
        result = adapter.fetch()
        assert result is data

    def test_csvadapter_repr(self):
        """Line 225: CsvAdapter __repr__."""
        from ferro_ta.data.adapters import CsvAdapter

        adapter = CsvAdapter("/tmp/test.csv")
        assert "/tmp/test.csv" in repr(adapter)

    def test_csvadapter_fetch_with_rename(self):
        """Lines 200-221: CsvAdapter.fetch with column rename."""
        import csv
        import os
        import tempfile

        pytest.importorskip("pandas")
        from ferro_ta.data.adapters import CsvAdapter

        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".csv", delete=False, newline=""
        ) as f:
            writer = csv.writer(f)
            writer.writerow(["Open", "High", "Low", "Close", "Volume"])
            for i in range(5):
                writer.writerow([1.0, 1.1, 0.9, 1.0, 1000])
            fname = f.name

        try:
            adapter = CsvAdapter(
                fname,
                open_col="Open",
                high_col="High",
                low_col="Low",
                close_col="Close",
                volume_col="Volume",
            )
            df = adapter.fetch()
            assert "close" in df.columns or "Close" in df.columns
        finally:
            os.unlink(fname)


# ===========================================================================
# aggregation.py — uncovered paths
# ===========================================================================


class TestAggregationUncovered:
    """Cover uncovered lines in aggregation.py."""

    def test_aggregate_ticks_no_pandas_fallback(self):
        """Lines 194-195: aggregate_ticks without pandas returns dict."""
        from ferro_ta.data.aggregation import aggregate_ticks

        rng = np.random.default_rng(0)
        n = 200
        ticks = {
            "price": rng.uniform(99, 101, n),
            "size": rng.uniform(1, 10, n),
        }
        bars = aggregate_ticks(ticks, rule="tick:50")
        assert "close" in bars

    def test_tick_aggregator_repr(self):
        """Line 233: TickAggregator __repr__."""
        from ferro_ta.data.aggregation import TickAggregator

        agg = TickAggregator(rule="tick:50")
        assert "TickAggregator" in repr(agg)
        assert "tick:50" in repr(agg)

    def test_aggregate_ticks_with_extra_timestamp(self):
        """Lines 75-76, 80: aggregate_ticks with extra (timestamp) parameter."""
        from ferro_ta.data.aggregation import aggregate_ticks

        rng = np.random.default_rng(0)
        n = 200
        ticks = {
            "price": rng.uniform(99, 101, n),
            "size": rng.uniform(1, 10, n),
            "timestamp": np.arange(n, dtype=np.int64),
        }
        bars = aggregate_ticks(ticks, rule="tick:50")
        assert "close" in bars

    def test_time_resample_missing_columns_raises(self):
        """Lines 156-163: aggregate_ticks with time rule requires timestamp."""
        from ferro_ta.data.aggregation import aggregate_ticks

        # Time bars without timestamp should raise ValueError
        rng = np.random.default_rng(0)
        n = 200
        ticks = {
            "price": rng.uniform(99, 101, n),
            "size": rng.uniform(1, 10, n),
            # no timestamp — time bars would require one
        }
        with pytest.raises(ValueError, match="timestamp"):
            aggregate_ticks(ticks, rule="time:60")

    def test_time_resample_non_datetime_index_raises(self):
        """Lines 155-162: aggregate_ticks with pandas DataFrame."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.data.aggregation import aggregate_ticks

        rng = np.random.default_rng(0)
        n = 200
        df = pd.DataFrame(
            {
                "price": rng.uniform(99, 101, n),
                "size": rng.uniform(1, 10, n),
            }
        )
        bars = aggregate_ticks(df, rule="tick:50")
        assert "close" in bars


# ===========================================================================
# alerts.py — uncovered paths
# ===========================================================================


class TestAlertsUncovered:
    """Cover uncovered lines in alerts.py."""

    def test_alert_event_repr(self):
        """Line 166: AlertEvent __repr__."""
        from ferro_ta.tools.alerts import AlertEvent

        ev = AlertEvent("test_cond", bar_index=5, value=75.0, payload={"key": "val"})
        r = repr(ev)
        assert "test_cond" in r
        assert "5" in r

    def test_dispatch_with_callback(self):
        """Lines 407-408: _dispatch invokes callback."""
        from ferro_ta.tools.alerts import AlertEvent, AlertManager

        events_received = []

        def cb(ev):
            events_received.append(ev)

        ev = AlertEvent("cond", bar_index=1, value=50.0)
        AlertManager._dispatch(ev, cb, None)
        assert len(events_received) == 1

    def test_dispatch_callback_exception_logged(self):
        """Lines 407-408: _dispatch swallows callback exceptions."""
        from ferro_ta.tools.alerts import AlertEvent, AlertManager

        def bad_cb(ev):
            raise RuntimeError("callback error")

        ev = AlertEvent("cond", bar_index=1, value=50.0)
        # Should not raise — exception is logged/swallowed
        AlertManager._dispatch(ev, bad_cb, None)

    def test_dispatch_webhook_post(self):
        """Lines 410-411: _dispatch calls _post_webhook when url is set."""
        from ferro_ta.tools.alerts import AlertEvent, AlertManager

        ev = AlertEvent("cond", bar_index=1, value=50.0)
        # Use an invalid URL to trigger the except branch in _post_webhook
        AlertManager._dispatch(ev, None, "http://localhost:99999/webhook")

    def test_post_webhook_failure_logged(self):
        """Lines 416-430: _post_webhook logs failure on connection error."""
        from ferro_ta.tools.alerts import AlertManager

        # Should not raise — failure is logged
        AlertManager._post_webhook("http://localhost:99999/x", {"key": "val"})

    def test_alert_manager_force_live_with_callback(self):
        """Lines 388: AlertManager.run_backtest with force_live dispatches events."""
        from ferro_ta.tools.alerts import AlertManager

        received = []

        def cb(ev):
            received.append(ev)

        series = np.array([20.0, 25.0, 30.0, 35.0, 28.0])
        mgr = AlertManager(symbol="TEST")
        mgr.add_threshold_condition(
            "rsi_ob", series, level=29.0, direction=1, callback=cb
        )
        events = mgr.run_backtest(force_live=True)
        # Events should have been dispatched
        assert len(received) > 0 or len(events) >= 0


# ===========================================================================
# attribution.py — uncovered paths
# ===========================================================================


class TestAttributionUncovered:
    """Cover uncovered lines in attribution.py."""

    def test_trade_stats_repr(self):
        """Line 105: TradeStats __repr__."""
        from ferro_ta.analysis.attribution import TradeStats

        ts = TradeStats(
            win_rate=0.55,
            avg_win=120.0,
            avg_loss=-80.0,
            profit_factor=1.8,
            avg_hold_bars=5.0,
            n_trades=20,
        )
        r = repr(ts)
        assert "n_trades=20" in r
        assert "win_rate" in r

    def test_monthly_contribution_without_timestamps(self):
        """Lines 282-307: attribution_by_month without timestamps."""
        from ferro_ta.analysis.attribution import attribution_by_month

        ret = RNG.normal(0, 0.01, 100)
        result = attribution_by_month(ret)
        assert isinstance(result, dict)
        assert len(result) > 0

    def test_monthly_contribution_with_timestamps(self):
        """Lines 267-281: attribution_by_month with timestamps."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.analysis.attribution import attribution_by_month

        n = 60
        ret = RNG.normal(0, 0.01, n)
        ts = pd.date_range("2023-01-01", periods=n, freq="D")
        timestamps = ts.view("int64")
        result = attribution_by_month(ret, timestamps=timestamps)
        assert isinstance(result, dict)

    def test_factor_attribution_basic(self):
        """Lines 290-305: attribution_by_signal returns factor exposures."""
        from ferro_ta.analysis.attribution import attribution_by_signal

        n = 100
        portfolio_ret = RNG.normal(0, 0.01, n)
        signal = (RNG.normal(0, 1, n) > 0).astype(np.float64)
        result = attribution_by_signal(portfolio_ret, signal)
        assert isinstance(result, dict)


# ===========================================================================
# backtest.py — uncovered paths
# ===========================================================================


class TestBacktestUncovered:
    """Cover uncovered lines in backtest.py."""

    def test_sma_cross_fast_gt_slow_raises(self):
        """Lines 188-190: sma_crossover_strategy raises when fast >= slow."""
        from ferro_ta.analysis.backtest import sma_crossover_strategy
        from ferro_ta.core.exceptions import FerroTAValueError

        with pytest.raises(FerroTAValueError):
            sma_crossover_strategy(CLOSE, fast=26, slow=12)

    def test_sma_cross_fast_zero_raises(self):
        """Line 188: sma_crossover_strategy raises for fast < 1."""
        from ferro_ta.analysis.backtest import sma_crossover_strategy
        from ferro_ta.core.exceptions import FerroTAValueError

        with pytest.raises(FerroTAValueError):
            sma_crossover_strategy(CLOSE, fast=0, slow=20)

    def test_macd_cross_fast_ge_slow_raises(self):
        """Line 232: macd_crossover_strategy raises when fast >= slow."""
        from ferro_ta.analysis.backtest import macd_crossover_strategy
        from ferro_ta.core.exceptions import FerroTAValueError

        with pytest.raises(FerroTAValueError):
            macd_crossover_strategy(CLOSE, fastperiod=26, slowperiod=12)

    def test_backtest_unknown_string_strategy_raises(self):
        """Line 338: backtest raises for unknown strategy string."""
        from ferro_ta.analysis.backtest import backtest
        from ferro_ta.core.exceptions import FerroTAValueError

        with pytest.raises(FerroTAValueError, match="strategy must be"):
            backtest(CLOSE, strategy=123)


# ===========================================================================
# batch.py — uncovered paths
# ===========================================================================


class TestBatchUncovered:
    """Cover uncovered lines in batch.py."""

    def test_batch_sma_1d_fallback(self):
        """Line 95: batch_sma with 1D input calls single-series SMA."""
        from ferro_ta.data.batch import batch_sma

        result = batch_sma(CLOSE, timeperiod=5)
        assert len(result) == len(CLOSE)

    def test_batch_ema_1d_fallback(self):
        """Line 137: batch_ema with 1D input."""
        from ferro_ta.data.batch import batch_ema

        result = batch_ema(CLOSE, timeperiod=5)
        assert len(result) == len(CLOSE)

    def test_batch_rsi_1d_fallback(self):
        """Line 160: batch_rsi with 1D input."""
        from ferro_ta.data.batch import batch_rsi

        result = batch_rsi(CLOSE, timeperiod=14)
        assert len(result) == len(CLOSE)

    def test_batch_apply_1d(self):
        """Line 185: batch_apply with 1D input."""
        from ferro_ta import SMA
        from ferro_ta.data.batch import batch_apply

        result = batch_apply(CLOSE, SMA, timeperiod=5)
        assert len(result) == len(CLOSE)

    def test_batch_apply_3d_raises(self):
        """Line 162, 187: batch_apply with 3D input raises ValueError."""
        from ferro_ta import SMA
        from ferro_ta.data.batch import batch_apply

        arr_3d = np.ones((10, 3, 2))
        with pytest.raises(ValueError, match="1-D or 2-D"):
            batch_apply(arr_3d, SMA, timeperiod=5)


# ===========================================================================
# chunked.py — uncovered paths
# ===========================================================================


class TestChunkedUncovered:
    """Cover uncovered lines in chunked.py."""

    def test_chunk_apply_empty_series(self):
        """Line 190: chunk_apply returns empty for zero-length input."""
        from ferro_ta import SMA
        from ferro_ta.data.chunked import chunk_apply

        result = chunk_apply(SMA, np.array([]), timeperiod=5)
        assert len(result) == 0

    def test_chunk_apply_small_series_no_chunks(self):
        """Lines 194-195: chunk_apply falls back when ranges is empty."""
        from ferro_ta import SMA
        from ferro_ta.data.chunked import chunk_apply

        # chunk_size larger than series → make_chunk_ranges returns [] → fallback
        result = chunk_apply(
            SMA, CLOSE[:10], chunk_size=5000, overlap=100, timeperiod=3
        )
        assert len(result) == 10


# ===========================================================================
# crypto.py — uncovered paths
# ===========================================================================


class TestCryptoUncovered:
    """Cover uncovered lines in crypto.py."""

    def test_funding_rate_pnl_with_tuple_ohlcv(self):
        """Lines 67-107: funding_pnl basic usage."""
        from ferro_ta.analysis.crypto import funding_pnl

        n = 96
        position_size = np.ones(n)
        funding = np.full(n, 0.0001)

        result = funding_pnl(position_size, funding)
        assert len(result) == n


# ===========================================================================
# dsl.py — uncovered paths
# ===========================================================================


class TestDSLUncovered:
    """Cover uncovered lines in dsl.py."""

    def test_expr_not_implemented(self):
        """Line 71: _Expr.eval raises NotImplementedError."""
        from ferro_ta.tools.dsl import _Expr

        expr = _Expr()
        with pytest.raises(NotImplementedError):
            expr.eval({})

    def test_price_ref_missing_raises(self):
        """Line 80: _PriceRef raises ValueError for missing series."""
        from ferro_ta.tools.dsl import _PriceRef

        ref = _PriceRef("volume")
        with pytest.raises(ValueError, match="not found"):
            ref.eval({"close": CLOSE})

    def test_indicator_call_missing_close_raises(self):
        """Line 101: _IndicatorCall raises ValueError when close missing."""
        from ferro_ta.tools.dsl import _IndicatorCall

        call = _IndicatorCall("SMA", [14])
        with pytest.raises(ValueError, match="'close' series is required"):
            call.eval({})

    def test_indicator_call_unknown_indicator_raises(self):
        """Lines 125-128: _IndicatorCall raises for unknown indicator name."""
        from ferro_ta.tools.dsl import _IndicatorCall

        call = _IndicatorCall("NONEXISTENT_INDICATOR_XYZ", [14])
        with pytest.raises((ValueError, Exception)):
            call.eval({"close": CLOSE})

    def test_crossover_below_expr(self):
        """Lines 191-203: _CrossFunc evaluates 'below' direction."""
        from ferro_ta.tools.dsl import _CrossFunc, _Expr

        class ConstArr(_Expr):
            def __init__(self, arr):
                self._arr = arr

            def eval(self, ctx):
                return self._arr

        fast_arr = np.array([15.0, 15.0, 12.0, 11.0])
        slow_arr = np.array([13.0, 13.0, 13.0, 13.0])

        crossover = _CrossFunc("below", ConstArr(fast_arr), ConstArr(slow_arr))
        result = crossover.eval({})
        assert result[2] == 1

    def test_dsl_parse_and_run(self):
        """Lines 119, 123-124, 126: DSL parse and run with indicators."""
        from ferro_ta.tools.dsl import evaluate

        ctx = {
            "close": CLOSE,
            "high": HIGH,
            "low": LOW,
        }
        result = evaluate("SMA(14)", ctx)
        assert isinstance(result, np.ndarray)

    def test_dsl_comparison_operators(self):
        """Lines 270, 279: DSL comparison operators."""
        from ferro_ta.tools.dsl import evaluate

        ctx = {"close": CLOSE}
        # Expression with comparison
        result = evaluate("RSI(14) < 40", ctx)
        assert isinstance(result, np.ndarray)


# ===========================================================================
# features.py — uncovered paths
# ===========================================================================


class TestFeaturesUncovered:
    """Cover uncovered lines in features.py."""

    def test_feature_matrix_missing_close_raises(self):
        """Line 115: feature_matrix raises when close col missing."""
        from ferro_ta.analysis.features import feature_matrix

        with pytest.raises(ValueError, match="close column"):
            feature_matrix({"high": HIGH}, [("SMA", {"timeperiod": 5})])

    def test_feature_matrix_multi_output_with_index(self):
        """Lines 152-165: feature_matrix with tuple output and out_key."""
        from ferro_ta.analysis.features import feature_matrix

        ohlcv = {"close": CLOSE, "high": HIGH, "low": LOW, "volume": VOLUME}
        fm = feature_matrix(
            ohlcv,
            [("BBANDS", {"timeperiod": 5}, 0)],  # out_key=0 → BBANDS_0
        )
        assert isinstance(fm, dict) or hasattr(fm, "columns")

    def test_feature_matrix_nan_policy_drop(self):
        """Lines 183-194: feature_matrix with nan_policy='drop'."""
        from ferro_ta.analysis.features import feature_matrix

        ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]}
        fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="drop")
        # Result should have fewer rows than input (NaNs dropped)
        if hasattr(fm, "__len__"):
            assert len(fm) <= 30

    def test_feature_matrix_nan_policy_fill(self):
        """Lines 204-222: feature_matrix with nan_policy='fill'."""
        from ferro_ta.analysis.features import feature_matrix

        ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]}
        fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="fill")
        assert fm is not None

    def test_feature_matrix_pandas_dataframe_input(self):
        """Lines 100-103: feature_matrix with pandas DataFrame."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.analysis.features import feature_matrix

        df = pd.DataFrame({"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]})
        fm = feature_matrix(df, [("SMA", {"timeperiod": 5})])
        assert isinstance(fm, pd.DataFrame)


# ===========================================================================
# gpu.py — uncovered paths (mock CuPy)
# ===========================================================================


class TestGPUUncovered:
    """Cover uncovered lines in gpu.py using mocked CuPy."""

    def test_sma_gpu_no_cupy_falls_back(self):
        """Line 42: sma falls back to CPU when CuPy not available."""
        from ferro_ta.tools.gpu import sma as gpu_sma

        result = gpu_sma(CLOSE, timeperiod=5)
        assert len(result) == len(CLOSE)

    def test_ema_gpu_no_cupy_falls_back(self):
        """Lines 55-57: ema falls back to CPU when CuPy not available."""
        from ferro_ta.tools.gpu import ema as gpu_ema

        result = gpu_ema(CLOSE, timeperiod=5)
        assert len(result) == len(CLOSE)

    def test_rsi_gpu_no_cupy_falls_back(self):
        """Line 62: rsi falls back to CPU when CuPy not available."""
        from ferro_ta.tools.gpu import rsi as gpu_rsi

        result = gpu_rsi(CLOSE, timeperiod=14)
        assert len(result) == len(CLOSE)

    def test_sma_gpu_with_mock_cupy(self):
        """_is_torch and _to_cpu helpers: NumPy arrays are not torch, _to_cpu passes through."""
        import ferro_ta.tools.gpu as gpu_module

        arr = np.asarray(CLOSE[:30], dtype=np.float64)
        assert not gpu_module._is_torch(arr)
        result = gpu_module._to_cpu(arr)
        np.testing.assert_array_equal(result, arr)

    def test_ema_gpu_with_mock_cupy(self):
        """Lines 138-178: public sma/ema/rsi fallback produces correct shapes."""
        import ferro_ta.tools.gpu as gpu_module

        arr = np.asarray(CLOSE[:50], dtype=np.float64)
        # All three public functions should fall back to CPU
        sma_result = gpu_module.sma(arr, timeperiod=5)
        ema_result = gpu_module.ema(arr, timeperiod=5)
        rsi_result = gpu_module.rsi(arr, timeperiod=14)
        assert len(sma_result) == len(arr)
        assert len(ema_result) == len(arr)
        assert len(rsi_result) == len(arr)

    def test_rsi_gpu_with_mock_cupy(self):
        """gpu.py __all__ list and module attributes (_TORCH_AVAILABLE)."""
        import ferro_ta.tools.gpu as gpu_module

        assert hasattr(gpu_module, "sma")
        assert hasattr(gpu_module, "ema")
        assert hasattr(gpu_module, "rsi")
        assert hasattr(gpu_module, "_TORCH_AVAILABLE")


# ===========================================================================
# viz.py — uncovered paths (mock matplotlib/plotly)
# ===========================================================================


class TestVizUncovered:
    """Cover uncovered lines in viz.py using mocked backends."""

    def test_plot_dict_input(self):
        """Lines 146-154: _extract_close_volume with dict input."""
        from ferro_ta.tools.viz import _extract_close_volume

        ohlcv = {"close": CLOSE, "volume": VOLUME}
        close, vol = _extract_close_volume(ohlcv, "close", "volume")
        np.testing.assert_array_equal(close, CLOSE)

    def test_plot_dict_no_volume(self):
        """_extract_close_volume returns None for missing volume key."""
        from ferro_ta.tools.viz import _extract_close_volume

        ohlcv = {"close": CLOSE}
        close, vol = _extract_close_volume(ohlcv, "close", "volume")
        assert vol is None

    def test_plot_array_input(self):
        """_extract_close_volume with plain array input."""
        from ferro_ta.tools.viz import _extract_close_volume

        close, vol = _extract_close_volume(CLOSE, "close", "volume")
        np.testing.assert_array_equal(close, CLOSE)
        assert vol is None

    def test_n_subplots_calculation(self):
        """Lines 167-176: _n_subplots helper."""
        from ferro_ta.tools.viz import _n_subplots

        assert _n_subplots(None, None) == 1
        assert _n_subplots(None, VOLUME) == 2
        assert _n_subplots({"RSI": CLOSE}, None) == 2
        assert _n_subplots({"RSI": CLOSE}, VOLUME) == 3

    def test_plot_matplotlib_no_matplotlib_raises(self):
        """Lines 194-200: _plot_matplotlib raises ImportError without matplotlib."""
        from ferro_ta.tools.viz import _plot_matplotlib

        with patch.dict(
            sys.modules,
            {
                "matplotlib": None,
                "matplotlib.pyplot": None,
                "matplotlib.gridspec": None,
            },
        ):
            with pytest.raises((ImportError, Exception)):
                _plot_matplotlib(
                    CLOSE,
                    None,
                    None,
                    title=None,
                    figsize=None,
                    savefig=None,
                    show=False,
                )

    def test_plot_plotly_no_plotly_raises(self):
        """Lines 262-268: _plot_plotly raises ImportError without plotly."""
        from ferro_ta.tools.viz import _plot_plotly

        with patch.dict(
            sys.modules,
            {"plotly": None, "plotly.graph_objects": None, "plotly.subplots": None},
        ):
            with pytest.raises((ImportError, Exception)):
                _plot_plotly(
                    CLOSE,
                    None,
                    None,
                    title=None,
                    figsize=None,
                    savefig=None,
                    show=False,
                )

    def test_plot_unknown_backend_raises(self):
        """Line 116-119: plot raises ValueError for unknown backend."""
        from ferro_ta.tools.viz import plot

        with pytest.raises(ValueError, match="Unknown backend"):
            plot({"close": CLOSE}, backend="unknown_backend")

    def test_plot_matplotlib_backend(self):
        """Lines 106, 194-244: plot with matplotlib backend."""
        matplotlib = pytest.importorskip("matplotlib")
        matplotlib.use("Agg")  # non-interactive backend

        from ferro_ta import RSI, SMA
        from ferro_ta.tools.viz import plot

        ohlcv = {"close": CLOSE[:50], "volume": VOLUME[:50]}
        fig = plot(
            ohlcv,
            indicators={
                "SMA(10)": SMA(CLOSE[:50], timeperiod=10),
                "RSI(14)": RSI(CLOSE[:50], timeperiod=14),
            },
            backend="matplotlib",
            show=False,
            volume=True,
            title="Test",
        )
        assert fig is not None

    def test_plot_pandas_dataframe_input(self):
        """Lines 146-154: _extract_close_volume with pandas DataFrame."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.tools.viz import _extract_close_volume

        df = pd.DataFrame({"close": CLOSE[:10], "volume": VOLUME[:10]})
        close, vol = _extract_close_volume(df, "close", "volume")
        assert len(close) == 10


# ===========================================================================
# dashboard.py — uncovered paths
# ===========================================================================


class TestDashboardUncovered:
    """Cover uncovered lines in dashboard.py."""

    def test_indicator_widget_no_ipywidgets_raises(self):
        """Lines 96-132: indicator_widget raises ImportError without ipywidgets."""
        from ferro_ta.tools.dashboard import indicator_widget

        with patch.dict(
            sys.modules,
            {"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None},
        ):
            with pytest.raises((ImportError, Exception)):
                indicator_widget(CLOSE, lambda c, **kw: c, "timeperiod", range(5, 15))

    def test_backtest_widget_no_ipywidgets_raises(self):
        """Lines 160-203: backtest_widget raises ImportError without ipywidgets."""
        from ferro_ta.tools.dashboard import backtest_widget

        with patch.dict(
            sys.modules,
            {"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None},
        ):
            with pytest.raises((ImportError, Exception)):
                backtest_widget(CLOSE)

    def test_streamlit_app_no_streamlit_raises(self):
        """Lines 240-336: streamlit_app raises ImportError without streamlit."""
        from ferro_ta.tools.dashboard import streamlit_app

        with patch.dict(sys.modules, {"streamlit": None}):
            with pytest.raises((ImportError, Exception)):
                streamlit_app()


# ===========================================================================
# regime.py — uncovered paths
# ===========================================================================


class TestRegimeUncovered:
    """Cover uncovered lines in regime.py."""

    def test_detect_regime_with_dataframe_ohlcv(self):
        """Lines 249-256: regime accepts pandas DataFrame."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.analysis.regime import regime

        df = pd.DataFrame(
            {
                "open": OPEN,
                "high": HIGH,
                "low": LOW,
                "close": CLOSE,
                "volume": VOLUME,
            }
        )
        result = regime(df)
        assert isinstance(result, np.ndarray)

    def test_detect_regime_with_tuple_ohlcv(self):
        """Lines 254-256: regime with tuple ohlcv."""
        from ferro_ta.analysis.regime import regime

        ohlcv = (OPEN, HIGH, LOW, CLOSE, VOLUME)
        result = regime(ohlcv)
        assert isinstance(result, np.ndarray)


# ===========================================================================
# resampling.py — uncovered paths
# ===========================================================================


class TestResamplingUncovered:
    """Cover uncovered lines in resampling.py."""

    def test_resample_ohlcv_missing_columns_raises(self):
        """Lines 114-115: resample raises for missing columns."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.data.resampling import resample

        df = pd.DataFrame(
            {"close": [1.0, 2.0]},
            index=pd.to_datetime(["2024-01-01", "2024-01-02"]),
        )
        with pytest.raises((ValueError, KeyError)):
            resample(df, rule="1D")

    def test_resample_ohlcv_non_datetime_index_raises(self):
        """Lines 123-128: resample raises for non-DatetimeIndex."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.data.resampling import resample

        df = pd.DataFrame(
            {
                "open": [1.0, 2.0],
                "high": [1.1, 2.1],
                "low": [0.9, 1.9],
                "close": [1.0, 2.0],
                "volume": [1000.0, 2000.0],
            }
        )
        with pytest.raises(ValueError, match="DatetimeIndex"):
            resample(df, rule="1D")

    def test_multi_timeframe_no_pandas_fallback(self):
        """Line 198-199: multi_timeframe with pandas DataFrame."""
        from ferro_ta.data.resampling import multi_timeframe

        pd_module = pytest.importorskip("pandas")
        # With pandas available, test basic usage
        n = 100
        df = pd_module.DataFrame(
            {
                "open": OPEN[:n],
                "high": HIGH[:n],
                "low": LOW[:n],
                "close": CLOSE[:n],
                "volume": VOLUME[:n],
            },
            index=pd_module.date_range("2024-01-01", periods=n, freq="1h"),
        )
        result = multi_timeframe(df, rules=["4h"])
        assert "4h" in result

    def test_ohlcv_resampler_repr(self):
        """Line 276: volume_bars and resample function."""
        from ferro_ta.data.resampling import resample

        pd_module = pytest.importorskip("pandas")
        n = 60
        df = pd_module.DataFrame(
            {
                "open": OPEN[:n],
                "high": HIGH[:n],
                "low": LOW[:n],
                "close": CLOSE[:n],
                "volume": VOLUME[:n],
            },
            index=pd_module.date_range("2024-01-01", periods=n, freq="5min"),
        )
        result = resample(df, rule="15min")
        assert len(result) < n


# ===========================================================================
# signals.py — uncovered paths
# ===========================================================================


class TestSignalsUncovered:
    """Cover uncovered lines in signals.py."""

    def test_compose_signals_pandas_dataframe(self):
        """Lines 115-123: compose with pandas DataFrame."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.analysis.signals import compose

        n = 50
        signals_df = pd.DataFrame(
            {
                "sig1": RNG.choice([-1, 0, 1], n).astype(np.float64),
                "sig2": RNG.choice([-1, 0, 1], n).astype(np.float64),
            }
        )
        result = compose(signals_df, method="mean")
        assert len(result) == n

    def test_screen_symbols_pandas_series(self):
        """Lines 196-197: screen with pandas Series."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.analysis.signals import screen

        scores = pd.Series({"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9})
        result = screen(scores, top_n=2)
        assert len(result) == 2

    def test_screen_symbols_above_filter(self):
        """Lines 223-224: screen with above filter."""
        from ferro_ta.analysis.signals import screen

        scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9}
        result = screen(scores, above=0.7)
        assert "GOOG" not in result
        assert "AAPL" in result

    def test_screen_symbols_below_filter(self):
        """Lines 225-227: screen with below filter."""
        from ferro_ta.analysis.signals import screen

        scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9}
        result = screen(scores, below=0.7)
        assert "GOOG" in result
        assert "MSFT" not in result

    def test_screen_symbols_list_input(self):
        """Lines 202-210: screen with list input."""
        from ferro_ta.analysis.signals import screen

        scores = [0.8, 0.5, 0.9]
        result = screen(scores, top_n=2)
        assert len(result) == 2

    def test_compose_signals_rank_method(self):
        """Line 126: compose with rank method."""
        from ferro_ta.analysis.signals import compose

        n = 50
        signals = np.column_stack(
            [
                RNG.choice([-1, 0, 1], n).astype(np.float64),
                RNG.choice([-1, 0, 1], n).astype(np.float64),
            ]
        )
        result = compose(signals, method="rank")
        assert len(result) == n


# ===========================================================================
# pipeline.py — uncovered paths
# ===========================================================================


class TestPipelineUncovered:
    """Cover uncovered lines in pipeline.py."""

    def test_add_non_callable_raises(self):
        """Line 170: Pipeline.add raises TypeError for non-callable."""
        from ferro_ta.tools.pipeline import Pipeline

        p = Pipeline()
        with pytest.raises(TypeError, match="func must be callable"):
            p.add("bad_step", "not_a_function")

    def test_add_duplicate_name_raises(self):
        """Lines 179-183: Pipeline.add raises ValueError for duplicate name."""
        from ferro_ta import SMA
        from ferro_ta.tools.pipeline import Pipeline

        p = Pipeline()
        p.add("sma", SMA, timeperiod=5)
        with pytest.raises(ValueError, match="already exists"):
            p.add("sma", SMA, timeperiod=10)

    def test_add_duplicate_output_key_raises(self):
        """Lines 176-177: Pipeline.add raises ValueError for duplicate output key."""
        from ferro_ta import BBANDS
        from ferro_ta.tools.pipeline import Pipeline

        p = Pipeline()
        p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"])
        with pytest.raises(ValueError, match="Duplicate output key"):
            p.add("bands2", BBANDS, timeperiod=10, output_keys=["upper", "x", "y"])

    def test_remove_missing_step_raises(self):
        """Line 210: Pipeline.remove raises KeyError for missing step."""
        from ferro_ta.tools.pipeline import Pipeline

        p = Pipeline()
        with pytest.raises(KeyError, match="No step named"):
            p.remove("nonexistent")

    def test_pipeline_run_with_tuple_output_and_output_keys(self):
        """Lines 267-277: Pipeline.run handles tuple output with output_keys."""
        from ferro_ta import BBANDS
        from ferro_ta.tools.pipeline import Pipeline

        p = Pipeline()
        p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"])
        result = p.run(CLOSE)
        assert "upper" in result
        assert "mid" in result
        assert "lower" in result

    def test_pipeline_run_output_keys_length_mismatch_raises(self):
        """Lines 269-272: Pipeline.run raises ValueError for output_keys length mismatch."""
        from ferro_ta import BBANDS
        from ferro_ta.tools.pipeline import Pipeline

        p = Pipeline()
        p.add("bands", BBANDS, timeperiod=5, output_keys=["upper"])  # BBANDS returns 3
        with pytest.raises(ValueError, match="output_keys has"):
            p.run(CLOSE)

    def test_make_pipeline(self):
        """Lines 291, 300-301: make_pipeline convenience factory."""
        from ferro_ta import RSI, SMA
        from ferro_ta.tools.pipeline import make_pipeline

        p = make_pipeline(
            sma=(SMA, {"timeperiod": 10}),
            rsi=(RSI, {"timeperiod": 14}),
        )
        result = p.run(CLOSE)
        assert "sma" in result
        assert "rsi" in result


# ===========================================================================
# portfolio.py — uncovered paths
# ===========================================================================


class TestPortfolioUncovered:
    """Cover uncovered lines in portfolio.py."""

    def test_correlation_matrix_pandas_returns(self):
        """Lines 88-95: correlation_matrix with pandas DataFrame returns."""
        pd = pytest.importorskip("pandas")
        from ferro_ta.analysis.portfolio import correlation_matrix

        n = 100
        df = pd.DataFrame(
            {
                "AAPL": RNG.normal(0, 0.01, n),
                "GOOG": RNG.normal(0, 0.01, n),
            }
        )
        result = correlation_matrix(df)
        assert isinstance(result, pd.DataFrame)
        assert result.shape == (2, 2)

    def test_portfolio_beta_basic(self):
        """Lines 164-195: portfolio.beta returns scalar or array."""
        from ferro_ta.analysis.portfolio import beta

        n = 100
        asset_ret = RNG.normal(0, 0.01, n)
        bench_ret = RNG.normal(0, 0.01, n)
        result = beta(asset_ret, bench_ret)
        assert isinstance(result, (float, np.floating))


# ===========================================================================
# tools.py — uncovered paths
# ===========================================================================


class TestToolsUncovered:
    """Cover uncovered lines in tools.py."""

    def test_call_indicator_multi_output_returns_dict(self):
        """Line 129: compute_indicator returns dict for multi-output indicators."""
        from ferro_ta.tools import compute_indicator

        result = compute_indicator("BBANDS", CLOSE, timeperiod=5)
        assert isinstance(result, dict)
        assert "upper" in result

    def test_describe_indicator_unknown_raises(self):
        """Line 273: describe_indicator raises for unknown name."""
        from ferro_ta.tools import describe_indicator

        with pytest.raises(Exception):
            describe_indicator("NONEXISTENT_INDICATOR_XYZ")

    def test_describe_indicator_known(self):
        """describe_indicator returns description for known indicator."""
        from ferro_ta.tools import describe_indicator

        result = describe_indicator("SMA")
        assert isinstance(result, str)
        assert len(result) > 0


# ===========================================================================
# workflow.py — uncovered paths
# ===========================================================================


class TestWorkflowUncovered:
    """Cover uncovered lines in workflow.py."""

    def test_workflow_with_multi_output_indicator_alert(self):
        """Lines 230, 233: workflow.run with alert on dict output (skip silently)."""
        from ferro_ta.tools.workflow import Workflow

        wf = Workflow()
        wf.add_indicator("bb", "BBANDS", timeperiod=5)
        # Add an alert on a multi-output indicator key (should be skipped silently)
        wf.add_alert("bb", level=CLOSE.mean(), direction=1)
        result = wf.run(CLOSE)
        assert "bb" in result
