Notebooks/Triangle Pattern Detection
Signals·Patterns·Intermediate

Triangle Pattern Detection

Algorithmically detect ascending, descending, and symmetrical triangles using pivot-based trendline fitting.

triangleschart patternstrendlines

Strategy — Triangle Pattern Detection


1. Dependency Installation

This section details the installation of essential Python libraries. These dependencies facilitate data manipulation, numerical computations, advanced plotting, signal processing, and statistical analysis, all critical for the implementation and evaluation of the triangle pattern detection strategy.

[20]
# Install necessary libraries for data handling, numerical operations, plotting, and signal processing.
!pip install pandas numpy plotly scipy
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
Requirement already satisfied: scipy in /usr/local/lib/python3.12/dist-packages (1.16.3)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

2. Library Imports

This section imports the prerequisite Python libraries. Each library fulfills a specific functional requirement, ranging from data manipulation and numerical operations to advanced visualization and statistical computations.

[21]
import warnings; warnings.filterwarnings("ignore") # Suppress warnings for cleaner output
import pandas as pd                                # Data manipulation and analysis
import numpy as np                                 # Numerical operations, especially for array handling
import plotly.graph_objects as go                  # Interactive charting, specifically for candlestick plots
from plotly.subplots import make_subplots          # Creating subplots in Plotly
from scipy.signal import argrelextrema             # Detecting local extrema (peaks and troughs) in data
from scipy.stats import linregress                 # Performing linear regression to find trendlines

3. Strategy Overview: Triangle Pattern Detection

Triangle patterns represent continuation or reversal formations characterized by converging trendlines established across successive local price highs and lows. The classification of these patterns, along with their implied market bias, is as follows:

Pattern TypeUpper Trendline BehaviorLower Trendline BehaviorMarket Bias (Expected Outcome)
Ascending TriangleHorizontal (Flat)Rising SlopeBullish Continuation
Descending TriangleFalling SlopeHorizontal (Flat)Bearish Continuation
Symmetrical TriangleFalling SlopeRising SlopeNeutral (Breakout Dependent)

Detection Methodology

The detection algorithm proceeds with the following logical steps:

  1. Local Extremum Identification: Identify significant local price highs (peaks) and local price lows (troughs) within the data series.
  2. Trendline Regression: For a defined lookback window, perform linear regression independently on sequences of recent local high prices to define the upper trendline and on recent local low prices to define the lower trendline.
  3. Pattern Classification: Classify the observed pattern by analyzing the calculated slopes of both the upper and lower trendlines. A slope is considered "flat" if its normalized magnitude falls below a predefined slope_threshold.
  4. Signal Generation: Generate a directional trading signal based on the identified triangle pattern:
    • A bullish signal (+1) is generated upon detecting an Ascending Triangle, characterized by a flat upper trendline and a rising lower trendline.
    • A bearish signal (−1) is generated upon detecting a Descending Triangle, characterized by a falling upper trendline and a flat lower trendline.
    • No direct signal is generated for a Symmetrical Triangle until a clear price breakout occurs.

Methodological Considerations

Robust identification of trendlines through linear regression necessitates a sufficient number of data points. For reliable classification, a minimum of four alternating local extrema (e.g., two peaks and two troughs) within the regression window is recommended to mitigate noise and spurious fits.

4. Data Generation

This section defines a function designed to generate synthetic Open-High-Low-Close-Volume (OHLCV) price data. The data generation process simulates a geometric random walk, producing a dataset that exhibits realistic price dynamics suitable for rigorous testing and validation of the triangle pattern detection algorithm, independent of external data feeds.

