Trend Strength Indicators: ROC, Slope, and R-squared
Quantify trend momentum and quality using slope, rate of change, regression analysis, and composite scores
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:
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:
# 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:
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:
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
- MA slope and angle measure trend steepness - angles >5° indicate strong directional movement
- Rate of Change (ROC) quantifies momentum - ROC > 5% over 20 days is strong bullish momentum
- R-squared measures trend linearity - only trade when R² > 0.70 for predictable trends
- ADX measures trend strength independent of direction - ADX > 25 confirms actionable trend
- Composite scores combine multiple metrics for robust trend strength classification
- 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.