Trend Identification: Swing Structure and ADX

Detect uptrends, downtrends, and sideways markets algorithmically using swing points and trend strength

30 min read
Intermediate

Introduction

"The trend is your friend" is the most repeated phrase in trading. But what is a trend, and how do you identify it algorithmically?

This lesson covers:

  • Formal trend definitions using swing highs and lows
  • Implementing trend detection algorithms
  • Distinguishing uptrends, downtrends, and sideways markets
  • Measuring trend strength quantitatively
  • Avoiding false trend signals

Formal Trend Definition

A trend is not subjective. We define it using swing points:

  • Swing High: A peak where the high is greater than N bars before and after it
  • Swing Low: A trough where the low is less than N bars before and after it

Common N values: 3, 5, 7 bars

Trend Definitions
Trend Type
Condition
Visual
UptrendHigher highs AND higher lows↗️ ↗️ ↗️
DowntrendLower highs AND lower lowsβ†˜οΈ β†˜οΈ β†˜οΈ
SidewaysHighs and lows within range↔️ ↔️ ↔️

Mathematical Formulation

For uptrend: SwingHighn>SwingHighnβˆ’1Β ANDΒ SwingLown>SwingLownβˆ’1\text{SwingHigh}_n > \text{SwingHigh}_{n-1} \text{ AND } \text{SwingLow}_n > \text{SwingLow}_{n-1}

For downtrend: SwingHighn<SwingHighnβˆ’1Β ANDΒ SwingLown<SwingLownβˆ’1\text{SwingHigh}_n < \text{SwingHigh}_{n-1} \text{ AND } \text{SwingLow}_n < \text{SwingLow}_{n-1}

Detecting Swing Points

First, implement swing high/low detection:

python
import yfinance as yf
import pandas as pd
import numpy as np

def find_swing_highs(df, window=5):
    """
    Find swing highs: peaks where high[i] > high[i-w:i+w].

    Args:
        df: DataFrame with 'High' column
        window: Number of bars before/after to compare

    Returns:
        Series with True at swing high locations
    """
    swing_highs = pd.Series(False, index=df.index)

    for i in range(window, len(df) - window):
        left_max = df['High'].iloc[i-window:i].max()
        right_max = df['High'].iloc[i+1:i+window+1].max()
        current_high = df['High'].iloc[i]

        if current_high > left_max and current_high > right_max:
            swing_highs.iloc[i] = True

    return swing_highs

def find_swing_lows(df, window=5):
    """
    Find swing lows: troughs where low[i] < low[i-w:i+w].
    """
    swing_lows = pd.Series(False, index=df.index)

    for i in range(window, len(df) - window):
        left_min = df['Low'].iloc[i-window:i].min()
        right_min = df['Low'].iloc[i+1:i+window+1].min()
        current_low = df['Low'].iloc[i]

        if current_low < left_min and current_low < right_min:
            swing_lows.iloc[i] = True

    return swing_lows

# Download data
df = yf.download('AAPL', period='6mo', progress=False)

# Detect swings
df['Swing_High'] = find_swing_highs(df, window=5)
df['Swing_Low'] = find_swing_lows(df, window=5)

# Extract swing points
swing_highs = df[df['Swing_High']][['High']]
swing_lows = df[df['Swing_Low']][['Low']]

print("Swing Points Detected:")
print(f"Total bars: {len(df)}")
print(f"Swing highs: {swing_highs.shape[0]}")
print(f"Swing lows: {swing_lows.shape[0]}")

print("\nLast 5 Swing Highs:")
print(swing_highs.tail())

print("\nLast 5 Swing Lows:")
print(swing_lows.tail())

Algorithmic Trend Classification

Use swing points to classify trend direction:

python
def classify_trend(df, lookback=3):
    """
    Classify trend based on last N swing highs and lows.

    Returns:
        Series with trend classification: 'Uptrend', 'Downtrend', 'Sideways'
    """
    # Get swing point values
    swing_high_values = df[df['Swing_High']]['High']
    swing_low_values = df[df['Swing_Low']]['Low']

    trend = pd.Series('Sideways', index=df.index)

    # For each bar, look at last N swings
    for i in range(lookback, len(df)):
        current_date = df.index[i]

        # Get last N swing highs before this date
        recent_highs = swing_high_values[swing_high_values.index < current_date].tail(lookback)
        recent_lows = swing_low_values[swing_low_values.index < current_date].tail(lookback)

        if len(recent_highs) >= 2 and len(recent_lows) >= 2:
            # Check if making higher highs and higher lows
            highs_rising = all(recent_highs.iloc[i] < recent_highs.iloc[i+1]
                              for i in range(len(recent_highs)-1))
            lows_rising = all(recent_lows.iloc[i] < recent_lows.iloc[i+1]
                             for i in range(len(recent_lows)-1))

            # Check if making lower highs and lower lows
            highs_falling = all(recent_highs.iloc[i] > recent_highs.iloc[i+1]
                               for i in range(len(recent_highs)-1))
            lows_falling = all(recent_lows.iloc[i] > recent_lows.iloc[i+1]
                              for i in range(len(recent_lows)-1))

            if highs_rising and lows_rising:
                trend.iloc[i] = 'Uptrend'
            elif highs_falling and lows_falling:
                trend.iloc[i] = 'Downtrend'

    return trend

# Classify trend
df['Trend'] = classify_trend(df, lookback=2)

# Count distribution
print("Trend Distribution:")
print(df['Trend'].value_counts())
print(f"\nPercentages:")
print(df['Trend'].value_counts(normalize=True) * 100)

# Show recent trend
print("\nRecent Trend Classification:")
print(df[['Close', 'Trend']].tail(10))

