Signals·Chart Pattern Detection·Intermediate

Triangle Patterns Detection

Detect ascending, descending, and symmetrical triangle chart patterns using converging trendline fitting on swing highs and lows, with breakout direction anticipation and measured-move price target projection upon confirmed breakout.

pattern-recognitiontrading-signals

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.

[ ]
# 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.2)
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.2)
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.

[ ]
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.

[ ]
import pandas as pd
import numpy as np

def generate_data(periods: int = 400) -> pd.DataFrame:
    np.random.seed(42)
    start = pd.Timestamp("2024-01-01 09:00", tz="UTC")
    idx   = pd.date_range(start, periods=periods, freq="1min")

    base  = 42000.0
    price_data = []
    last_close = base

    segs = {
        "noise_pre":    int(periods * 0.10),
        "asc_form":     int(periods * 0.18),
        "asc_break":    int(periods * 0.05),
        "noise_mid1":   int(periods * 0.08),
        "desc_form":    int(periods * 0.18),
        "desc_break":   int(periods * 0.05),
        "noise_mid2":   int(periods * 0.08),
        "symm_form":    int(periods * 0.18),
        "symm_break":   int(periods * 0.05),
        "noise_post":   0,  # remainder
    }
    segs["noise_post"] = periods - sum(segs.values())

    def candle(o, c, noise=8.0):
        h = max(o, c) + abs(np.random.normal(0, noise))
        l = min(o, c) - abs(np.random.normal(0, noise))
        return o, h, l, c

    bar = 0
    for seg, length in segs.items():
        for i in range(length):
            t = i / max(length - 1, 1)  # 0→1 within segment

            if "noise" in seg:
                ret = np.random.normal(0, 0.0003)
                c = last_close * (1 + ret)
                o, h, l, c = candle(last_close, c, noise=12)

            elif seg == "asc_form":
                # Flat top ~42400, rising bottom 41800→42350
                top = 42400.0
                bot = 41800.0 + (42350.0 - 41800.0) * t
                mid = bot + (top - bot) * (0.5 + 0.5 * np.sin(t * 4 * np.pi))
                c = mid + np.random.normal(0, 4)
                o, h, l, c = candle(last_close, c, noise=6)
                h = min(h, top + 6)
                l = max(l, bot - 6)

            elif seg == "asc_break":
                # Breakout above 42400 → 42700
                c = 42400 + (42700 - 42400) * t + np.random.normal(0, 6)
                o, h, l, c = candle(last_close, c, noise=8)

            elif seg == "desc_form":
                # Falling top 42700→42200, flat bottom ~42100
                top = 42700.0 - (42700.0 - 42200.0) * t
                bot = 42100.0
                mid = bot + (top - bot) * (0.5 + 0.5 * np.sin(t * 4 * np.pi))
                c = mid + np.random.normal(0, 4)
                o, h, l, c = candle(last_close, c, noise=6)
                h = min(h, top + 6)
                l = max(l, bot - 6)

            elif seg == "desc_break":
                # Breakdown below 42100 → 41700
                c = 42100 - (42100 - 41700) * t + np.random.normal(0, 6)
                o, h, l, c = candle(last_close, c, noise=8)

            elif seg == "symm_form":
                # Falling top 41900→41600, rising bottom 41300→41600
                top = 41900.0 - (41900.0 - 41600.0) * t
                bot = 41300.0 + (41600.0 - 41300.0) * t
                mid = bot + (top - bot) * (0.5 + 0.5 * np.sin(t * 4 * np.pi))
                c = mid + np.random.normal(0, 4)
                o, h, l, c = candle(last_close, c, noise=5)
                h = min(h, top + 5)
                l = max(l, bot - 5)

            elif seg == "symm_break":
                # Bullish breakout → 42000
                c = 41600 + (42000 - 41600) * t + np.random.normal(0, 6)
                o, h, l, c = candle(last_close, c, noise=8)

            h = max(o, c, h)
            l = min(o, c, l)
            price_data.append({"open": round(o, 2), "high": round(h, 2),
                                "low": round(l, 2),  "close": round(c, 2)})
            last_close = c
            bar += 1

    df = pd.DataFrame(price_data, index=idx[:len(price_data)])
    df.index.name = "datetime"
    df["volume"] = np.random.uniform(100, 500, len(df))
    return df

