Double Top Bottom Detection
Build a double top and double bottom pattern detector that identifies these classic reversal chart formations using peak and trough detection algorithms with configurable tolerance for imperfect symmetry in real market data.
Strategy — Double Top and Bottom Detection
1. Dependency Installation
!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
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from scipy.signal import argrelextrema3. Strategy Overview
Double Top is a bearish reversal pattern formed by two consecutive peaks at approximately the same price level, separated by a trough (neckline). Double Bottom is the mirror image: two troughs at similar levels separated by a peak.
Detection logic:
- Identify local maxima (Double Top) and minima (Double Bottom) using
argrelextrema. - For each pair of consecutive peaks: validate that their prices are within a fractional tolerance (e.g., 2 %).
- The neckline breakout confirms the pattern.
Signal logic:
- Double Top → Sell (−1): bearish reversal after resistance is tested twice.
- Double Bottom → Buy (+1): bullish reversal after support is tested twice.
Limitation: Close price proximity between the two peaks/troughs can be coincidental in trending markets; volume confirmation improves reliability.
4. Data Generation
def generate_data(periods: int) -> pd.DataFrame:
"""
Generate synthetic OHLCV price data using a geometric random walk.
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
for i in range(periods):
open_price = last_close + np.random.normal(0, last_close * 0.0005)
close_price = open_price + np.random.normal(0, last_close * 0.005)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)),
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
display(df.head())| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 41994 | 42117 | 41880 | 42086 | 169.222768 | 2024-01-01 00:00:00+00:00 |
| 1 | 42096 | 42378 | 42096 | 42328 | 300.537387 | 2024-01-01 00:01:00+00:00 |
| 2 | 42343 | 42353 | 41933 | 42009 | 494.445361 | 2024-01-01 00:02:00+00:00 |
| 3 | 42003 | 42052 | 41878 | 41879 | 142.584834 | 2024-01-01 00:03:00+00:00 |
| 4 | 41857 | 41923 | 41798 | 41846 | 339.614008 | 2024-01-01 00:04:00+00:00 |
5. Strategy Function
def double_top_bottom_detection(
df: pd.DataFrame,
order: int = 10,
price_tol: float = 0.02,
) -> pd.DataFrame:
"""
Detect Double Top and Double Bottom reversal patterns.
Core logic
----------
1. Compute local maxima (peaks) and minima (troughs) with scipy argrelextrema.
2. For each consecutive peak pair: if their price levels are within price_tol,
classify as Double Top and emit a Sell signal at the bar after the second peak.
3. For each consecutive trough pair: if their price levels are within price_tol,
classify as Double Bottom and emit a Buy signal at the bar after the second trough.
Parameters
----------
df : pd.DataFrame
OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
order : int
Minimum number of bars between consecutive extrema.
price_tol : float
Maximum fractional price difference between the two tops or bottoms.
Returns
-------
pd.DataFrame
Original DataFrame extended with: pattern, signal.
"""
df = df.copy().sort_values("datetime", ignore_index=True)
df["pattern"] = "none"
df["signal"] = 0
close = df["close"].values
# ── Local extrema ────────────────────────────────────────────────────────
peak_idx = argrelextrema(close, np.greater, order=order)[0]
trough_idx = argrelextrema(close, np.less, order=order)[0]
# ── Double Top detection ─────────────────────────────────────────────────
for i in range(len(peak_idx) - 1):
p1_i, p2_i = peak_idx[i], peak_idx[i+1]
p1, p2 = close[p1_i], close[p2_i]
# Both peaks must be within price_tol of each other
if abs(p1 - p2) / max(p1, p2) < price_tol:
signal_bar = min(p2_i + 1, len(df) - 1)
df.at[signal_bar, "pattern"] = "double_top"
df.at[signal_bar, "signal"] = -1 # bearish
# ── Double Bottom detection ──────────────────────────────────────────────
for i in range(len(trough_idx) - 1):
t1_i, t2_i = trough_idx[i], trough_idx[i+1]
t1, t2 = close[t1_i], close[t2_i]
if abs(t1 - t2) / max(t1, t2) < price_tol:
signal_bar = min(t2_i + 1, len(df) - 1)
df.at[signal_bar, "pattern"] = "double_bottom"
df.at[signal_bar, "signal"] = 1 # bullish
return df
df_signals = double_top_bottom_detection(df, order=10, price_tol=0.02)
print("--- Pattern Distribution ---")
print(df_signals["pattern"].value_counts())
print("\n--- Signal Distribution ---")
print(df_signals["signal"].value_counts())--- Pattern Distribution --- pattern none 475 double_bottom 13 double_top 12 Name: count, dtype: int64 --- Signal Distribution --- signal 0 475 1 13 -1 12 Name: count, dtype: int64
Explanation:
abs(p1 - p2) / max(p1, p2) < price_tol: Fractional proximity check ensures both peaks/troughs represent the same price level within the specified tolerance.- Signals are assigned to the bar immediately following the second extremum, representing the earliest actionable entry point after pattern completion.
6. Visualization
buy_signals = df_signals[df_signals["signal"] == 1]
sell_signals = df_signals[df_signals["signal"] == -1]
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=["Price + Double Top/Bottom Signals", "Signal"],
row_heights=[0.7, 0.3])
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)
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"),
name="Double Bottom (+1)"), row=1, col=1)
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"),
name="Double Top (-1)"), row=1, col=1)
fig.add_trace(go.Scatter(
x=df_signals["datetime"], y=df_signals["signal"],
mode="lines", name="Signal", line=dict(color="purple", width=1)),
row=2, col=1)
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)
fig.update_layout(
title_text="Double Top and Bottom Detection",
xaxis_rangeslider_visible=False,
height=700, xaxis2_title="Datetime",
)
fig.show()Conclusion
This notebook demonstrates the detection of Double Top and Double Bottom reversal patterns using scipy.signal.argrelextrema and a price tolerance check. The generated signals indicate potential bullish or bearish reversals. Further analysis could involve backtesting the strategy on historical data, optimizing parameters like order and price_tol, and incorporating volume confirmation for increased reliability. Additional enhancements could include integrating this strategy into a broader trading system with risk management components.