Signals·Chart Pattern Detection·Intermediate

Flag Pennant Detection

Implement flag and pennant continuation pattern detection by identifying sharp impulse pole moves followed by consolidating rectangular or triangular flag formations with measured-move target projections for trade planning.

pattern-recognitiontrading-signals

Strategy — Flag and Pennant Pattern 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.stats import linregress

3. Strategy Overview

Flag and Pennant are short-term continuation patterns that form after a sharp price move (the flagpole), followed by a brief consolidation.

PatternConsolidation ShapeBias
Bull FlagParallel channel, slightly downward slopingBullish continuation
Bear FlagParallel channel, slightly upward slopingBearish continuation
Bull PennantConverging lines (symmetrical triangle) after bullish poleBullish continuation
Bear PennantConverging lines after bearish poleBearish continuation

Detection logic:

  1. Identify the flagpole: a sharp directional move over pole_bars bars whose magnitude exceeds pole_threshold × ATR.
  2. Measure the consolidation channel over the subsequent flag_bars bars using linear regression on highs and lows.
  3. Classify the consolidation as a Flag (parallel slopes) or Pennant (converging slopes).
  4. Emit a signal aligned with the flagpole direction on breakout.

Limitation: Flag and pennant identification on synthetic random-walk data is infrequent; live data with strong trending behaviour will produce more signals.

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 41996 42152 41980 42037 408.354716 2024-01-01 00:00:00+00:00
1 42056 42183 41913 42147 192.442383 2024-01-01 00:01:00+00:00
2 42144 42616 42084 42453 366.862401 2024-01-01 00:02:00+00:00
3 42447 42451 42321 42394 223.939004 2024-01-01 00:03:00+00:00
4 42353 42455 42164 42235 375.811312 2024-01-01 00:04:00+00:00

5. Strategy Function

[ ]
def flag_pennant_detection(
    df: pd.DataFrame,
    pole_bars: int = 10,
    flag_bars: int = 10,
    pole_threshold: float = 1.5,
) -> pd.DataFrame:
    """
    Detect Flag and Pennant continuation patterns following a strong directional move.

    Core logic
    ----------
    1. Compute ATR over pole_bars to normalise the flagpole magnitude.
    2. Scan each bar: if the price move over the prior pole_bars exceeds
       pole_threshold × ATR, a flagpole is identified.
    3. Over the subsequent flag_bars, compute linear regression slopes of highs
       and lows to determine consolidation shape (parallel = flag, converging = pennant).
    4. Emit a directional signal matching the flagpole direction.

    Parameters
    ----------
    df : pd.DataFrame
        OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
    pole_bars : int
        Number of bars used to measure the flagpole move.
    flag_bars : int
        Number of bars following the pole used to evaluate the consolidation.
    pole_threshold : float
        ATR multiplier; flagpole moves must exceed this multiple of ATR.

    Returns
    -------
    pd.DataFrame
        Original DataFrame extended with: atr, pattern, signal.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)
    df["pattern"] = "none"
    df["signal"]  = 0

    # ── ATR (Average True Range) ─────────────────────────────────────────────
    tr = pd.concat([
        df["high"] - df["low"],
        (df["high"] - df["close"].shift(1)).abs(),
        (df["low"]  - df["close"].shift(1)).abs(),
    ], axis=1).max(axis=1)
    df["atr"] = tr.rolling(pole_bars).mean()

    close = df["close"].values
    highs = df["high"].values
    lows  = df["low"].values

    for i in range(pole_bars, len(df) - flag_bars):
        atr_val = df["atr"].iloc[i]
        if np.isnan(atr_val) or atr_val == 0:
            continue

        # ── Flagpole identification ──────────────────────────────────────────
        pole_move = close[i] - close[i - pole_bars]
        if abs(pole_move) < pole_threshold * atr_val:
            continue  # insufficient impulse

        pole_direction = 1 if pole_move > 0 else -1  # +1 bullish, -1 bearish

        # ── Consolidation regression ─────────────────────────────────────────
        flag_slice = np.arange(flag_bars)
        slope_h, *_ = linregress(flag_slice, highs[i: i + flag_bars])
        slope_l, *_ = linregress(flag_slice, lows[i:  i + flag_bars])

        norm_h = slope_h / np.mean(highs[i: i + flag_bars])
        norm_l = slope_l / np.mean(lows[i:  i + flag_bars])

        signal_bar = min(i + flag_bars, len(df) - 1)

        # Pennant: slopes converge (opposite signs after impulse)
        if norm_h < 0 and norm_l > 0:
            df.at[signal_bar, "pattern"] = f"{'bull' if pole_direction == 1 else 'bear'}_pennant"
            df.at[signal_bar, "signal"]  = pole_direction

        # Flag: both slopes move against the pole (retracement channel)
        elif pole_direction == 1 and norm_h < 0 and norm_l < 0:
            df.at[signal_bar, "pattern"] = "bull_flag"
            df.at[signal_bar, "signal"]  = 1
        elif pole_direction == -1 and norm_h > 0 and norm_l > 0:
            df.at[signal_bar, "pattern"] = "bear_flag"
            df.at[signal_bar, "signal"]  = -1

    return df

df_signals = flag_pennant_detection(df, pole_bars=10, flag_bars=10, pole_threshold=1.5)

print("--- Pattern Distribution ---")
print(df_signals["pattern"].value_counts())
print("\n--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
--- Pattern Distribution ---
pattern
none            381
bear_flag        58
bull_flag        55
bear_pennant      3
bull_pennant      3
Name: count, dtype: int64

--- Signal Distribution ---
signal
 0    381
-1     61
 1     58
Name: count, dtype: int64

Explanation:

  • ATR: Normalises the flagpole threshold to current volatility, ensuring the pole magnitude is meaningful relative to market conditions.
  • norm_h / norm_l: Slope signs after the pole move determine whether the consolidation is retracing (flag) or converging (pennant).

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 + Flag/Pennant Signals", "ATR"],
    row_heights=[0.65, 0.35])

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="Bull (+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="Bear (-1)"), row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["atr"],
    mode="lines", name="ATR", line=dict(color="orange", width=1)),
    row=2, col=1)

fig.update_layout(
    title_text="Flag and Pennant Detection",
    xaxis_rangeslider_visible=False,
    height=700, xaxis2_title="Datetime",
)
fig.show()

Conclusion

This notebook demonstrates the detection of Flag and Pennant patterns using a synthetic dataset. The flag_pennant_detection function identifies these continuation patterns by analyzing sharp price movements (flagpoles) and subsequent consolidation phases. The visualizations help in understanding how these patterns are identified on price charts and their correlation with ATR. Further work could involve testing on real-world data and optimizing the pattern detection parameters.