Signals·Chart Pattern Detection·Intermediate

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.

pattern-recognitiontrading-signals

Strategy — Double Top and Bottom Detection


1. Dependency Installation

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

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

3. 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:

  1. Identify local maxima (Double Top) and minima (Double Bottom) using argrelextrema.
  2. For each pair of consecutive peaks: validate that their prices are within a fractional tolerance (e.g., 2 %).
  3. 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.