← Back to Home
Tracing the Market's Skeleton An Algorithmic Exploration of the ZigZag Indicator and Price Patterns

Tracing the Market's Skeleton An Algorithmic Exploration of the ZigZag Indicator and Price Patterns

Financial market price charts can often appear overwhelmingly chaotic, a dense tapestry of fluctuating highs and lows. Within this perceived randomness, a common desire for traders and analysts is to simplify the noise, to identify the “true” significant swings that define market structure. This is the domain of the ZigZag indicator.

The ZigZag indicator is not a predictive tool in itself. Instead, it serves as a powerful retrospective filter, designed to highlight only those price movements that exceed a certain percentage threshold, effectively creating a simplified skeleton of the price action. It connects significant swing highs and swing lows, ignoring smaller fluctuations. The immediate benefit is clarity: the ZigZag can make it easier to visually identify trends, reversals, and—crucially for our exploration—classic chart patterns like Double Tops, Double Bottoms, and Triangles.

However, the very nature of the ZigZag indicator presents an interesting challenge for algorithmic trading: it is inherently repainting. A ZigZag point is only confirmed when the price moves a specified percentage in the opposite direction. This means the last segment of a ZigZag line can change as new price data comes in. How might one translate such a lagging, repainting indicator into a functional, non-repainting algorithmic strategy? This article delves into an algorithmic exploration of a ZigZag-based strategy, attempting to leverage its pattern recognition capabilities while navigating its unique characteristics.


The Theory: From Swings to Signals

The strategy idea revolves around using the ZigZag indicator to map market structure and then applying classical technical analysis principles to this simplified view.

  1. The ZigZag Indicator:
    • Core Function: It identifies significant price reversals based on a zigzag_pct (e.g., 5%). If the price moves 5% down from a high, that high is confirmed as a ZigZag high. If it moves 5% up from a low, that low is confirmed as a ZigZag low.
    • Key Property: Because it requires a confirmed reversal, the last ZigZag point is always a lagging signal, and it can repaint (i.e., a temporary high/low might be absorbed into a larger swing if the price continues further in the original direction).
    • Hypothesis for Automation: Despite its lagging nature, confirmed ZigZag points define clear swing highs and lows, which are fundamental building blocks for chart patterns.
  2. Chart Pattern Recognition:
    • Once significant swing points are identified by the ZigZag, classic chart patterns can be hypothesized:
      • Double Top/Bottom: Two peaks/troughs at roughly the same price level, often signaling a reversal.
      • Triangles (Ascending/Descending): Converging price action (e.g., flat resistance/rising support for ascending triangle), often signaling a breakout.
    • Hypothesis: These patterns, when confirmed, provide potential directional biases for future price movement.
  3. Support and Resistance (S/R) Levels:
    • ZigZag pivots naturally mark potential support and resistance levels.
    • Hypothesis: Price tends to react strongly to these levels. A break of a significant S/R level (especially on confirming volume) might indicate a continuation of momentum in the direction of the break.
  4. Volume Confirmation:
    • Hypothesis: Breakouts from patterns or S/R levels are often considered more reliable if they occur on higher-than-average volume. This suggests genuine conviction behind the move.
  5. Trailing Stop-Loss:
    • A crucial risk management technique that aims to protect profits by automatically moving the stop price as a trade moves favorably.

The overarching strategy idea is to explore if the clarity offered by the ZigZag in identifying price patterns and S/R levels, when combined with volume confirmation, can provide actionable trading signals, while acknowledging the inherent lagging nature of the ZigZag.


Algorithmic Implementation: A backtrader Strategy

The following backtrader code provides a concrete implementation for exploring a ZigZag-based strategy. Each snippet will be presented and analyzed to understand how the theoretical ideas are translated into executable code.

Step 1: Initial Setup and Data Loading

Every quantitative exploration begins with data. This section prepares the environment and fetches historical price data.

import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib qt
plt.rcParams['figure.figsize'] = (10, 6)