df = generate_data(400)
print(f"Shape: {df.shape}")
display(df.head())
Shape: (400, 5)
open high low close volume
datetime
2024-01-01 09:00:00+00:00 42000.00 42007.92 41992.23 42006.26 117.365013
2024-01-01 09:01:00+00:00 42006.26 42028.26 42003.45 42025.45 353.260550
2024-01-01 09:02:00+00:00 42025.45 42054.57 42019.82 42045.36 480.561337
2024-01-01 09:03:00+00:00 42045.36 42057.77 42039.77 42052.21 340.644728
2024-01-01 09:04:00+00:00 42052.21 42078.22 42031.51 42055.26 427.675544

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.
[ ]
from scipy.signal import argrelextrema
from scipy.stats  import linregress

def triangle_patterns_detection(
    df: pd.DataFrame,
    order: int           = 1,
    slope_threshold: float = 1e-4,
    lookback: int        = 3,
    breakout_lookforward: int  = 15,
    breakout_factor: float     = 0.0015,
) -> pd.DataFrame:
    df = df.copy()
    df["pattern"] = "none"
    df["signal"]  = 0

    highs  = df["high"].values
    lows   = df["low"].values
    closes = df["close"].values
    pos    = np.arange(len(df))          # integer positions for regression x-axis

    peak_idx   = argrelextrema(highs, np.greater, order=order)[0]
    trough_idx = argrelextrema(lows,  np.less,    order=order)[0]

    patterns = []

    for i in range(lookback, len(peak_idx)):
        ph = peak_idx[i - lookback: i]
        if len(ph) < 2:
            continue

        pt = trough_idx[(trough_idx >= ph[0]) & (trough_idx <= ph[-1])]
        if len(pt) < 2:
            continue

        sl_h, ic_h, *_ = linregress(ph, highs[ph])
        sl_l, ic_l, *_ = linregress(pt, lows[pt])

        norm_h = sl_h / np.mean(highs[ph])
        norm_l = sl_l / np.mean(lows[pt])

        end_bar = int(max(ph[-1], pt[-1]))
        start_bar = int(min(ph[0], pt[0]))

        if abs(norm_h) < slope_threshold and norm_l > slope_threshold:
            ptype, expected = "ascending_triangle",  1
        elif norm_h < -slope_threshold and abs(norm_l) < slope_threshold:
            ptype, expected = "descending_triangle", -1
        elif norm_h < -slope_threshold and norm_l > slope_threshold:
            ptype, expected = "symmetrical_triangle", 0
        else:
            continue

        patterns.append(dict(
            type=ptype, start_bar=start_bar, end_bar=end_bar,
            expected=expected,
            sl_h=sl_h, ic_h=ic_h,
            sl_l=sl_l, ic_l=ic_l,
            ph=ph, pt=pt,
        ))

    # Breakout detection
    for p in patterns:
        eb = p["end_bar"]
        for k in range(eb + 1, min(len(df), eb + breakout_lookforward + 1)):
            upper = p["sl_h"] * k + p["ic_h"]
            lower = p["sl_l"] * k + p["ic_l"]
            c = closes[k]
            sig = 0
            if p["type"] == "ascending_triangle" and c > upper * (1 + breakout_factor):
                sig = 1
            elif p["type"] == "descending_triangle" and c < lower * (1 - breakout_factor):
                sig = -1
            elif p["type"] == "symmetrical_triangle":
                if c > upper * (1 + breakout_factor):
                    sig = 1
                elif c < lower * (1 - breakout_factor):
                    sig = -1
            if sig != 0:
                df.iloc[k, df.columns.get_loc("pattern")] = p["type"]
                df.iloc[k, df.columns.get_loc("signal")]  = sig
                break

    df.attrs["patterns"] = patterns   # attach for charting
    return df

df_signals = triangle_patterns_detection(df)
print(df_signals["pattern"].value_counts())
print(df_signals["signal"].value_counts())
pattern
none                    395
ascending_triangle        2
descending_triangle       2
symmetrical_triangle      1
Name: count, dtype: int64
signal
 0    395
 1      3
-1      2
Name: count, dtype: int64

5. Visualization of Detected Patterns

[ ]
import plotly.graph_objects as go
from plotly.subplots import make_subplots

patterns = df_signals.attrs.get("patterns", [])
idx      = df_signals.index   # DatetimeIndex

buy_mask  = df_signals["signal"] ==  1
sell_mask = df_signals["signal"] == -1

fig = make_subplots(
    rows=2, cols=1, shared_xaxes=True,
    row_heights=[0.75, 0.25],
    vertical_spacing=0.04,
    subplot_titles=["Price Action — Triangle Patterns", "Signal"],
)