Key insight: Markets are sideways 62% of the time. This is why trend-following strategies often struggle - they wait for the 25-38% of time when trends exist.

Moving Average Trend Detection

A simpler (but less precise) method uses moving averages:

python
# Calculate moving averages
df['SMA_20'] = df['Close'].rolling(20).mean()
df['SMA_50'] = df['Close'].rolling(50).mean()
df['SMA_200'] = df['Close'].rolling(200).mean()

# Simple trend rules
df['MA_Trend'] = 'Sideways'
df.loc[df['Close'] > df['SMA_50'], 'MA_Trend'] = 'Uptrend'
df.loc[df['Close'] < df['SMA_50'], 'MA_Trend'] = 'Downtrend'

# Golden/Death Cross (SMA 50 vs 200)
df['Golden_Cross'] = (df['SMA_50'] > df['SMA_200']) & (df['SMA_50'].shift(1) <= df['SMA_200'].shift(1))
df['Death_Cross'] = (df['SMA_50'] < df['SMA_200']) & (df['SMA_50'].shift(1) >= df['SMA_200'].shift(1))

print("Moving Average Trend:")
print(df['MA_Trend'].value_counts())

print("\nGolden/Death Crosses:")
print(f"Golden Cross occurrences: {df['Golden_Cross'].sum()}")
print(f"Death Cross occurrences: {df['Death_Cross'].sum()}")

# Compare swing-based vs MA-based
comparison = df[['Trend', 'MA_Trend']].tail(20)
agreement = (df['Trend'] == df['MA_Trend']).sum() / len(df) * 100
print(f"\nTrend method agreement: {agreement:.1f}%")

print("\nRecent comparison:")
print(comparison)

Comparison:

  • Swing-based: More sensitive, detects trend changes faster
  • MA-based: Smoother, less prone to whipsaws but slower to react
  • Agreement is 68%: Methods disagree on borderline cases

Best practice: Use swing-based for precision, MA-based for simplicity. Combine both for confirmation: only trade when both methods agree.

ADX: Quantitative Trend Strength

Average Directional Index (ADX) measures trend strength (not direction):

  • ADX > 25: Strong trend
  • ADX < 20: Weak trend / sideways
python
def calculate_adx(df, period=14):
    """
    Calculate ADX (Average Directional Index).

    Returns:
        DataFrame with +DI, -DI, and ADX columns
    """
    # True Range
    df['H-L'] = df['High'] - df['Low']
    df['H-PC'] = abs(df['High'] - df['Close'].shift(1))
    df['L-PC'] = abs(df['Low'] - df['Close'].shift(1))
    df['TR'] = df[['H-L', 'H-PC', 'L-PC']].max(axis=1)

    # Directional Movement
    df['DM+'] = np.where((df['High'] - df['High'].shift(1)) > (df['Low'].shift(1) - df['Low']),
                         df['High'] - df['High'].shift(1), 0)
    df['DM+'] = np.where(df['DM+'] < 0, 0, df['DM+'])

    df['DM-'] = np.where((df['Low'].shift(1) - df['Low']) > (df['High'] - df['High'].shift(1)),
                         df['Low'].shift(1) - df['Low'], 0)
    df['DM-'] = np.where(df['DM-'] < 0, 0, df['DM-'])

    # Smoothed TR and DM
    df['TR_smooth'] = df['TR'].rolling(period).sum()
    df['DM+_smooth'] = df['DM+'].rolling(period).sum()
    df['DM-_smooth'] = df['DM-'].rolling(period).sum()

    # Directional Indicators
    df['+DI'] = 100 * (df['DM+_smooth'] / df['TR_smooth'])
    df['-DI'] = 100 * (df['DM-_smooth'] / df['TR_smooth'])

    # ADX
    df['DX'] = 100 * abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI'])
    df['ADX'] = df['DX'].rolling(period).mean()

    return df[['+DI', '-DI', 'ADX']]

# Calculate ADX
adx_data = calculate_adx(df.copy())
df['+DI'] = adx_data['+DI']
df['-DI'] = adx_data['-DI']
df['ADX'] = adx_data['ADX']

# Classify trend strength
df['Trend_Strength'] = 'Weak'
df.loc[df['ADX'] > 25, 'Trend_Strength'] = 'Strong'
df.loc[df['ADX'] > 40, 'Trend_Strength'] = 'Very Strong'

print("ADX Trend Strength Analysis:")
print(df['Trend_Strength'].value_counts())

print("\nRecent ADX values:")
print(df[['Close', 'ADX', '+DI', '-DI', 'Trend_Strength']].tail(10))

# Strong trend periods
strong_trends = df[df['ADX'] > 25]
print(f"\nStrong trend periods: {len(strong_trends)} days ({len(strong_trends)/len(df)*100:.1f}%)")

Critical Insight: Strong trends (ADX > 25) only occur 29% of the time. This confirms earlier finding that markets are mostly sideways/choppy. Trend-following strategies must account for this.

Summary

Key Takeaways

  1. Trends are defined by swing structure: Higher highs + higher lows = uptrend
  2. Swing point detection uses N-bar lookback windows (typically 5-7 bars)
  3. Markets are sideways 60-70% of the time - trend-following only works 30-40% of time
  4. Moving averages provide simpler trend detection but lag swing-based methods
  5. ADX measures trend strength, not direction - ADX > 25 indicates actionable trend
  6. Combine methods for confirmation: Swing structure + MA alignment + ADX > 25

Next Steps

Understanding trend identification is critical. Next, we'll learn trend strength indicators: measuring momentum, angle, and persistence to distinguish strong trends from weak ones that are likely to fail.