Trend Strength Indicators: ROC, Slope, and R-squared

Quantify trend momentum and quality using slope, rate of change, regression analysis, and composite scores

28 min read
Intermediate

Introduction

Not all trends are created equal. A weak, choppy uptrend is very different from a strong, persistent one. Trend strength indicators quantify momentum, angle, and persistence.

This lesson covers:

  • Slope and angle of moving averages
  • Rate of change (ROC) and momentum
  • Regression channel analysis
  • R-squared for trend quality
  • Building a composite trend strength score

Moving Average Slope

The slope of a moving average indicates trend direction and steepness:

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

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

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

# Calculate slope (change per bar)
df['SMA_20_Slope'] = df['SMA_20'].diff(5)
df['SMA_50_Slope'] = df['SMA_50'].diff(10)

# Normalize slope to percentage
df['SMA_20_Slope_Pct'] = (df['SMA_20_Slope'] / df['SMA_20']) * 100
df['SMA_50_Slope_Pct'] = (df['SMA_50_Slope'] / df['SMA_50']) * 100

# Calculate angle in degrees
df['SMA_20_Angle'] = np.degrees(np.arctan(df['SMA_20_Slope'] / 5))
df['SMA_50_Angle'] = np.degrees(np.arctan(df['SMA_50_Slope'] / 10))

print("Moving Average Slope Analysis:")
print(df[['Close', 'SMA_20', 'SMA_20_Slope', 'SMA_20_Angle']].tail(10))

print("\nSlope Statistics:")
print(f"SMA_20 avg slope: {df['SMA_20_Slope_Pct'].mean():.3f}%")
print(f"SMA_20 avg angle: {df['SMA_20_Angle'].mean():.2f}°")
print(f"Max positive slope: {df['SMA_20_Slope_Pct'].max():.2f}%")
print(f"Max negative slope: {df['SMA_20_Slope_Pct'].min():.2f}%")

Interpretation:

  • Angle > 5°: Strong trend
  • Angle 0-5°: Weak trend
  • Angle < 0°: Counter-trend

Rate of Change (ROC)

Rate of Change measures price momentum over N periods:

ROCn=ClosetClosetnClosetn×100\text{ROC}_n = \frac{\text{Close}_t - \text{Close}_{t-n}}{\text{Close}_{t-n}} \times 100

python
# Calculate ROC for different periods
df['ROC_10'] = ((df['Close'] - df['Close'].shift(10)) / df['Close'].shift(10)) * 100
df['ROC_20'] = ((df['Close'] - df['Close'].shift(20)) / df['Close'].shift(20)) * 100
df['ROC_50'] = ((df['Close'] - df['Close'].shift(50)) / df['Close'].shift(50)) * 100

# Classify momentum
df['Momentum'] = 'Neutral'
df.loc[df['ROC_20'] > 5, 'Momentum'] = 'Strong Bullish'
df.loc[(df['ROC_20'] > 0) & (df['ROC_20'] <= 5), 'Momentum'] = 'Weak Bullish'
df.loc[df['ROC_20'] < -5, 'Momentum'] = 'Strong Bearish'
df.loc[(df['ROC_20'] < 0) & (df['ROC_20'] >= -5), 'Momentum'] = 'Weak Bearish'

print("Rate of Change Analysis:")
print(df[['Close', 'ROC_10', 'ROC_20', 'ROC_50', 'Momentum']].tail(10))

print("\nMomentum Distribution:")
print(df['Momentum'].value_counts())

print("\nROC Statistics (20-period):")
print(f"Mean: {df['ROC_20'].mean():.2f}%")
print(f"Std: {df['ROC_20'].std():.2f}%")
print(f"Max: {df['ROC_20'].max():.2f}%")
print(f"Min: {df['ROC_20'].min():.2f}%")

Linear Regression Trend Strength

Use linear regression to quantify trend quality via R-squared:

python
from scipy import stats

def calculate_regression_metrics(prices, window=20):
    """
    Calculate linear regression slope, R-squared for trend quality.

    Returns:
        slope, intercept, r_value, r_squared
    """
    if len(prices) < window:
        return None, None, None, None

    x = np.arange(window)
    y = prices[-window:]

    slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)
    r_squared = r_value ** 2

    return slope, intercept, r_value, r_squared

# Calculate rolling regression
window = 20
df['Reg_Slope'] = np.nan
df['Reg_R2'] = np.nan

for i in range(window, len(df)):
    prices = df['Close'].iloc[i-window:i].values
    slope, intercept, r_value, r2 = calculate_regression_metrics(prices, window)
    df.iloc[i, df.columns.get_loc('Reg_Slope')] = slope
    df.iloc[i, df.columns.get_loc('Reg_R2')] = r2