# Download data and run backtest
data = yf.download('ETH-USD', '2021-01-01', '2024-01-01')
data.columns = data.columns.droplevel(1)
data_feed = bt.feeds.PandasData(dataname=data)

Analysis of this Snippet:

Step 2: Defining the ZigZag Strategy: ZigZagStrategy Initialization

This section outlines the ZigZagStrategy class, including its parameters, and the initialization of indicators and internal state variables for tracking ZigZag points and patterns.

class ZigZagStrategy(bt.Strategy):
    params = (
        ('zigzag_pct', 0.05),     # ZigZag reversal percentage (5%)
        ('pattern_lookback', 5),  # Number of ZigZag points for pattern analysis
        ('support_resistance_strength', 2), # Minimum touches for S/R level confirmation
        ('breakout_volume_multiplier', 1.2), # Volume confirmation multiplier for breakouts
        ('volume_period', 7),     # Volume average period for volume confirmation
        ('trailing_stop_pct', 0.02), # 2% trailing stop percentage
    )
    
    def __init__(self):
        # Price and volume data references
        self.high = self.data.high
        self.low = self.data.low
        self.close = self.data.close
        self.volume = self.data.volume
        
        # Volume indicator for confirmation
        self.volume_sma = bt.indicators.SMA(self.volume, period=self.params.volume_period)
        
        # ZigZag tracking variables
        self.zigzag_points = []     # Stores (bar_index, price, type='high'/'low') tuples
        self.current_direction = 0  # 1 for up, -1 for down, 0 for initial
        self.current_extreme = None # (bar_index, high_price, low_price) of current extreme bar
        self.last_pivot = None      # The last confirmed ZigZag pivot point
        
        # Pattern recognition and S/R tracking
        self.support_levels = []    # Stores (price_level, count_of_touches)
        self.resistance_levels = [] # Stores (price_level, count_of_touches)
        self.current_pattern = None # Stores identified chart pattern details
        
        # Trailing stop tracking variables
        self.entry_price = 0
        self.trailing_stop_price = 0
        self.highest_price_since_entry = 0 # For long positions
        self.lowest_price_since_entry = 0  # For short positions
        
        # backtrader's order tracking variables
        self.order = None
        self.stop_order = None

Analysis of the __init__ Method:

Step 3: Custom ZigZag Calculation and S/R Level Management