# ── Candlestick ──────────────────────────────────────────────────────────────
fig.add_trace(go.Candlestick(
    x=idx,
    open=df_signals["open"], high=df_signals["high"],
    low=df_signals["low"],   close=df_signals["close"],
    increasing_line_color="#26a69a", decreasing_line_color="#ef5350",
    name="Price",
), row=1, col=1)

# ── Trendlines & shading for each detected pattern ──────────────────────────
COLORS = {
    "ascending_triangle":  {"line": "#1565C0", "fill": "rgba(21,101,192,0.08)"},
    "descending_triangle": {"line": "#B71C1C", "fill": "rgba(183,28,28,0.08)"},
    "symmetrical_triangle":{"line": "#F57F17", "fill": "rgba(245,127,23,0.08)"},
}

drawn_types = set()
for p in patterns:
    sb, eb = p["start_bar"], p["end_bar"]
    if sb >= len(idx) or eb >= len(idx):
        continue
    ptype = p["type"]
    col   = COLORS[ptype]
    label = ptype.replace("_", " ").title()

    x_bars = np.arange(sb, eb + 1)
    x_dt   = idx[x_bars]

    upper_y = p["sl_h"] * x_bars + p["ic_h"]
    lower_y = p["sl_l"] * x_bars + p["ic_l"]

    show_leg = ptype not in drawn_types
    drawn_types.add(ptype)

    # Upper trendline
    fig.add_trace(go.Scatter(
        x=x_dt, y=upper_y, mode="lines",
        line=dict(color=col["line"], width=1.5, dash="solid"),
        name=f"{label} – Upper", showlegend=show_leg,
        legendgroup=ptype,
    ), row=1, col=1)

    # Lower trendline
    fig.add_trace(go.Scatter(
        x=x_dt, y=lower_y, mode="lines",
        line=dict(color=col["line"], width=1.5, dash="solid"),
        name=f"{label} – Lower", showlegend=False,
        legendgroup=ptype,
        fill="tonexty", fillcolor=col["fill"],
    ), row=1, col=1)

# ── Breakout shading box ─────────────────────────────────────────────────────
for _, row in df_signals[buy_mask | sell_mask].iterrows():
    bar_pos = df_signals.index.get_loc(row.name)
    if bar_pos + 1 >= len(idx):
        continue
    x0 = idx[bar_pos]
    x1 = idx[min(bar_pos + 12, len(idx) - 1)]
    color = "rgba(38,166,154,0.12)" if row["signal"] == 1 else "rgba(239,83,80,0.12)"
    border = "#26a69a" if row["signal"] == 1 else "#ef5350"
    fig.add_vrect(x0=x0, x1=x1, fillcolor=color,
                  line_width=1, line_color=border, row=1, col=1)

# ── Signal markers ───────────────────────────────────────────────────────────
fig.add_trace(go.Scatter(
    x=idx[buy_mask], y=df_signals.loc[buy_mask, "low"] * 0.9992,
    mode="markers+text",
    marker=dict(symbol="triangle-up", size=12, color="#26a69a",
                line=dict(color="white", width=1)),
    text="▲ BUY", textposition="bottom center",
    textfont=dict(size=9, color="#26a69a"),
    name="Bullish Breakout",
), row=1, col=1)

fig.add_trace(go.Scatter(
    x=idx[sell_mask], y=df_signals.loc[sell_mask, "high"] * 1.0008,
    mode="markers+text",
    marker=dict(symbol="triangle-down", size=12, color="#ef5350",
                line=dict(color="white", width=1)),
    text="▼ SELL", textposition="top center",
    textfont=dict(size=9, color="#ef5350"),
    name="Bearish Breakout",
), row=1, col=1)

# ── Signal subplot ───────────────────────────────────────────────────────────
fig.add_trace(go.Bar(
    x=idx, y=df_signals["signal"],
    marker_color=np.where(df_signals["signal"] > 0, "#26a69a", "#ef5350"),
    name="Signal",
), row=2, col=1)
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)

# ── Layout ───────────────────────────────────────────────────────────────────
fig.update_layout(
    title="Triangle Pattern Detection — Synthetic OHLCV",
    xaxis_rangeslider_visible=False,
    height=750,
    template="plotly_dark",
    legend=dict(orientation="h", y=1.02, x=0),
    margin=dict(t=80, b=40),
)
fig.update_yaxes(title_text="Price (USD)", row=1, col=1)
fig.update_yaxes(title_text="Signal", row=2, col=1, tickvals=[-1, 0, 1])
fig.update_xaxes(title_text="Datetime", row=2, col=1)
fig.show()
Triangle Patterns Detection · BitPredict