[22]
def generate_data(periods: int) -> pd.DataFrame:
    """
    Generate synthetic OHLCV price data using a geometric random walk,
    with intentionally inserted triangle patterns.

    Parameters
    ----------
    periods : int
        Number of 1-minute bars to generate.

    Returns
    -------
    pd.DataFrame
        DataFrame with columns: open, high, low, close, volume, datetime.
    """
    start_date     = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
    price_data = []
    last_close = 42000 # Starting price for the random walk simulation

    # Parameters for injecting patterns
    pattern_start_index_1 = int(periods * 0.2)
    pattern_end_index_1   = int(periods * 0.3)
    pattern_start_index_2 = int(periods * 0.6)
    pattern_end_index_2   = int(periods * 0.7)

    for i in range(periods):
        # Base random walk noise for periods without explicit patterns
        # Introduce a bit more volatility for a realistic look
        drift = np.random.normal(0, last_close * 0.0002)
        volatility = last_close * np.random.uniform(0.0003, 0.0007)

        current_open  = last_close
        current_close = current_open + drift + np.random.normal(0, volatility)

        # Ensure prices are positive
        current_open  = max(1, current_open)
        current_close = max(1, current_close)

        # --- Injecting an Ascending Triangle Pattern --- (more natural)
        if pattern_start_index_1 < i < pattern_end_index_1:
            # Base price for pattern now depends on the current random walk level
            base_pattern_price = last_close

            # Flat top resistance with added noise
            flat_resistance = base_pattern_price * 1.01 + np.random.normal(0, base_pattern_price * 0.0001)

            # Rising support with added noise and clearer progression
            rising_support_start = base_pattern_price * 0.98
            rising_support_end   = base_pattern_price * 1.005 # Can rise slightly above base
            low_target = rising_support_start + (rising_support_end - rising_support_start) * \
                         ((i - pattern_start_index_1) / (pattern_end_index_1 - pattern_start_index_1))
            low_target += np.random.normal(0, base_pattern_price * 0.0001) # Add noise

            # Ensure prices stay within the converging pattern, with some noise
            current_high = flat_resistance + abs(np.random.normal(0, base_pattern_price * 0.0001))
            current_low  = low_target - abs(np.random.normal(0, base_pattern_price * 0.0001))

            current_open  = np.random.uniform(current_low, current_high)
            current_close = np.random.uniform(current_low, current_high)
            current_close = np.clip(current_close, low_target, flat_resistance) # Ensure within pattern range
            current_open  = current_close + np.random.normal(0, current_close * 0.0001) # Small open-close movement

            high_price = max(current_high, current_open, current_close)
            low_price  = min(current_low, current_open, current_close)

            last_close = current_close

        # --- Injecting a Descending Triangle Pattern --- (more natural)
        elif pattern_start_index_2 < i < pattern_end_index_2:
            # Base price for pattern now depends on the current random walk level
            base_pattern_price = last_close

            # Falling resistance with added noise and clearer progression
            falling_resistance_start = base_pattern_price * 1.02
            falling_resistance_end   = base_pattern_price * 0.995 # Can fall slightly below base
            high_target = falling_resistance_start - (falling_resistance_start - falling_resistance_end) * \
                          ((i - pattern_start_index_2) / (pattern_end_index_2 - pattern_start_index_2))
            high_target += np.random.normal(0, base_pattern_price * 0.0001) # Add noise

            # Flat bottom support with added noise
            flat_support = base_pattern_price * 0.99 - np.random.normal(0, base_pattern_price * 0.0001)

            # Ensure prices stay within the converging pattern, with some noise
            current_high = high_target + abs(np.random.normal(0, base_pattern_price * 0.0001))
            current_low  = flat_support - abs(np.random.normal(0, base_pattern_price * 0.0001))

            current_open  = np.random.uniform(current_low, current_high)
            current_close = np.random.uniform(current_low, current_high)
            current_close = np.clip(current_close, flat_support, high_target) # Ensure within pattern range
            current_open  = current_close + np.random.normal(0, current_close * 0.0001) # Small open-close movement

            high_price = max(current_high, current_open, current_close)
            low_price  = min(current_low, current_open, current_close)

            last_close = current_close

        else:
            # Default random walk logic for other periods
            body_high   = max(current_open, current_close)
            body_low    = min(current_open, current_close)
            # More natural spread for high/low based on the body of the candle
            high_price  = max(body_high + abs(np.random.normal(0, volatility * 0.5)), current_open, current_close)
            low_price   = min(body_low  - abs(np.random.normal(0, volatility * 0.5)), current_open, current_close)
            last_close = current_close

        # Ensure high_price is strictly greater than or equal to low_price
        if high_price < low_price:
            high_price, low_price = low_price, high_price

        # Append generated OHLC data, ensuring prices are at least 1
        price_data.append({
            "open":  max(1, int(current_open)),
            "high":  max(1, int(high_price)),
            "low":   max(1, int(low_price)),
            "close": max(1, int(current_close)),
        })

    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"]   = np.random.uniform(100.0, 500.0, periods) # Generate random volume data
    df["datetime"] = df.index.to_series() # Add datetime column as a regular column for easier access
    return df.reset_index(drop=True)

# Generate a DataFrame with 500 periods of synthetic data
df = generate_data(500)
display(df.head()) # Display the first 5 rows of the generated DataFrame
open high low close volume datetime
0 42000 42001 41975 41982 354.601291 2024-01-01 00:00:00+00:00
1 41982 42006 41970 41993 396.770241 2024-01-01 00:01:00+00:00
2 41993 42009 41980 42003 383.166798 2024-01-01 00:02:00+00:00
3 42003 42003 41997 42003 473.709219 2024-01-01 00:03:00+00:00
4 42003 42004 41968 41985 315.112190 2024-01-01 00:04:00+00:00