This section contains the core logic for calculating ZigZag points and updating support/resistance levels based on these points.

    def calculate_zigzag(self):
        """Calculate ZigZag points based on percentage threshold"""
        current_bar = len(self.data) - 1 # Current bar index
        current_high = self.high[0]
        current_low = self.low[0]
        current_close = self.close[0] # Not directly used for extreme, but available
        
        # Initialize ZigZag if it's the first bar
        if self.current_extreme is None:
            self.current_extreme = (current_bar, current_high, current_low)
            return
        
        prev_bar, prev_high, prev_low = self.current_extreme # Previous confirmed extreme
        threshold = self.params.zigzag_pct # The percentage reversal needed
        
        # Determine initial direction if not set yet
        if self.current_direction == 0:
            if current_high > prev_high * (1 + threshold):
                self.current_direction = 1 # Upward trend starts
                self.add_zigzag_point(prev_bar, prev_low, 'low') # Add previous low as first pivot
                self.current_extreme = (current_bar, current_high, current_low)
            elif current_low < prev_low * (1 - threshold):
                self.current_direction = -1 # Downward trend starts
                self.add_zigzag_point(prev_bar, prev_high, 'high') # Add previous high as first pivot
                self.current_extreme = (current_bar, current_high, current_low)
        
        # Logic when already in an upward trend
        elif self.current_direction == 1:
            if current_high > prev_high: # New higher high, extend current upward extreme
                self.current_extreme = (current_bar, current_high, current_low)
            elif current_low < prev_high * (1 - threshold): # Price reversed significantly downwards
                self.add_zigzag_point(prev_bar, prev_high, 'high') # Confirm previous high as a pivot
                self.current_direction = -1 # Change direction to down
                self.current_extreme = (current_bar, current_high, current_low) # Start new extreme
        
        # Logic when already in a downward trend
        elif self.current_direction == -1:
            if current_low < prev_low: # New lower low, extend current downward extreme
                self.current_extreme = (current_bar, current_high, current_low)
            elif current_high > prev_low * (1 + threshold): # Price reversed significantly upwards
                self.add_zigzag_point(prev_bar, prev_low, 'low') # Confirm previous low as a pivot
                self.current_direction = 1 # Change direction to up
                self.current_extreme = (current_bar, current_high, current_low)

    def add_zigzag_point(self, bar, price, point_type):
        """Adds a new confirmed ZigZag point to the list and updates S/R levels."""
        self.zigzag_points.append((bar, price, point_type))
        
        # Keep only a limited number of recent ZigZag points
        if len(self.zigzag_points) > 20: # Keep latest 20 points
            self.zigzag_points = self.zigzag_points[-20:]
        
        # Update support/resistance levels based on this new pivot
        self.update_support_resistance(price, point_type)

    def update_support_resistance(self, price, point_type):
        """Updates support and resistance levels based on new ZigZag pivots."""
        tolerance = self.params.zigzag_pct / 2 # A small percentage tolerance for S/R levels
        
        if point_type == 'low': # If a new ZigZag low is confirmed
            support_confirmed = False
            for i, (level, count) in enumerate(self.support_levels):
                # Check if new low is close to an existing support level
                if abs(price - level) / level < tolerance:
                    self.support_levels[i] = (level, count + 1) # Increment touch count
                    support_confirmed = True
                    break
            
            if not support_confirmed: # If not near existing level, add as a new potential support
                self.support_levels.append((price, 1))
        
        elif point_type == 'high': # If a new ZigZag high is confirmed
            resistance_confirmed = False
            for i, (level, count) in enumerate(self.resistance_levels):
                # Check if new high is close to an existing resistance level
                if abs(price - level) / level < tolerance:
                    self.resistance_levels[i] = (level, count + 1) # Increment touch count
                    resistance_confirmed = True
                    break
            
            if not resistance_confirmed: # If not near existing level, add as a new potential resistance
                self.resistance_levels.append((price, 1))
        
        # Keep only strong S/R levels (minimum touches) and only the most recent ones
        self.support_levels = [(level, count) for level, count in self.support_levels if count >= self.params.support_resistance_strength][-10:]
        self.resistance_levels = [(level, count) for level, count in self.resistance_levels if count >= self.params.support_resistance_strength][-10:]

Analysis of ZigZag and S/R Management:

Step 4: Chart Pattern Recognition and Support/Resistance Queries

