Triangle Pattern Detection
Algorithmically detect ascending, descending, and symmetrical triangles using pivot-based trendline fitting.
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.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.
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.
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 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.
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
# 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