4. Strategy Function: Triangle Pattern Detection

This section details the primary function, triangle_patterns_detection, which is responsible for identifying and classifying triangle patterns within price data. The function operates by detecting local extrema, subsequently performing linear regressions on these points to establish trendlines, classifying pattern types based on trendline slopes, and ultimately generating a corresponding directional signal.

Key Components and Concepts:

  • linregress(indices, prices): This statistical function computes a least-squares regression line across a given series of local extrema (defined by their indices and corresponding prices). The derived slope and its sign are critical for accurately determining the direction and gradient of the identified trendline.
  • Normalized Slope (norm_slope = slope / mean_price): To ensure the slope_threshold parameter maintains universal applicability across diverse financial instruments and varying price magnitudes, the calculated trendline slope is normalized. This normalization is achieved by dividing the raw slope by the mean price of the extrema used in its calculation. This process renders slope_threshold a dimensionless parameter, enhancing its transferability and consistency.
[23]
def triangle_patterns_detection(
    df: pd.DataFrame,
    order: int = 8,
    slope_threshold: float = 0.01,
    lookback: int = 5,
) -> pd.DataFrame:
    """
    Detect Ascending, Descending, and Symmetrical triangle patterns.

    Core logic
    ----------
    1. Identify local highs and lows using argrelextrema with the given order.
    2. Over a rolling lookback window of extrema, fit linear regressions to
       the sequences of local high prices and local low prices independently.
    3. Classify the triangle type by comparing the absolute slopes of both lines
       against slope_threshold (near-zero slope = "flat").
    4. Emit a directional signal based on the triangle type.

    Parameters
    ----------
    df : pd.DataFrame
        OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
    order : int
        Bars on each side required to qualify as a local extremum.
        A larger order results in fewer, more significant extrema.
    slope_threshold : float
        Fractional slope magnitude below which a trendline is considered flat.
        This threshold determines the sensitivity for identifying horizontal lines.
    lookback : int
        Number of recent extrema to include in each regression window.
        This defines the window for trendline fitting and pattern recognition.

    Returns
    -------
    pd.DataFrame
        Original DataFrame extended with: pattern, signal.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)
    df["pattern"] = "none" # Initialize 'pattern' column with default value
    df["signal"]  = 0      # Initialize 'signal' column with default value

    highs  = df["high"].values # Extract high prices as a NumPy array for efficient processing
    lows   = df["low"].values  # Extract low prices as a NumPy array for efficient processing

    # Detect local peaks (highs) and troughs (lows) using scipy.signal.argrelextrema
    # argrelextrema returns indices of local extrema based on the 'order' parameter
    peak_idx   = argrelextrema(highs, np.greater, order=order)[0]
    trough_idx = argrelextrema(lows,  np.less,    order=order)[0]

    # ── Rolling triangle classification ──────────────────────────────────────
    # Iterate through detected peaks to form rolling windows for trendline analysis
    for i in range(lookback, len(peak_idx)):
        # Select recent peak indices for the upper trendline regression
        ph_i = peak_idx[i - lookback: i]
        if len(ph_i) < 2: # Require at least two points to perform linear regression
            continue

        # Perform linear regression on the selected recent peak prices
        # slope_h represents the slope of the upper trendline
        slope_h, _, _, _, _ = linregress(ph_i, highs[ph_i])
        # Normalize the slope by the mean price of the peaks to make it scale-independent
        norm_slope_h = slope_h / np.mean(highs[ph_i])

        # Identify trough indices that fall within the same time window as the current set of peaks
        pt_i = trough_idx[(trough_idx >= ph_i[0]) & (trough_idx <= ph_i[-1])]
        if len(pt_i) < 2: # Require at least two points to perform linear regression
            continue

        # Perform linear regression on the selected recent trough prices (lower trendline)
        # slope_l represents the slope of the lower trendline
        slope_l, _, _, _, _ = linregress(pt_i, lows[pt_i])
        # Normalize the slope by the mean price of the troughs
        norm_slope_l = slope_l / np.mean(lows[pt_i])

        # Determine the bar index where the pattern is considered confirmed and a signal is placed.
        # This is typically after the last peak in the current lookback window.
        signal_bar = min(peak_idx[i - 1] + 1, len(df) - 1)

        # --- Pattern Classification Logic ---

        # Ascending triangle: characterized by a flat (near-zero slope) upper trendline
        # and a rising (positive slope) lower trendline.
        # This pattern typically suggests bullish continuation potential.
        if abs(norm_slope_h) < slope_threshold and norm_slope_l > slope_threshold:
            df.at[signal_bar, "pattern"] = "ascending_triangle"
            df.at[signal_bar, "signal"]  = 1 # Assign a bullish signal (+1)

        # Descending triangle: characterized by a falling (negative slope) upper trendline
        # and a flat (near-zero slope) lower trendline.
        # This pattern typically suggests bearish continuation potential.
        elif norm_slope_h < -slope_threshold and abs(norm_slope_l) < slope_threshold:
            df.at[signal_bar, "pattern"] = "descending_triangle"
            df.at[signal_bar, "signal"]  = -1 # Assign a bearish signal (-1)

        # Symmetrical triangle: characterized by converging slopes (falling upper, rising lower).
        # This pattern is typically neutral; a directional signal is only generated upon breakout.
        elif norm_slope_h < -slope_threshold and norm_slope_l > slope_threshold:
            df.at[signal_bar, "pattern"] = "symmetrical_triangle"
            df.at[signal_bar, "signal"]  = 0 # Assign a neutral signal (0), awaiting breakout

    return df

# Apply the triangle pattern detection function to the generated data
df_signals = triangle_patterns_detection(df, order=2, slope_threshold=0.0001, lookback=3)

print("---\nPattern Distribution ---")
print(df_signals["pattern"].value_counts()) # Display counts of each pattern type identified
print("\n--- Signal Distribution ---")
print(df_signals["signal"].value_counts())  # Display counts of each signal type generated
---
Pattern Distribution ---
pattern
none                    495
descending_triangle       3
symmetrical_triangle      2
Name: count, dtype: int64

--- Signal Distribution ---
signal
 0    497
-1      3
Name: count, dtype: int64

5. Visualization of Detected Patterns

[25]
# Separate buy and sell signals from the DataFrame for distinct plotting
buy_signals  = df_signals[df_signals["signal"] ==  1]
sell_signals = df_signals[df_signals["signal"] == -1]
# Separate symmetrical triangle signals for distinct plotting
symmetrical_signals = df_signals[df_signals["pattern"] == "symmetrical_triangle"]

# Create a figure with two subplots: one for price/patterns and one for the signal
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
    subplot_titles=["Price Action with Triangle Pattern Signals", "Generated Signal"],
    row_heights=[0.7, 0.3])

# Add Candlestick chart for price action in the upper subplot
fig.add_trace(go.Candlestick(
    x=df_signals["datetime"],
    open=df_signals["open"], high=df_signals["high"],
    low=df_signals["low"],   close=df_signals["close"],
    name="Price"), row=1, col=1)

# Add scatter markers for Ascending (bullish) triangle buy signals
# Markers are placed slightly below the low price for visibility
fig.add_trace(go.Scatter(
    x=buy_signals["datetime"], y=buy_signals["low"] * 0.999,
    mode="markers", marker=dict(symbol="triangle-up", size=10, color="green"), # Green triangle-up symbol
    name="Ascending (Buy Signal)"), row=1, col=1)

# Add scatter markers for Descending (bearish) triangle sell signals
# Markers are placed slightly above the high price for visibility
fig.add_trace(go.Scatter(
    x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
    mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"), # Red triangle-down symbol
    name="Descending (Sell Signal)"), row=1, col=1)

# Add scatter markers for Symmetrical triangle (neutral) signals
# Markers are placed in the middle of high and low for visibility
fig.add_trace(go.Scatter(
    x=symmetrical_signals["datetime"], y=(symmetrical_signals["low"] + symmetrical_signals["high"]) / 2,
    mode="markers", marker=dict(symbol="diamond", size=10, color="orange"), # Orange diamond symbol
    name="Symmetrical (Neutral Signal)"), row=1, col=1)

# Add line plot for the numerical signal output in the lower subplot
fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["signal"],
    mode="lines", name="Signal Value", line=dict(color="purple", width=1)),
    row=2, col=1)
# Add a horizontal dashed line at y=0 in the signal subplot for reference
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)

# Update layout settings for better presentation and interaction
fig.update_layout(
    title_text="Triangle Pattern Detection Strategy Visualization", # Main plot title
    xaxis_rangeslider_visible=False, # Hide the range slider for a cleaner aesthetic
    height=700, xaxis2_title="Datetime", # Set plot height and label the x-axis for the second subplot
)
fig.show() # Display the interactive plot
[24]