This section focuses on identifying classic chart patterns from the sequence of ZigZag points and on querying the nearest support and resistance levels.

    def identify_patterns(self):
        """Identifies common ZigZag patterns (Double Top/Bottom, Triangles)."""
        if len(self.zigzag_points) < 4: # Need at least 4 points for most basic patterns
            return None
        
        # Consider only the most recent ZigZag points for pattern detection
        recent_points = self.zigzag_points[-self.params.pattern_lookback:]
        
        if len(recent_points) >= 4:
            # Double top pattern: High - Low - High - Low (last point is low)
            # Pattern: H - L - H - L (where H2 approx H1, L below H1)
            if (recent_points[-1][2] == 'low' and recent_points[-2][2] == 'high' and
                recent_points[-3][2] == 'low' and recent_points[-4][2] == 'high'):
                
                high1 = recent_points[-4][1]
                high2 = recent_points[-2][1]
                low_between = recent_points[-3][1]
                
                # Check if the two highs are approximately equal (within zigzag_pct tolerance)
                if abs(high1 - high2) / max(high1, high2) < self.params.zigzag_pct:
                    # Return pattern details: type, resistance level (max of highs), support level (low between highs)
                    return {'type': 'double_top', 'resistance': max(high1, high2), 'support': low_between}
            
            # Double bottom pattern: Low - High - Low - High (last point is high)
            # Pattern: L - H - L - H (where L2 approx L1, H above L1)
            if (len(recent_points) >= 4 and 
                recent_points[-1][2] == 'high' and recent_points[-2][2] == 'low' and
                recent_points[-3][2] == 'high' and recent_points[-4][2] == 'low'):
                
                low1 = recent_points[-4][1]
                low2 = recent_points[-2][1]
                high_between = recent_points[-3][1]
                
                # Check if the two lows are approximately equal
                if abs(low1 - low2) / max(low1, low2) < self.params.zigzag_pct:
                    # Return pattern details: type, support level (min of lows), resistance level (high between lows)
                    return {'type': 'double_bottom', 'support': min(low1, low2), 'resistance': high_between}
        
        # Triangle patterns (need at least 5 ZigZag points for converging lines)
        if len(recent_points) >= 5:
            highs = [p[1] for p in recent_points if p[2] == 'high']
            lows = [p[1] for p in recent_points if p[2] == 'low']
            
            if len(highs) >= 2 and len(lows) >= 2:
                # Ascending triangle: rising lows, flat resistance
                # (Low 1 < Low 2), and (High 1 approx High 2)
                if (lows[-1] > lows[-2] and 
                    abs(highs[-1] - highs[-2]) / max(highs[-1], highs[-2]) < self.params.zigzag_pct):
                    return {'type': 'ascending_triangle', 'resistance': max(highs[-2:]), 'support_trend': 'rising'}
                
                # Descending triangle: falling highs, flat support
                # (High 1 > High 2), and (Low 1 approx Low 2)
                if (highs[-1] < highs[-2] and 
                    abs(lows[-1] - lows[-2]) / max(lows[-1], lows[-2]) < self.params.zigzag_pct):
                    return {'type': 'descending_triangle', 'support': min(lows[-2:]), 'resistance_trend': 'falling'}
        
        return None # No recognizable pattern

    def get_nearest_support_resistance(self):
        """Finds the nearest strong support and resistance levels to the current price."""
        current_price = self.close[0]
        
        nearest_support = None
        nearest_resistance = None
        
        # Find nearest support below current price
        for level, count in self.support_levels:
            if level < current_price: # Check for levels below current price
                if nearest_support is None or level > nearest_support: # Find the closest one (highest of supports below price)
                    nearest_support = level
        
        # Find nearest resistance above current price
        for level, count in self.resistance_levels:
            if level > current_price: # Check for levels above current price
                if nearest_resistance is None or level < nearest_resistance: # Find the closest one (lowest of resistances above price)
                    nearest_resistance = level
        
        return nearest_support, nearest_resistance

Analysis of Pattern Recognition and S/R Queries:

Step 5: Implementing Risk Management

