Trend Identification: Swing Structure and ADX
Detect uptrends, downtrends, and sideways markets algorithmically using swing points and trend strength
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 Type | Condition | Visual |
|---|---|---|
| Uptrend | Higher highs AND higher lows | βοΈ βοΈ βοΈ |
| Downtrend | Lower highs AND lower lows | βοΈ βοΈ βοΈ |
| Sideways | Highs and lows within range | βοΈ βοΈ βοΈ |
Mathematical Formulation
For uptrend:
For downtrend:
Detecting Swing Points
First, implement swing high/low detection:
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:
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:
# 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
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
- Trends are defined by swing structure: Higher highs + higher lows = uptrend
- Swing point detection uses N-bar lookback windows (typically 5-7 bars)
- Markets are sideways 60-70% of the time - trend-following only works 30-40% of time
- Moving averages provide simpler trend detection but lag swing-based methods
- ADX measures trend strength, not direction - ADX > 25 indicates actionable trend
- 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.