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.
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 scipyRequirement 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 trendlines3. 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 Type | Upper Trendline Behavior | Lower Trendline Behavior | Market Bias (Expected Outcome) |
|---|---|---|---|
| Ascending Triangle | Horizontal (Flat) | Rising Slope | Bullish Continuation |
| Descending Triangle | Falling Slope | Horizontal (Flat) | Bearish Continuation |
| Symmetrical Triangle | Falling Slope | Rising Slope | Neutral (Breakout Dependent) |
Detection Methodology
The detection algorithm proceeds with the following logical steps:
- Local Extremum Identification: Identify significant local price highs (peaks) and local price lows (troughs) within the data series.
- 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.
- 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. - 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 theirindicesand correspondingprices). 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 theslope_thresholdparameter 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 rendersslope_thresholda 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()