This section contains the notify_order method and a helper update_trailing_stop method, which together manage order status updates and implement a dynamic trailing stop-loss mechanism.

    def notify_order(self, order):
        if order.status in [order.Completed]:
            if order.isbuy() and self.position.size > 0:
                # Long position opened - set initial trailing stop price
                self.entry_price = order.executed.price
                self.highest_price_since_entry = order.executed.price # Initial highest price
                self.trailing_stop_price = order.executed.price * (1 - self.params.trailing_stop_pct)
                # Place a simple Stop order for the trailing stop
                self.stop_order = self.sell(exectype=bt.Order.Stop, price=self.trailing_stop_price)
                
            elif order.issell() and self.position.size < 0:
                # Short position opened - set initial trailing stop price
                self.entry_price = order.executed.price
                self.lowest_price_since_entry = order.executed.price # Initial lowest price
                self.trailing_stop_price = order.executed.price * (1 + self.params.trailing_stop_pct)
                # Place a simple Stop order for the trailing stop
                self.stop_order = self.buy(exectype=bt.Order.Stop, price=self.trailing_stop_price)
        
        if order.status in [order.Completed, order.Canceled, order.Rejected]:
            # Reset main order reference unless it's the stop order itself
            if self.stop_order is None or order != self.stop_order:
                self.order = None
            
            # If the completed order was the stop order, reset trailing stop tracking
            if self.stop_order is not None and order == self.stop_order:
                self.stop_order = None
                # Reset tracking variables when position is closed by stop-loss
                self.entry_price = 0
                self.trailing_stop_price = 0
                self.highest_price_since_entry = 0
                self.lowest_price_since_entry = 0

    def update_trailing_stop(self):
        """Updates the trailing stop based on current price movement and pre-defined percentage."""
        if not self.position or self.stop_order is None:
            return # No open position or no stop order to update
        
        current_price = self.close[0]
        
        if self.position.size > 0: # Long position
            # Update the highest price seen since entry (for trailing stop reference)
            if current_price > self.highest_price_since_entry:
                self.highest_price_since_entry = current_price
                
                # Calculate the new trailing stop price
                new_stop_price = self.highest_price_since_entry * (1 - self.params.trailing_stop_pct)
                
                # Only update the stop if it moves in our favor (higher for long)
                if new_stop_price > self.trailing_stop_price:
                    self.trailing_stop_price = new_stop_price
                    
                    # Cancel the old stop order and place a new one at the updated price
                    self.cancel(self.stop_order)
                    self.stop_order = self.sell(exectype=bt.Order.Stop, price=self.trailing_stop_price)
        
        elif self.position.size < 0: # Short position
            # Update the lowest price seen since entry (for trailing stop reference)
            if current_price < self.lowest_price_since_entry:
                self.lowest_price_since_entry = current_price
                
                # Calculate the new trailing stop price
                new_stop_price = self.lowest_price_since_entry * (1 + self.params.trailing_stop_pct)
                
                # Only update the stop if it moves in our favor (lower for short)
                if new_stop_price < self.trailing_stop_price:
                    self.trailing_stop_price = new_stop_price
                    
                    # Cancel the old stop order and place a new one at the updated price
                    self.cancel(self.stop_order)
                    self.stop_order = self.buy(exectype=bt.Order.Stop, price=self.trailing_stop_price)

Analysis of Risk Management:

Step 6: The Trading Logic: Orchestrating ZigZag Signals