# Classify trend quality
df['Trend_Quality'] = 'Poor'
df.loc[df['Reg_R2'] > 0.7, 'Trend_Quality'] = 'Good'
df.loc[df['Reg_R2'] > 0.85, 'Trend_Quality'] = 'Excellent'

print("Regression Trend Quality:")
print(df[['Close', 'Reg_Slope', 'Reg_R2', 'Trend_Quality']].tail(10))

print("\nTrend Quality Distribution:")
print(df['Trend_Quality'].value_counts())

print("\nR-squared Statistics:")
print(f"Mean R²: {df['Reg_R2'].mean():.3f}")
print(f"Median R²: {df['Reg_R2'].median():.3f}")
print(f"% Excellent trends (R² > 0.85): {(df['Reg_R2'] > 0.85).sum() / len(df) * 100:.1f}%")

R² measures how well price fits a straight line:

  • R² > 0.85: Excellent linear trend (price moves predictably)
  • R² 0.70-0.85: Good trend (some noise but directional)
  • R² < 0.70: Poor trend (choppy, mean-reverting)

Only trade trend-following systems when R² > 0.70.

Composite Trend Strength Score

Combine multiple indicators into a single trend strength score:

python
def calculate_trend_score(row):
    """
    Composite trend strength score (0-100).

    Components:
    - ADX (0-25 points)
    - R² (0-25 points)
    - ROC_20 (0-25 points)
    - MA Slope (0-25 points)
    """
    score = 0

    # ADX component (0-25)
    if pd.notna(row.get('ADX')):
        adx_score = min(row['ADX'] / 2, 25)  # Cap at 25
        score += adx_score

    # R² component (0-25)
    if pd.notna(row.get('Reg_R2')):
        r2_score = row['Reg_R2'] * 25
        score += r2_score

    # ROC component (0-25)
    if pd.notna(row.get('ROC_20')):
        roc_abs = abs(row['ROC_20'])
        roc_score = min(roc_abs / 0.4, 25)  # 10% ROC = max score
        score += roc_score

    # MA Slope component (0-25)
    if pd.notna(row.get('SMA_20_Angle')):
        angle_abs = abs(row['SMA_20_Angle'])
        angle_score = min(angle_abs / 0.8, 25)  # 20° = max score
        score += angle_score

    return score

# Calculate ADX first (from previous lesson)
def calculate_adx(df, period=14):
    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)
    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-'])
    df['TR_smooth'] = df['TR'].rolling(period).sum()
    df['DM+_smooth'] = df['DM+'].rolling(period).sum()
    df['DM-_smooth'] = df['DM-'].rolling(period).sum()
    df['+DI'] = 100 * (df['DM+_smooth'] / df['TR_smooth'])
    df['-DI'] = 100 * (df['DM-_smooth'] / df['TR_smooth'])
    df['DX'] = 100 * abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI'])
    df['ADX'] = df['DX'].rolling(period).mean()
    return df

df = calculate_adx(df)
df['Trend_Score'] = df.apply(calculate_trend_score, axis=1)

# Classify strength
df['Strength_Class'] = 'Weak'
df.loc[df['Trend_Score'] > 40, 'Strength_Class'] = 'Moderate'
df.loc[df['Trend_Score'] > 60, 'Strength_Class'] = 'Strong'
df.loc[df['Trend_Score'] > 80, 'Strength_Class'] = 'Very Strong'

print("Composite Trend Strength Score:")
print(df[['Close', 'ADX', 'Reg_R2', 'ROC_20', 'Trend_Score', 'Strength_Class']].tail(15))

print("\nStrength Distribution:")
print(df['Strength_Class'].value_counts())

print("\nScore Statistics:")
print(f"Mean: {df['Trend_Score'].mean():.1f}")
print(f"Median: {df['Trend_Score'].median():.1f}")
print(f"Strong trends (>60): {(df['Trend_Score'] > 60).sum()} ({(df['Trend_Score'] > 60).sum()/len(df)*100:.1f}%)")

Trading Rule: Only take trend-following signals when Trend_Score > 60. This filters out weak, choppy trends that cause whipsaws.

Summary

Key Takeaways

  1. MA slope and angle measure trend steepness - angles >5° indicate strong directional movement
  2. Rate of Change (ROC) quantifies momentum - ROC > 5% over 20 days is strong bullish momentum
  3. R-squared measures trend linearity - only trade when R² > 0.70 for predictable trends
  4. ADX measures trend strength independent of direction - ADX > 25 confirms actionable trend
  5. Composite scores combine multiple metrics for robust trend strength classification
  6. Only 10-15% of days have strong trends (score > 60) - trend-following must be selective

Next Steps

Now that you can identify and measure trend strength, the next lesson covers support and resistance: price levels where trends pause, reverse, or accelerate based on supply/demand imbalances.