"""Technical Analysis indicators, trend detection, structure analysis, and candlestick pattern recognition.

This module provides the :class:`TechAnalysis` class, a comprehensive toolkit
of 30+ technical indicators commonly used in quantitative trading and
financial market analysis.  Every method operates on ``pandas.Series``
or ``pandas.DataFrame`` objects and returns the same types, making it
easy to integrate into pandas-based data pipelines.

Example:
    >>> import pandas as pd
    >>> from wael_lib import TechAnalysis
    >>> ta = TechAnalysis()
    >>> df = pd.read_csv("OHLCV.csv")
    >>> df["RSI"] = ta.rsi(df["Close"], 14)
"""

from __future__ import annotations

from typing import List, Tuple

import numpy as np
import pandas as pd


class TechAnalysis:
    """Collection of technical-analysis indicators and pattern-detection methods.

    All indicator methods accept ``pandas.Series`` (or DataFrames where
    noted) and return ``pandas.Series`` of the same length.

    Detection methods (``detect_trend``, ``detect_bos_choch``,
    ``detect_formations``, ``detect_candlestick_pattern``) operate row-by-row
    and return a descriptive string label.

    Example:
        >>> ta = TechAnalysis()
        >>> rsi = ta.rsi(df["Close"], 14)
        >>> macd_line, signal, hist = ta.macd(df["Close"])
    """

    def __init__(self) -> None:
        """Initialise the TechAnalysis helper (no configuration required)."""
        pass

    # ──────────────────────────────────────────────────────────────────────
    #  Moving Averages
    # ──────────────────────────────────────────────────────────────────────

    def sma(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the Simple Moving Average (SMA).

        The SMA is the unweighted mean of the previous ``length`` data points.

        Args:
            source (pd.Series): Price or indicator series to smooth.
            length (int): Window size (number of periods).

        Returns:
            pd.Series: Rolling mean of *source*.  The first ``length - 1``
            values will be ``NaN``.

        Example:
            >>> ta.sma(df["Close"], 20)
        """
        return source.rolling(window=length).mean()

    def ema(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the Exponential Moving Average (EMA).

        Uses the *span* method (``adjust=False``) which is equivalent to the
        recursive EMA formula used by most charting platforms.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Span of the EMA (equivalent to the period).

        Returns:
            pd.Series: EMA values with the same index as *source*.

        Example:
            >>> ta.ema(df["Close"], 12)
        """
        return source.ewm(span=length, adjust=False).mean()

    def wma(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the Weighted Moving Average (WMA).

        Each bar in the window is multiplied by a linearly increasing weight
        (1, 2, …, *length*) and divided by the sum of the weights.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Window size.

        Returns:
            pd.Series: Weighted moving average values.  The first
            ``length - 1`` values will be ``NaN``.

        Example:
            >>> ta.wma(df["Close"], 10)
        """
        weights = pd.Series(range(1, length + 1))

        def weighted_average(series: pd.Series) -> float:
            return (series * weights).sum() / weights.sum()

        return source.rolling(window=length).apply(weighted_average, raw=False)

    def tema(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the Triple Exponential Moving Average (TEMA).

        TEMA reduces the lag of a traditional EMA by combining three EMA
        passes:  ``TEMA = 3 * EMA1 - 3 * EMA2 + EMA3``.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Period for each EMA pass.

        Returns:
            pd.Series: TEMA values with reduced lag compared to a simple EMA.

        Example:
            >>> ta.tema(df["Close"], 20)
        """
        ema1 = self.ema(source, length)
        ema2 = self.ema(ema1, length)
        ema3 = self.ema(ema2, length)
        return (3 * ema1) - (3 * ema2) + ema3

    def tma(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the Triangular Moving Average (TMA).

        TMA applies two passes of the Simple Moving Average using a
        half-period for each pass, producing a smoother curve than a single
        SMA while maintaining approximately the same look-back window.  The
        double-SMA construction assigns triangular (pyramid-like) weights to
        the data so that the centre of the window receives the most influence,
        making TMA less reactive to short-term noise than SMA or EMA.

        Formula::

            half = ceil((length + 1) / 2)
            TMA  = SMA(SMA(source, half), half)

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Overall look-back period.  Each internal SMA pass
                uses ``ceil((length + 1) / 2)`` bars.

        Returns:
            pd.Series: TMA values, smoother than a single SMA of the same
            period but with greater lag.  The first ``2 * half - 2`` values
            will be ``NaN``.

        Example:
            >>> ta.tma(df["Close"], 20)
        """
        half = (length + 2) // 2  # equivalent to math.ceil((length + 1) / 2)
        return self.sma(self.sma(source, half), half)

    # ──────────────────────────────────────────────────────────────────────
    #  Oscillators & Momentum
    # ──────────────────────────────────────────────────────────────────────

    def rsi(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the Relative Strength Index (RSI).

        RSI measures the speed and magnitude of recent price changes to
        evaluate overbought (> 70) or oversold (< 30) conditions.

        Args:
            source (pd.Series): Price series (typically Close).
            length (int): Look-back period (commonly 14).

        Returns:
            pd.Series: RSI values in the range ``[0, 100]``.

        Example:
            >>> ta.rsi(df["Close"], 14)
        """
        delta = source.diff()
        gain = delta.where(delta > 0, 0)
        loss = -delta.where(delta < 0, 0)
        avg_gain = gain.rolling(window=length, min_periods=1).mean()
        avg_loss = loss.rolling(window=length, min_periods=1).mean()
        rs = avg_gain / avg_loss
        return 100 - (100 / (1 + rs))

    def stoch(
        self,
        high: pd.Series,
        low: pd.Series,
        close: pd.Series,
        k_length: int,
        d_length: int,
    ) -> Tuple[pd.Series, pd.Series]:
        """Calculate the Stochastic Oscillator (%K and %D).

        %K shows where the current close sits relative to the high-low range
        over *k_length* periods.  %D is the SMA of %K.

        Args:
            high (pd.Series): High price series.
            low (pd.Series): Low price series.
            close (pd.Series): Close price series.
            k_length (int): Look-back period for %K.
            d_length (int): Smoothing period for %D (SMA of %K).

        Returns:
            Tuple[pd.Series, pd.Series]: ``(percent_k, percent_d)`` where
            each element is a ``pd.Series`` in the range ``[0, 100]``.

        Example:
            >>> k, d = ta.stoch(df["High"], df["Low"], df["Close"], 14, 3)
        """
        lowest_low = low.rolling(window=k_length).min()
        highest_high = high.rolling(window=k_length).max()
        percent_k = 100 * ((close - lowest_low) / (highest_high - lowest_low))
        percent_d = percent_k.rolling(window=d_length).mean()
        return percent_k, percent_d

    def mfi(
        self,
        high: pd.Series,
        low: pd.Series,
        close: pd.Series,
        volume: pd.Series,
        length: int,
    ) -> pd.Series:
        """Calculate the Money Flow Index (MFI).

        MFI is a volume-weighted RSI that measures buying and selling
        pressure.  Values above 80 are typically overbought; below 20
        oversold.

        Args:
            high (pd.Series): High price series.
            low (pd.Series): Low price series.
            close (pd.Series): Close price series.
            volume (pd.Series): Volume series.
            length (int): Look-back period (commonly 14).

        Returns:
            pd.Series: MFI values in the range ``[0, 100]``.

        Example:
            >>> ta.mfi(df["High"], df["Low"], df["Close"], df["Volume"], 14)
        """
        typical_price = (high + low + close) / 3
        money_flow = typical_price * volume
        positive_flow = money_flow.where(typical_price > typical_price.shift(1), 0)
        negative_flow = money_flow.where(typical_price < typical_price.shift(1), 0)
        pos_mf = positive_flow.rolling(window=length).sum()
        neg_mf = negative_flow.rolling(window=length).sum()
        mfr = pos_mf / neg_mf
        return 100 - (100 / (1 + mfr))

    def adx(
        self,
        high: pd.Series,
        low: pd.Series,
        close: pd.Series,
        length: int,
    ) -> pd.Series:
        """Calculate the Average Directional Index (ADX).

        ADX quantifies trend strength regardless of direction.  Values above
        25 typically indicate a strong trend; below 20 a weak or ranging
        market.

        Args:
            high (pd.Series): High price series.
            low (pd.Series): Low price series.
            close (pd.Series): Close price series.
            length (int): Smoothing period (commonly 14).

        Returns:
            pd.Series: ADX values (0–100 range, though rarely exceeds 60).

        Example:
            >>> ta.adx(df["High"], df["Low"], df["Close"], 14)
        """
        plus_dm = high.diff()
        minus_dm = low.diff().abs()
        plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0)
        minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0)
        tr1 = high - low
        tr2 = abs(high - close.shift(1))
        tr3 = abs(low - close.shift(1))
        true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
        atr = true_range.rolling(window=length).mean()
        plus_di = 100 * (plus_dm.rolling(window=length).sum() / atr)
        minus_di = 100 * (minus_dm.rolling(window=length).sum() / atr)
        dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100
        return dx.rolling(window=length).mean()

    def macd(
        self,
        source: pd.Series,
        fast_length: int = 12,
        slow_length: int = 26,
        signal_length: int = 9,
    ) -> Tuple[pd.Series, pd.Series, pd.Series]:
        """Calculate the Moving Average Convergence Divergence (MACD).

        Args:
            source (pd.Series): Price series (typically Close).
            fast_length (int): Period for the fast EMA. Defaults to ``12``.
            slow_length (int): Period for the slow EMA. Defaults to ``26``.
            signal_length (int): Period for the signal line EMA. Defaults to ``9``.

        Returns:
            Tuple[pd.Series, pd.Series, pd.Series]: ``(macd_line, signal_line,
            histogram)`` where:

            * **macd_line** – difference between fast and slow EMAs.
            * **signal_line** – EMA of the MACD line.
            * **histogram** – ``macd_line - signal_line``.

        Example:
            >>> macd_line, signal, hist = ta.macd(df["Close"])
        """
        fast_ema = self.ema(source, fast_length)
        slow_ema = self.ema(source, slow_length)
        macd_line = fast_ema - slow_ema
        signal_line = self.ema(macd_line, signal_length)
        histogram = macd_line - signal_line
        return macd_line, signal_line, histogram

    def tema_macd(
        self,
        source: pd.Series,
        fast_length: int = 12,
        slow_length: int = 24,
        signal_length: int = 9,
    ) -> Tuple[pd.Series, pd.Series, pd.Series]:
        """Calculate the TEMA-based MACD.

        Same logic as :meth:`macd` but uses Triple Exponential Moving
        Averages for reduced lag.

        Args:
            source (pd.Series): Price series (typically Close).
            fast_length (int): Period for the fast TEMA. Defaults to ``12``.
            slow_length (int): Period for the slow TEMA. Defaults to ``24``.
            signal_length (int): Period for the signal TEMA. Defaults to ``9``.

        Returns:
            Tuple[pd.Series, pd.Series, pd.Series]: ``(macd_line, signal_line,
            histogram)``.

        Example:
            >>> macd_line, signal, hist = ta.tema_macd(df["Close"])
        """
        fast_tema = self.tema(source, fast_length)
        slow_tema = self.tema(source, slow_length)
        macd_line = fast_tema - slow_tema
        signal_line = self.tema(macd_line, signal_length)
        histogram = macd_line - signal_line
        return macd_line, signal_line, histogram

    def PriceExtOsc(
        self,
        high: pd.Series,
        low: pd.Series,
        length: int,
    ) -> pd.Series:
        """Calculate the Price Extension Oscillator.

        Measures how far the current midpoint price has extended from the
        lowest midpoint over *length* bars, normalised to 0–100 and
        smoothed with a 2-period SMA.

        Args:
            high (pd.Series): High price series.
            low (pd.Series): Low price series.
            length (int): Look-back period.

        Returns:
            pd.Series: Oscillator values in the range ``[0, 100]``.

        Example:
            >>> ta.PriceExtOsc(df["High"], df["Low"], 14)
        """
        M_P = (high + low) / 2
        Pa_Osc = (
            (M_P - self.lowest(M_P, length))
            / (self.highest(M_P, length) - self.lowest(M_P, length))
        ) * 100
        return self.sma(Pa_Osc, 2)

    def accumulation(self, df: pd.DataFrame, length: int = 14) -> pd.Series:
        """Calculate the Accumulation/Distribution indicator.

        Tracks cumulative money flow (price change x volume) over
        *length* periods.

        Args:
            df (pd.DataFrame): OHLCV DataFrame.  Must contain ``'Volume'``
                and ``'Close'`` columns.
            length (int): Rolling sum period. Defaults to ``14``.

        Returns:
            pd.Series: Accumulation values (also added to *df* as ``'Acc'``).

        Note:
            This method **mutates** *df* by adding ``'Money'`` and ``'Acc'``
            columns.

        Example:
            >>> ta.accumulation(df, 14)
        """
        df["Money"] = df["Volume"] * (df["Close"] - df["Close"].shift(1))
        df["Acc"] = self.math_sum(df["Money"], length)
        return df["Acc"]

    def acc_osc(self, df: pd.DataFrame, length: int = 14) -> pd.Series:
        """Calculate the Accumulation/Distribution Oscillator.

        Normalises :meth:`accumulation` to a 0–100 range using a min-max
        transform over the most recent 14 bars.

        Args:
            df (pd.DataFrame): OHLCV DataFrame.  Must contain ``'Volume'``
                and ``'Close'`` columns.
            length (int): Period passed to :meth:`accumulation`. Defaults
                to ``14``.

        Returns:
            pd.Series: Oscillator values in ``[0, 100]`` (also added to *df*
            as ``'Acc_Osc'``).

        Example:
            >>> ta.acc_osc(df, 14)
        """
        Acc = self.accumulation(df, length)
        df["Acc_Osc"] = (
            (Acc - self.lowest(Acc, 14))
            / (self.highest(Acc, 14) - self.lowest(Acc, 14))
        ) * 100
        return df["Acc_Osc"]

    def extremes(self, source: pd.Series, length: int = 14) -> pd.Series:
        """Calculate the Extremes indicator (min-max normalisation).

        Maps the current value to a 0–100 scale relative to the highest
        and lowest values over *length* bars.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Look-back period. Defaults to ``14``.

        Returns:
            pd.Series: Normalised values in ``[0, 100]``.

        Example:
            >>> ta.extremes(df["Close"], 14)
        """
        highest_high = self.highest(source, length)
        lowest_low = self.lowest(source, length)
        return ((source - lowest_low) / (highest_high - lowest_low)) * 100

    def categorize_price_ranges(self, source: pd.Series) -> pd.Series:
        """Categorize price levels into descriptive ranges based on historical extremes.

        This method uses the :meth:`extremes` indicator to determine where the
        current price sits relative to its recent history and assigns a
        descriptive label (e.g., "Low", "Moderate", "High").

        Args:
            source (pd.Series): Price series (typically Close).

        Returns:
            pd.Series: Categorical labels indicating the price range.

        Example:
            >>> ta.categorize_price_ranges(df["Close"])
        """
        # Compute normalized price position (0-100) over a long history
        price_position = self.extremes(source, 250)

        # Defensive handling: drop NaNs for quantile computation, keep original index
        pos = price_position
        pos_valid = pos.dropna()

        # If there's no valid data, return a series of NaN (preserve index)
        if pos_valid.empty:
            return pd.Series(index=pos.index, data=np.nan)

        # Compute quantile edges (0%,20%,40%,60%,80%,100%) and ensure uniqueness
        try:
            edges = np.unique(np.nanpercentile(pos_valid.values.astype(float), [0, 20, 40, 60, 80, 100]))
        except Exception:
            # Fallback: treat all as a single bin
            return pd.Series('Moderate', index=pos.index)

        # If all values are identical (only one unique edge), return a single-label series
        if len(edges) <= 1:
            return pd.Series('Moderate', index=pos.index)

        # Create descriptive labels based on number of bins available
        n_bins = len(edges) - 1
        default_labels = ['Very Low', 'Low', 'Moderate', 'High', 'Very High']
        labels = default_labels[:n_bins]

        # Use pd.cut with the computed edges. include_lowest ensures minimum falls into first bin.
        binned = pd.cut(pos, bins=edges, labels=labels, include_lowest=True)

        # Return binned categories; positions that were NaN remain NaN
        return binned

    # ──────────────────────────────────────────────────────────────────────
    #  Volatility
    # ──────────────────────────────────────────────────────────────────────

    def atr(
        self,
        high: pd.Series,
        low: pd.Series,
        close: pd.Series,
        length: int,
    ) -> pd.Series:
        """Calculate the Average True Range (ATR).

        ATR is the rolling mean of the True Range, which captures gap
        moves and intra-bar volatility.

        Args:
            high (pd.Series): High price series.
            low (pd.Series): Low price series.
            close (pd.Series): Close price series.
            length (int): Smoothing period (commonly 14).

        Returns:
            pd.Series: ATR values (same unit as price).

        Example:
            >>> ta.atr(df["High"], df["Low"], df["Close"], 14)
        """
        tr1 = high - low
        tr2 = abs(high - close.shift(1))
        tr3 = abs(low - close.shift(1))
        tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
        return tr.rolling(window=length).mean()

    def stdev(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the rolling standard deviation.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Window size.

        Returns:
            pd.Series: Standard-deviation values.

        Example:
            >>> ta.stdev(df["Close"], 20)
        """
        return source.rolling(window=length).std()

    # ──────────────────────────────────────────────────────────────────────
    #  Utility Functions
    # ──────────────────────────────────────────────────────────────────────

    def highest(self, source: pd.Series, length: int) -> pd.Series:
        """Return the rolling maximum over *length* periods.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Window size.

        Returns:
            pd.Series: Rolling highs.

        Example:
            >>> ta.highest(df["High"], 20)
        """
        return source.rolling(window=length).max()

    def lowest(self, source: pd.Series, length: int) -> pd.Series:
        """Return the rolling minimum over *length* periods.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Window size.

        Returns:
            pd.Series: Rolling lows.

        Example:
            >>> ta.lowest(df["Low"], 20)
        """
        return source.rolling(window=length).min()

    def change(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the difference from *length* periods ago.

        Args:
            source (pd.Series): Price or indicator series.
            length (int): Number of periods to look back.

        Returns:
            pd.Series: Period-over-period changes.

        Example:
            >>> ta.change(df["Close"], 1)
        """
        return source.diff(periods=length)

    def valuewhen(
        self,
        condition: pd.Series,
        value: pd.Series,
        occurrence: int,
    ) -> pd.Series:
        """Return the value of *value* when *condition* was last True.

        Equivalent to Pine Script's ``valuewhen()`` function.

        Args:
            condition (pd.Series): Boolean series indicating the event.
            value (pd.Series): Series from which to extract the value.
            occurrence (int): Which occurrence to return (``1`` = most recent).

        Returns:
            pd.Series: Captured values, forward-filled.

        Example:
            >>> ta.valuewhen(df["RSI"] > 70, df["Close"], 1)
        """
        return value.where(condition).shift(occurrence - 1)

    def barssince(self, condition: pd.Series) -> pd.Series:
        """Count the number of bars since *condition* was last True.

        Equivalent to Pine Script's ``barssince()`` function.

        Args:
            condition (pd.Series): Boolean series.

        Returns:
            pd.Series: Integer bar counts.  Resets to ``0`` each time the
            condition fires.

        Example:
            >>> ta.barssince(df["Close"] > df["Close"].shift(1))
        """
        condition_true = condition.astype(int)
        condition_true_cum_sum = condition_true.cumsum()
        condition_true_first_idx_by_cumsum = (
            condition_true_cum_sum.where(condition_true_cum_sum.diff() != 0).fillna(
                method="ffill"
            )
        )
        return condition_true_cum_sum - condition_true_first_idx_by_cumsum.fillna(0)

    def math_max(self, arg1: pd.Series, arg2: pd.Series) -> pd.Series:
        """Return the element-wise maximum of two series.

        Args:
            arg1 (pd.Series): First series or scalar.
            arg2 (pd.Series): Second series or scalar.

        Returns:
            pd.Series: Element-wise maxima.

        Example:
            >>> ta.math_max(df["Open"], df["Close"])
        """
        return pd.concat([arg1, arg2], axis=1).max(axis=1)

    def math_min(self, arg1: pd.Series, arg2: pd.Series) -> pd.Series:
        """Return the element-wise minimum of two series.

        Args:
            arg1 (pd.Series): First series or scalar.
            arg2 (pd.Series): Second series or scalar.

        Returns:
            pd.Series: Element-wise minima.

        Example:
            >>> ta.math_min(df["Open"], df["Close"])
        """
        return pd.concat([arg1, arg2], axis=1).min(axis=1)

    def math_round(self, source: pd.Series) -> pd.Series:
        """Round values to the nearest integer.

        Args:
            source (pd.Series): Numeric series.

        Returns:
            pd.Series: Rounded values.

        Example:
            >>> ta.math_round(df["Close"])
        """
        return source.round()

    def math_abs(self, source: pd.Series) -> pd.Series:
        """Return the absolute value of each element.

        Args:
            source (pd.Series): Numeric series.

        Returns:
            pd.Series: Absolute values.

        Example:
            >>> ta.math_abs(df["Close"].diff())
        """
        return source.abs()

    def math_sum(self, source: pd.Series, length: int) -> pd.Series:
        """Calculate the rolling sum over *length* periods.

        Args:
            source (pd.Series): Numeric series.
            length (int): Window size.

        Returns:
            pd.Series: Rolling sums.

        Example:
            >>> ta.math_sum(df["Volume"], 20)
        """
        return source.rolling(window=length).sum()

    # ──────────────────────────────────────────────────────────────────────
    #  Pivot Points
    # ──────────────────────────────────────────────────────────────────────

    def pivot_high(
        self,
        high: pd.Series,
        left: int,
        right: int,
        fill_pivots: bool = True,
    ) -> pd.Series:
        """Identify pivot highs based on left and right bar counts.

        A pivot high is a bar whose high is greater than the highest high
        of the *left* preceding bars **and** the *right* following bars.

        Args:
            high (pd.Series): High price series.
            left (int): Number of bars to the left of the candidate.
            right (int): Number of bars to the right of the candidate.
            fill_pivots (bool): If ``True`` (default), forward-fill the last
                detected pivot value so that every row has a value.

        Returns:
            pd.Series: Pivot-high prices.  Non-pivot rows are ``NaN``
            (or forward-filled if *fill_pivots* is ``True``).

        Example:
            >>> ta.pivot_high(df["High"], 5, 5)
        """
        left_max = high.rolling(window=left, min_periods=1).max().shift(1)
        right_max = high.rolling(window=right, min_periods=1).max().shift(-right)
        condition = (high > left_max) & (high > right_max)
        pivot_highs = high.where(condition, np.nan)
        if fill_pivots:
            pivot_highs = pivot_highs.fillna(method="ffill")
        return pivot_highs

    def pivot_low(
        self,
        low: pd.Series,
        left: int,
        right: int,
        fill_pivots: bool = True,
    ) -> pd.Series:
        """Identify pivot lows based on left and right bar counts.

        A pivot low is a bar whose low is less than the lowest low
        of the *left* preceding bars **and** the *right* following bars.

        Args:
            low (pd.Series): Low price series.
            left (int): Number of bars to the left of the candidate.
            right (int): Number of bars to the right of the candidate.
            fill_pivots (bool): If ``True`` (default), forward-fill the last
                detected pivot value so that every row has a value.

        Returns:
            pd.Series: Pivot-low prices.  Non-pivot rows are ``NaN``
            (or forward-filled if *fill_pivots* is ``True``).

        Example:
            >>> ta.pivot_low(df["Low"], 5, 5)
        """
        left_min = low.rolling(window=left, min_periods=1).min().shift(1)
        right_min = low.rolling(window=right, min_periods=1).min().shift(-right)
        condition = (low < left_min) & (low < right_min)
        pivot_lows = low.where(condition, np.nan)
        if fill_pivots:
            pivot_lows = pivot_lows.fillna(method="ffill")
        return pivot_lows

    def swinglow(
        self,
        src: pd.Series,
        left: int = 5,
        right: int = 5,
    ) -> pd.DataFrame:
        """Detect swing lows with bar indices (Pine Script swinglow equivalent).

        A swing low occurs when a price at a specific bar is lower than all
        *left* bars **before** it and all *right* bars **after** it. This
        method tracks both the pivot price and the bar index where it occurred,
        useful for divergence detection and pattern analysis.

        Unlike :meth:`pivot_low`, this returns detection events at the
        confirmation bar (``bar_index``) and stores the actual pivot bar index
        (``bar_index - right``) for historical analysis.

        Args:
            src (pd.Series): Source price series (typically Low).
            left (int): Number of bars to the left that must be higher.
            right (int): Number of bars to the right that must be higher.

        Returns:
            pd.DataFrame: DataFrame with columns:

            - ``swing_low_value``: Price at the swing low (``NaN`` if no pivot).
            - ``swing_low_index``: Integer position where swing low occurred.
            - ``swing_low_detected``: Boolean flag indicating detection.

        Note:
            Detection appears *right* bars after the actual pivot point because
            we need future bars to confirm the swing low. The first
            ``left + right`` bars will have ``NaN`` values.

        Example:
            >>> result = ta.swinglow(df['Low'], left=5, right=5)
            >>> detected = result[result['swing_low_detected']]
            >>> print(f"Found {len(detected)} swing lows")
        """
        n = len(src)
        swing_low_values = np.full(n, np.nan)
        swing_low_indices = np.full(n, np.nan)
        swing_low_detected = np.zeros(n, dtype=bool)

        src_array = src.values

        for bar_index in range(left + right, n):
            pivot_position = bar_index - right
            pl = src_array[pivot_position]
            is_pivot_low = True

            # Check left side: all values should be >= pl (excludes pivot itself)
            for i in range(1, left + 1):
                if src_array[pivot_position - i] < pl:
                    is_pivot_low = False
                    break

            # Check right side: all values should be >= pl (excludes pivot itself)
            if is_pivot_low:
                for j in range(1, right + 1):
                    if src_array[pivot_position + j] < pl:
                        is_pivot_low = False
                        break

            if is_pivot_low:
                swing_low_values[bar_index] = pl
                swing_low_indices[bar_index] = pivot_position
                swing_low_detected[bar_index] = True

        return pd.DataFrame(
            {
                "swing_low_value": swing_low_values,
                "swing_low_index": swing_low_indices,
                "swing_low_detected": swing_low_detected,
            },
            index=src.index,
        )

    def swinghigh(
        self,
        src: pd.Series,
        left: int = 5,
        right: int = 5,
    ) -> pd.DataFrame:
        """Detect swing highs with bar indices (Pine Script swinghigh equivalent).

        A swing high occurs when a price at a specific bar is higher than all
        *left* bars **before** it and all *right* bars **after** it. This
        method tracks both the pivot price and the bar index where it occurred,
        useful for divergence detection and pattern analysis.

        Unlike :meth:`pivot_high`, this returns detection events at the
        confirmation bar (``bar_index``) and stores the actual pivot bar index
        (``bar_index - right``) for historical analysis.

        Args:
            src (pd.Series): Source price series (typically High).
            left (int): Number of bars to the left that must be lower.
            right (int): Number of bars to the right that must be lower.

        Returns:
            pd.DataFrame: DataFrame with columns:

            - ``swing_high_value``: Price at the swing high (``NaN`` if no pivot).
            - ``swing_high_index``: Integer position where swing high occurred.
            - ``swing_high_detected``: Boolean flag indicating detection.

        Note:
            Detection appears *right* bars after the actual pivot point because
            we need future bars to confirm the swing high. The first
            ``left + right`` bars will have ``NaN`` values.

        Example:
            >>> result = ta.swinghigh(df['High'], left=5, right=5)
            >>> detected = result[result['swing_high_detected']]
            >>> print(f"Found {len(detected)} swing highs")
        """
        n = len(src)
        swing_high_values = np.full(n, np.nan)
        swing_high_indices = np.full(n, np.nan)
        swing_high_detected = np.zeros(n, dtype=bool)

        src_array = src.values

        for bar_index in range(left + right, n):
            pivot_position = bar_index - right
            ph = src_array[pivot_position]
            is_pivot_high = True

            # Check left side: all values should be <= ph (excludes pivot itself)
            for i in range(1, left + 1):
                if src_array[pivot_position - i] > ph:
                    is_pivot_high = False
                    break

            # Check right side: all values should be <= ph (excludes pivot itself)
            if is_pivot_high:
                for j in range(1, right + 1):
                    if src_array[pivot_position + j] > ph:
                        is_pivot_high = False
                        break

            if is_pivot_high:
                swing_high_values[bar_index] = ph
                swing_high_indices[bar_index] = pivot_position
                swing_high_detected[bar_index] = True

        return pd.DataFrame(
            {
                "swing_high_value": swing_high_values,
                "swing_high_index": swing_high_indices,
                "swing_high_detected": swing_high_detected,
            },
            index=src.index,
        )

    # ──────────────────────────────────────────────────────────────────────
    #  Trend Detection
    # ──────────────────────────────────────────────────────────────────────

    def rw_top(self, data: np.ndarray, curr_index: int, order: int) -> bool:
        """Check whether a local **top** exists at ``curr_index - order``.

        Uses a rolling-window comparison: the candidate bar must be higher
        than all *order* bars on each side.

        Args:
            data (np.ndarray): 1-D array of prices (typically Close or High).
            curr_index (int): Current bar index (the *confirmation* index).
            order (int): Number of bars on each side to compare.

        Returns:
            bool: ``True`` if a local top is confirmed, ``False`` otherwise.

        Example:
            >>> ta.rw_top(df["Close"].values, 50, 5)
        """
        if curr_index < order * 2 + 1:
            return False

        top = True
        k = curr_index - order
        v = data[k]
        for i in range(1, order + 1):
            if data[k + i] > v or data[k - i] > v:
                top = False
                break

        return top

    def rw_bottom(self, data: np.ndarray, curr_index: int, order: int) -> bool:
        """Check whether a local **bottom** exists at ``curr_index - order``.

        Symmetric counterpart of :meth:`rw_top`.

        Args:
            data (np.ndarray): 1-D array of prices.
            curr_index (int): Current bar index (confirmation index).
            order (int): Number of bars on each side to compare.

        Returns:
            bool: ``True`` if a local bottom is confirmed, ``False`` otherwise.

        Example:
            >>> ta.rw_bottom(df["Close"].values, 50, 5)
        """
        if curr_index < order * 2 + 1:
            return False

        bottom = True
        k = curr_index - order
        v = data[k]
        for i in range(1, order + 1):
            if data[k + i] < v or data[k - i] < v:
                bottom = False
                break

        return bottom

    def rw_extremes(
        self, data: np.ndarray, order: int
    ) -> Tuple[List[list], List[list]]:
        """Detect all local tops and bottoms in *data* using a rolling window.

        Iterates through every index and calls :meth:`rw_top` /
        :meth:`rw_bottom`.

        Args:
            data (np.ndarray): 1-D array of prices.
            order (int): Number of bars on each side for confirmation.

        Returns:
            Tuple[List[list], List[list]]: ``(tops, bottoms)`` where each
            element is a list of ``[confirmation_index, extreme_index,
            price]`` triplets.

        Example:
            >>> tops, bottoms = ta.rw_extremes(df["Close"].values, 5)
        """
        tops: List[list] = []
        bottoms: List[list] = []
        for i in range(len(data)):
            if self.rw_top(data, i, order):
                tops.append([i, i - order, data[i - order]])
            if self.rw_bottom(data, i, order):
                bottoms.append([i, i - order, data[i - order]])

        return tops, bottoms

    def detect_trend(
        self,
        df: pd.DataFrame,
        row_index: int,
        tops: List[list],
        bottoms: List[list],
    ) -> str:
        """Classify the trend at a specific bar.

        Uses the two most recent confirmed tops and bottoms (from
        :meth:`rw_extremes`) and the current close to determine the trend.

        Args:
            df (pd.DataFrame): OHLCV DataFrame with at least a ``'Close'``
                column.
            row_index (int): Integer index of the bar to evaluate.
            tops (List[list]): List of ``[confirmation_idx, extreme_idx,
                price]`` from :meth:`rw_extremes`.
            bottoms (List[list]): List of ``[confirmation_idx, extreme_idx,
                price]`` from :meth:`rw_extremes`.

        Returns:
            str: One of ``'Uptrend'``, ``'Downtrend'``, or ``'Sideways'``.

        Example:
            >>> tops, bottoms = ta.rw_extremes(df["Close"].values, 5)
            >>> ta.detect_trend(df, len(df) - 1, tops, bottoms)
            'Uptrend'
        """
        confirmed_tops = [t for t in tops if t[0] <= row_index]
        confirmed_bottoms = [b for b in bottoms if b[0] <= row_index]

        if len(confirmed_tops) < 2 or len(confirmed_bottoms) < 2:
            return "Sideways"

        last_top = confirmed_tops[-1][2]
        prev_top = confirmed_tops[-2][2]
        last_bottom = confirmed_bottoms[-1][2]
        prev_bottom = confirmed_bottoms[-2][2]
        current_close = df.loc[row_index, "Close"]

        if (current_close > last_top) or (
            (last_top > prev_top) and (current_close >= last_bottom)
        ):
            return "Uptrend"
        elif (current_close < last_bottom) or (
            (last_bottom < prev_bottom) and (current_close <= last_top)
        ):
            return "Downtrend"
        else:
            return "Sideways"

    # ──────────────────────────────────────────────────────────────────────
    #  BOS & CHoCH Detection
    # ──────────────────────────────────────────────────────────────────────

    def detect_bos_choch(
        self,
        df: pd.DataFrame,
        row_index: int,
        tops: List[list],
        bottoms: List[list],
    ) -> str:
        """Detect Break of Structure (BOS) or Change of Character (CHoCH).

        **BOS** signals trend continuation:

        * *Bullish BOS* – higher high broken with a strong green candle.
        * *Bearish BOS* – lower low broken with a strong red candle.

        **CHoCH** signals a potential reversal:

        * *Bullish CHoCH* – lower-high sequence broken to the upside.
        * *Bearish CHoCH* – higher-low sequence broken to the downside.

        Filters applied:
            * Candle body >= 60% of total range (strong candle).
            * Correct candle colour (green for bullish, red for bearish).
            * First break only (previous close must be on the non-broken side).

        Args:
            df (pd.DataFrame): OHLCV DataFrame with ``'Open'``, ``'High'``,
                ``'Low'``, ``'Close'`` columns.
            row_index (int): Bar index to evaluate.
            tops (List[list]): Output of :meth:`rw_extremes`.
            bottoms (List[list]): Output of :meth:`rw_extremes`.

        Returns:
            str: One of ``'Bullish BOS'``, ``'Bearish BOS'``,
            ``'Bullish CHoCH'``, ``'Bearish CHoCH'``, or
            ``'No Structure Change'``.

        Example:
            >>> ta.detect_bos_choch(df, len(df) - 1, tops, bottoms)
            'Bullish BOS'
        """
        confirmed_tops = [t for t in tops if t[0] <= row_index]
        confirmed_bottoms = [b for b in bottoms if b[0] <= row_index]

        if len(confirmed_tops) < 2 or len(confirmed_bottoms) < 2:
            return "No Structure Change"

        last_top = confirmed_tops[-1][2]
        prev_top = confirmed_tops[-2][2]
        last_bottom = confirmed_bottoms[-1][2]
        prev_bottom = confirmed_bottoms[-2][2]

        current_open = df.loc[row_index, "Open"]
        current_close = df.loc[row_index, "Close"]
        current_high = df.loc[row_index, "High"]
        current_low = df.loc[row_index, "Low"]
        prev_close = df.loc[row_index - 1, "Close"] if row_index > 0 else np.nan

        body = abs(current_close - current_open)
        candle_range = current_high - current_low

        if candle_range == 0 or body < 0.6 * candle_range:
            return "No Structure Change"

        if (
            (last_top > prev_top)
            and (current_close > last_top)
            and (prev_close < last_top)
            and (current_close > current_open)
        ):
            return "Bullish BOS"
        elif (
            (last_bottom < prev_bottom)
            and (current_close < last_bottom)
            and (prev_close > last_bottom)
            and (current_close < current_open)
        ):
            return "Bearish BOS"
        elif (
            (last_top <= prev_top)
            and (current_close > last_top)
            and (prev_close < last_top)
            and (current_close > current_open)
        ):
            return "Bullish CHoCH"
        elif (
            (last_bottom >= prev_bottom)
            and (current_close < last_bottom)
            and (prev_close > last_bottom)
            and (current_close < current_open)
        ):
            return "Bearish CHoCH"
        else:
            return "No Structure Change"

    # ──────────────────────────────────────────────────────────────────────
    #  Chart Formations Detection
    # ──────────────────────────────────────────────────────────────────────

    def detect_formations(
        self,
        df: pd.DataFrame,
        row_index: int,
        tops: List[list],
        bottoms: List[list],
    ) -> str:
        """Detect classical chart formations at a specific bar.

        Requires at least 4 confirmed tops **and** 4 confirmed bottoms.

        Supported formations:
            * **Head and Shoulders** / **Inverse Head and Shoulders**
            * **Double Top** / **Double Bottom** (tolerance < 3%)
            * **Bullish Continuation Triangle** / **Bearish Continuation Triangle**
            * **Bullish Reversal Triangle** / **Bearish Reversal Triangle**

        Args:
            df (pd.DataFrame): OHLCV DataFrame with a ``'Close'`` column.
            row_index (int): Bar index to evaluate.
            tops (List[list]): Output of :meth:`rw_extremes`.
            bottoms (List[list]): Output of :meth:`rw_extremes`.

        Returns:
            str: A descriptive string such as ``'Head and Shoulders'``,
            ``'Double Bottom'``, etc., or ``'No Formation Detected'``.

        Example:
            >>> ta.detect_formations(df, len(df) - 1, tops, bottoms)
            'No Formation Detected'
        """
        confirmed_tops = [t for t in tops if t[0] <= row_index]
        confirmed_bottoms = [b for b in bottoms if b[0] <= row_index]

        if len(confirmed_tops) < 4 or len(confirmed_bottoms) < 4:
            return "No Formation Detected"

        last_top = confirmed_tops[-1][2]
        prev_top = confirmed_tops[-2][2]
        prev_top1 = confirmed_tops[-3][2]
        prev_top2 = confirmed_tops[-4][2]

        last_bottom = confirmed_bottoms[-1][2]
        prev_bottom = confirmed_bottoms[-2][2]
        prev_bottom1 = confirmed_bottoms[-3][2]
        prev_bottom2 = confirmed_bottoms[-4][2]

        current_close = df.loc[row_index, "Close"]

        if (
            (prev_bottom >= prev_bottom1)
            and (prev_top1 > prev_top2)
            and (prev_top > prev_top1)
            and (last_bottom < prev_top1)
            and (last_top < prev_top)
            and (current_close < last_bottom)
        ):
            return "Head and Shoulders"
        elif (
            (prev_top <= prev_top1)
            and (prev_bottom1 < prev_bottom2)
            and (prev_bottom < prev_bottom1)
            and (last_top > prev_bottom1)
            and (last_bottom > prev_bottom)
            and (current_close > last_top)
        ):
            return "Inverse Head and Shoulders"
        elif (
            (prev_bottom >= prev_bottom1)
            and (prev_top > prev_top1)
            and (abs(last_top - prev_top) / prev_top < 0.03)
            and (current_close < last_bottom)
        ):
            return "Double Top"
        elif (
            (prev_top <= prev_top1)
            and (prev_bottom < prev_bottom1)
            and (abs(last_bottom - prev_bottom) / max(last_bottom, prev_bottom) < 0.03)
            and (current_close > last_top)
        ):
            return "Double Bottom"
        elif (
            (prev_bottom >= prev_bottom1)
            and (prev_top > prev_top1)
            and (last_bottom >= prev_bottom)
            and (last_top <= prev_top)
            and (current_close > last_top)
        ):
            return "Bullish Continuation Triangle"
        elif (
            (prev_bottom >= prev_bottom1)
            and (prev_top > prev_top1)
            and (last_bottom >= prev_bottom)
            and (last_top <= prev_top)
            and (current_close < last_bottom)
        ):
            return "Bearish Reversal Triangle"
        elif (
            (prev_top <= prev_top1)
            and (prev_bottom < prev_bottom1)
            and (last_top <= prev_top)
            and (last_bottom >= prev_bottom)
            and (current_close < last_bottom)
        ):
            return "Bearish Continuation Triangle"
        elif (
            (prev_top <= prev_top1)
            and (prev_bottom < prev_bottom1)
            and (last_top <= prev_top)
            and (last_bottom >= prev_bottom)
            and (current_close > last_top)
        ):
            return "Bullish Reversal Triangle"
        else:
            return "No Formation Detected"

    # ──────────────────────────────────────────────────────────────────────
    #  Candlestick Patterns
    # ──────────────────────────────────────────────────────────────────────

    def detect_candlestick_pattern(self, df: pd.DataFrame, row_index: int) -> str:
        """Detect Japanese candlestick patterns at a specific bar.

        Follows John Murphy's classical technical-analysis methodology.
        A 20-period TEMA is used to determine whether the market is in an
        up-move (close > TEMA) or down-move (close < TEMA), which
        disambiguates bullish vs. bearish patterns.

        **Bullish patterns** (detected below TEMA — after down-move):
            * Bullish Engulfing (no confirmation needed)
            * Bullish Three White Soldiers (no confirmation needed)
            * Hammer (next candle must close above hammer high)
            * Inverted Hammer (next candle must close above)
            * Bullish Harami (next candle must close above)
            * Dragonfly Doji (next candle must close above high)

        **Bearish patterns** (detected above TEMA — after up-move):
            * Bearish Engulfing (no confirmation needed)
            * Bearish Three Black Crows (no confirmation needed)
            * Shooting Star (next candle must close below low)
            * Hanging Man (next candle must close below)
            * Bearish Harami (next candle must close below)
            * Gravestone Doji (next candle must close below low)

        Args:
            df (pd.DataFrame): OHLCV DataFrame with ``'Open'``, ``'High'``,
                ``'Low'``, ``'Close'``, and ``'Volume'`` columns.  A
                ``'tema20'`` column will be added automatically if absent.
            row_index (int): Bar index to evaluate.  Must be >= 2 (needs
                at least two prior candles).

        Returns:
            str: A descriptive string such as ``'Bullish Engulfing'``,
            ``'Shooting Star'``, etc., or
            ``'Candlestick Pattern Not Detected'``.

        Note:
            Patterns requiring confirmation look ahead by one bar.  If
            *row_index* is the last row in *df*, those patterns cannot
            be confirmed and will not be returned.

        Example:
            >>> ta.detect_candlestick_pattern(df, len(df) - 2)
            'Hammer'
        """
        if "tema20" not in df.columns:
            df["tema20"] = self.tema(df["Close"], 20)

        if row_index < 2:
            return "Candlestick Pattern Not Detected"

        open_val = df.loc[row_index, "Open"]
        high_val = df.loc[row_index, "High"]
        low_val = df.loc[row_index, "Low"]
        close_val = df.loc[row_index, "Close"]
        tema20_val = df.loc[row_index, "tema20"]
        volume_val = df.loc[row_index, "Volume"]

        open_1 = df.loc[row_index - 1, "Open"]
        high_1 = df.loc[row_index - 1, "High"]
        low_1 = df.loc[row_index - 1, "Low"]
        close_1 = df.loc[row_index - 1, "Close"]

        open_2 = df.loc[row_index - 2, "Open"]
        high_2 = df.loc[row_index - 2, "High"]
        low_2 = df.loc[row_index - 2, "Low"]
        close_2 = df.loc[row_index - 2, "Close"]

        can_confirm = row_index < len(df) - 1
        if can_confirm:
            open_next = df.loc[row_index + 1, "Open"]
            high_next = df.loc[row_index + 1, "High"]
            low_next = df.loc[row_index + 1, "Low"]
            close_next = df.loc[row_index + 1, "Close"]

        if row_index >= 20:
            volume_avg = df.loc[row_index - 20 : row_index - 1, "Volume"].mean()
        else:
            volume_avg = volume_val

        body = abs(close_val - open_val)
        range_val = high_val - low_val
        upper_wick = high_val - max(open_val, close_val)
        lower_wick = min(open_val, close_val) - low_val

        # --- BULLISH ---
        if (
            (close_1 < open_1)
            and (close_val > open_val)
            and (open_val < close_1)
            and (close_val > open_1)
            and (close_val < tema20_val)
        ):
            return "Bullish Engulfing"
        elif (
            (close_2 > open_2)
            and (close_1 > open_1)
            and (close_val > open_val)
            and (close_1 > close_2)
            and (close_val > close_1)
            and (open_val > open_1)
            and (open_val < close_1)
            and (open_1 > open_2)
            and (open_1 < close_2)
            and (close_val < tema20_val)
        ):
            return "Bullish Three White Soldiers"
        elif (
            can_confirm
            and (close_val > open_val)
            and (lower_wick >= 2 * body)
            and (upper_wick <= body * 0.3)
            and (body > 0)
            and (close_val < tema20_val)
            and (close_next > high_val)
        ):
            return "Hammer"
        elif (
            can_confirm
            and (close_val > open_val)
            and (upper_wick >= 2 * body)
            and (lower_wick <= body * 0.3)
            and (body > 0)
            and (close_val < tema20_val)
            and (close_next > close_val)
        ):
            return "Inverted Hammer"
        elif (
            can_confirm
            and (close_1 < open_1)
            and (close_val > open_val)
            and (open_val > close_1)
            and (close_val < open_1)
            and (close_val < tema20_val)
            and (close_next > close_val)
        ):
            return "Bullish Harami"
        elif (
            can_confirm
            and (abs(close_val - open_val) <= range_val * 0.1)
            and (upper_wick <= range_val * 0.1)
            and (lower_wick >= range_val * 0.6)
            and (close_val < tema20_val)
            and (close_next > high_val)
        ):
            return "Dragonfly Doji"

        # --- BEARISH ---
        elif (
            (close_1 > open_1)
            and (close_val < open_val)
            and (open_val > close_1)
            and (close_val < open_1)
            and (close_val > tema20_val)
        ):
            return "Bearish Engulfing"
        elif (
            (close_2 < open_2)
            and (close_1 < open_1)
            and (close_val < open_val)
            and (close_1 < close_2)
            and (close_val < close_1)
            and (open_val < open_1)
            and (open_val > close_1)
            and (open_1 < open_2)
            and (open_1 > close_2)
            and (close_val > tema20_val)
        ):
            return "Bearish Three Black Crows"
        elif (
            can_confirm
            and (close_val < open_val)
            and (upper_wick >= 2 * body)
            and (lower_wick <= body * 0.3)
            and (body > 0)
            and (close_val > tema20_val)
            and (close_next < low_val)
        ):
            return "Shooting Star"
        elif (
            can_confirm
            and (close_val < open_val)
            and (lower_wick >= 2 * body)
            and (upper_wick <= body * 0.3)
            and (body > 0)
            and (close_val > tema20_val)
            and (close_next < close_val)
        ):
            return "Hanging Man"
        elif (
            can_confirm
            and (close_1 > open_1)
            and (close_val < open_val)
            and (open_val < close_1)
            and (close_val > open_1)
            and (close_val > tema20_val)
            and (close_next < close_val)
        ):
            return "Bearish Harami"
        elif (
            can_confirm
            and (abs(close_val - open_val) <= range_val * 0.1)
            and (lower_wick <= range_val * 0.1)
            and (upper_wick >= range_val * 0.6)
            and (close_val > tema20_val)
            and (close_next < low_val)
        ):
            return "Gravestone Doji"
        else:
            return "Candlestick Pattern Not Detected"