This is the main loop that executes for each new bar of data, orchestrating ZigZag calculation, pattern detection, S/R analysis, and trading decisions.

    def next(self):
        # Update trailing stop first to ensure immediate risk management
        self.update_trailing_stop()
        
        if self.order is not None:
            return # Skip if an order is pending
        
        # Calculate ZigZag points for the current bar
        self.calculate_zigzag()
        
        # Skip if not enough ZigZag points have been accumulated for pattern analysis
        if len(self.zigzag_points) < 3:
            return
        
        # Identify patterns based on recent ZigZag points
        pattern = self.identify_patterns()
        current_price = self.close[0]
        # Check for volume confirmation for potential breakouts/reversals
        volume_confirmation = self.volume[0] > self.volume_sma[0] * self.params.breakout_volume_multiplier
        
        # Get nearest support and resistance levels from confirmed pivots
        nearest_support, nearest_resistance = self.get_nearest_support_resistance()
        
        # --- Trading signals based on ZigZag patterns and S/R breakouts ---
        
        # Pattern-based signals
        if pattern:
            if pattern['type'] == 'double_bottom' and volume_confirmation:
                # Buy on breakout above resistance after double bottom
                # Hypothesis: Price breaking above the "neckline" confirms reversal
                if current_price > pattern['resistance'] and not self.position:
                    self.order = self.buy()
            
            elif pattern['type'] == 'double_top' and volume_confirmation:
                # Sell on breakdown below support after double top
                # Hypothesis: Price breaking below the "neckline" confirms reversal
                if current_price < pattern['support'] and not self.position:
                    self.order = self.sell()
            
            elif pattern['type'] == 'ascending_triangle' and volume_confirmation:
                # Buy on resistance breakout in ascending triangle
                # Hypothesis: Price breaking above flat resistance confirms bullish move
                if current_price > pattern['resistance'] and not self.position:
                    self.order = self.buy()
            
            elif pattern['type'] == 'descending_triangle' and volume_confirmation:
                # Sell on support breakdown in descending triangle
                # Hypothesis: Price breaking below flat support confirms bearish move
                if current_price < pattern['support'] and not self.position:
                    self.order = self.sell()
        
        # Support/Resistance breakout signals (as a fallback if no pattern is found)
        # This part of the logic executes only if no specific pattern was identified this bar
        else:
            if nearest_resistance and volume_confirmation:
                # Buy on resistance breakout (with a small buffer to confirm clear break)
                # Hypothesis: Strong break above resistance signals continuation
                if (current_price > nearest_resistance * 1.001 and 
                    not self.position):
                    self.order = self.buy()
            
            if nearest_support and volume_confirmation:
                # Sell on support breakdown (with a small buffer to confirm clear break)
                # Hypothesis: Strong break below support signals continuation
                if (current_price < nearest_support * 0.999 and 
                    not self.position):
                    self.order = self.sell()
        
        # ZigZag trend reversal signals (can override or complement pattern/S/R breaks)
        # These aim to catch reversals immediately after a new ZigZag pivot is confirmed
        if len(self.zigzag_points) >= 2: # Ensure at least two pivots to check for reversal
            last_point = self.zigzag_points[-1] # The most recently confirmed ZigZag pivot
            
            # New ZigZag low formed recently - potential reversal up
            if (last_point[2] == 'low' and 
                len(self.data) - last_point[0] <= 3 and # Check if this pivot is very recent (within 3 bars)
                volume_confirmation):
                
                if self.position.size < 0: # Close short position
                    if self.stop_order is not None: self.cancel(self.stop_order)
                    self.order = self.close()
                elif not self.position: # Go long if no position
                    self.order = self.buy()
            
            # New ZigZag high formed recently - potential reversal down
            elif (last_point[2] == 'high' and 
                  len(self.data) - last_point[0] <= 3 and # Check if this pivot is very recent
                  volume_confirmation):
                
                if self.position.size > 0: # Close long position
                    if self.stop_order is not None: self.cancel(self.stop_order)
                    self.order = self.close()
                elif not self.position: # Go short if no position
                    self.order = self.sell()

Analysis of next() (The Trade Orchestrator):


Step 7: Executing the ZigZag Experiment: The Backtest Execution

This section sets up backtrader’s core engine, adds the strategy and data, configures the broker, and executes the simulation.

cerebro = bt.Cerebro()
cerebro.addstrategy(ZigZagStrategy)
cerebro.adddata(data_feed)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.broker.setcash(100000)
cerebro.broker.setcommission(commission=0.001)

print(f'Start: ${cerebro.broker.getvalue():,.2f}')
results = cerebro.run()
print(f'End: ${cerebro.broker.getvalue():,.2f}')
print(f'Return: {((cerebro.broker.getvalue() / 100000) - 1) * 100:.2f}%')

# Fix matplotlib plotting issues
plt.rcParams['figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000

try:
    cerebro.plot(iplot=False, style='line', volume=True)
    plt.show()
except Exception as e:
    print(f"Plotting error: {e}")
    print("Strategy completed successfully - plotting skipped")
Pasted image 20250610094821.png

Analysis of the Backtest Execution:


Reflections on Our ZigZag Expedition

This backtrader strategy offers a fascinating dive into automating price pattern recognition and support/resistance dynamics using the ZigZag indicator. It highlights the potential for structuring trading decisions around simplified market geometry.

This strategy provides a rich ground for further research into geometry-based trading. The journey of translating the visual art of chart pattern recognition into precise, testable algorithms is a continuous and intriguing challenge in quantitative trading.