Technical Indicators with Python — Deep Dive
Building indicators from first principles
Understanding the math behind each indicator prevents black-box usage and enables custom variations tailored to specific instruments or timeframes.
Exponential Moving Average internals
The EMA uses a smoothing factor (alpha) derived from the period. Each new value blends the current price with the previous EMA:
import numpy as np
import pandas as pd
def ema(prices: pd.Series, period: int) -> pd.Series:
"""EMA with the standard Wilder smoothing convention."""
alpha = 2.0 / (period + 1)
result = np.empty(len(prices))
result[:period] = np.nan
result[period - 1] = prices.iloc[:period].mean() # seed with SMA
for i in range(period, len(prices)):
result[i] = alpha * prices.iloc[i] + (1 - alpha) * result[i - 1]
return pd.Series(result, index=prices.index)
The choice of seed value matters for short series. For production use, the difference between SMA-seeded and first-value-seeded EMA diminishes after roughly 3× the period length.
Wilder’s RSI vs. Cutler’s RSI
The original RSI by Wilder uses an exponentially weighted moving average for gain and loss, while Cutler’s variant uses a simple moving average. The difference is subtle but affects signal timing:
def wilder_rsi(prices: pd.Series, period: int = 14) -> pd.Series:
"""RSI using Wilder's smoothing (EWMA with alpha = 1/period)."""
delta = prices.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
# Wilder smoothing: alpha = 1/period
avg_gain = gain.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def cutler_rsi(prices: pd.Series, period: int = 14) -> pd.Series:
"""RSI using simple moving average — no path dependency."""
delta = prices.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.rolling(period).mean()
avg_loss = loss.rolling(period).mean()
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
Wilder’s version is path-dependent (the current value depends on the entire history), while Cutler’s only looks at the most recent window. For backtesting reproducibility, Cutler’s variant is simpler to validate.
MACD with histogram divergence detection
Beyond basic crossover signals, MACD histogram divergence — where price makes a new high but the histogram does not — is a more nuanced signal:
def macd_full(prices: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
ema_fast = prices.ewm(span=fast, adjust=False).mean()
ema_slow = prices.ewm(span=slow, adjust=False).mean()
macd_line = ema_fast - ema_slow
signal_line = macd_line.ewm(span=signal, adjust=False).mean()
histogram = macd_line - signal_line
return pd.DataFrame({
"macd": macd_line,
"signal": signal_line,
"histogram": histogram,
})
def detect_bearish_divergence(prices: pd.Series, histogram: pd.Series, lookback: int = 20) -> pd.Series:
"""Flag bars where price makes a higher high but MACD histogram makes a lower high."""
price_hh = prices.rolling(lookback).max() == prices
hist_peaks = histogram.rolling(lookback).max()
hist_declining = histogram < hist_peaks.shift(lookback)
return price_hh & hist_declining
Vectorized computation at scale
When computing indicators across thousands of symbols, loop-based implementations become bottlenecks. NumPy vectorization and Numba JIT compilation provide significant speedups.
Vectorized Bollinger Bands across a panel
import numpy as np
def bollinger_panel(close_matrix: np.ndarray, period: int = 20, num_std: float = 2.0) -> tuple:
"""
Compute Bollinger Bands for a (time × symbols) matrix in one pass.
Returns upper, middle, lower bands as matrices.
"""
# Rolling mean via convolution
kernel = np.ones(period) / period
middle = np.apply_along_axis(lambda x: np.convolve(x, kernel, mode='same'), axis=0, arr=close_matrix)
# Rolling std via the variance identity: std = sqrt(E[x²] - E[x]²)
sq_mean = np.apply_along_axis(lambda x: np.convolve(x**2, kernel, mode='same'), axis=0, arr=close_matrix)
variance = sq_mean - middle**2
std = np.sqrt(np.maximum(variance, 0))
upper = middle + num_std * std
lower = middle - num_std * std
return upper, middle, lower
Numba-accelerated ATR
import numpy as np
from numba import njit
@njit
def atr_numba(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
"""ATR with Numba JIT — 50-100× faster than pure Python loops."""
n = len(high)
tr = np.empty(n)
atr = np.empty(n)
tr[0] = high[0] - low[0]
for i in range(1, n):
hl = high[i] - low[i]
hc = abs(high[i] - close[i - 1])
lc = abs(low[i] - close[i - 1])
tr[i] = max(hl, hc, lc)
atr[:period] = np.nan
atr[period - 1] = np.mean(tr[:period])
for i in range(period, n):
atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period
return atr
Custom composite indicators
Production trading systems often create custom indicators that combine multiple signals into a single score:
import pandas as pd
import numpy as np
def trend_strength_score(df: pd.DataFrame) -> pd.Series:
"""
Composite score from 0 (no trend) to 100 (strong trend).
Combines ADX, moving average alignment, and slope.
"""
# ADX component (0-100 by definition)
adx = compute_adx(df, period=14)
# Moving average alignment: how many MAs are in order
sma_20 = df["close"].rolling(20).mean()
sma_50 = df["close"].rolling(50).mean()
sma_100 = df["close"].rolling(100).mean()
sma_200 = df["close"].rolling(200).mean()
alignment = (
(sma_20 > sma_50).astype(float) +
(sma_50 > sma_100).astype(float) +
(sma_100 > sma_200).astype(float)
) / 3 * 100
# Slope of 50-day SMA (normalized)
slope = sma_50.diff(5) / df["close"] * 1000
slope_score = slope.clip(-50, 50) + 50 # map to 0-100
# Weighted composite
return 0.4 * adx + 0.3 * alignment + 0.3 * slope_score
def compute_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Average Directional Index — measures trend strength regardless of direction."""
high, low, close = df["high"], df["low"], df["close"]
plus_dm = high.diff().clip(lower=0)
minus_dm = (-low.diff()).clip(lower=0)
# Zero out when the other DM is larger
plus_dm[plus_dm < minus_dm] = 0
minus_dm[minus_dm < plus_dm] = 0
atr = compute_atr_series(high, low, close, period)
plus_di = 100 * plus_dm.ewm(alpha=1/period, adjust=False).mean() / atr
minus_di = 100 * minus_dm.ewm(alpha=1/period, adjust=False).mean() / atr
dx = 100 * abs(plus_di - minus_di) / (plus_di + minus_di)
adx = dx.ewm(alpha=1/period, adjust=False).mean()
return adx
def compute_atr_series(high, low, close, period):
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs(),
], axis=1).max(axis=1)
return tr.ewm(alpha=1/period, adjust=False).mean()
Regime-adaptive indicators
A fixed 14-period RSI works differently in low-volatility and high-volatility regimes. Adaptive indicators adjust their parameters dynamically:
def adaptive_rsi(prices: pd.Series, min_period: int = 6, max_period: int = 28) -> pd.Series:
"""RSI where the lookback period adapts to recent volatility."""
vol = prices.pct_change().rolling(20).std()
vol_rank = vol.rank(pct=True)
# High volatility → shorter period (more responsive)
# Low volatility → longer period (less noise)
adaptive_period = (max_period - (vol_rank * (max_period - min_period))).round().astype(int)
adaptive_period = adaptive_period.clip(min_period, max_period)
result = pd.Series(np.nan, index=prices.index)
for i in range(max_period, len(prices)):
p = adaptive_period.iloc[i]
window = prices.iloc[i - p : i + 1]
delta = window.diff()
gain = delta.clip(lower=0).mean()
loss = -delta.clip(upper=0).mean()
if loss == 0:
result.iloc[i] = 100.0
else:
rs = gain / loss
result.iloc[i] = 100 - (100 / (1 + rs))
return result
Testing indicator robustness
Before trusting any indicator signal in a strategy, validate it:
- Stability across instruments: does the signal work on stocks, ETFs, futures, and forex, or only on one asset class?
- Parameter sensitivity: plot strategy performance as a heatmap over a range of indicator parameters. Smooth surfaces indicate robust signals; spiky surfaces indicate overfitting.
- Regime analysis: segment history into bull, bear, and sideways periods. An indicator that only works in one regime has limited value.
- Signal decay: measure how quickly the indicator’s predictive power fades. An RSI signal that predicts 1-day returns but not 5-day returns has a short shelf life.
- Correlation with other indicators: if your RSI signal and MACD signal are 0.9 correlated, they add no diversification to a multi-signal strategy.
Practical architecture for indicator pipelines
from abc import ABC, abstractmethod
from typing import Any
class Indicator(ABC):
@abstractmethod
def compute(self, df: pd.DataFrame) -> pd.Series:
...
@abstractmethod
def name(self) -> str:
...
class RSIIndicator(Indicator):
def __init__(self, period: int = 14):
self.period = period
def compute(self, df: pd.DataFrame) -> pd.Series:
return wilder_rsi(df["close"], self.period)
def name(self) -> str:
return f"rsi_{self.period}"
class IndicatorPipeline:
def __init__(self, indicators: list[Indicator]):
self.indicators = indicators
def run(self, df: pd.DataFrame) -> pd.DataFrame:
result = df.copy()
for ind in self.indicators:
result[ind.name()] = ind.compute(df)
return result
This pattern keeps indicator logic testable and composable — each indicator is a unit-tested class, and the pipeline orchestrates computation order and handles missing data consistently.
The one thing to remember: The real value of implementing indicators in Python is not the formulas themselves — it is the ability to test parameter sensitivity, detect regime dependence, and build custom composites that no off-the-shelf charting tool provides.
See Also
- Python Backtesting Trading Strategies Why traders use Python to test their ideas on old data before risking real money, in plain language.
- Python Fraud Detection Patterns How Python helps banks and companies catch cheaters and thieves before they get away with it.
- Python Portfolio Optimization How Python helps you pick the right mix of investments so you get the best return for the risk you are willing to take.
- Python Quantitative Finance How Python helps people use math and data to make smarter money decisions, explained without any jargon.
- Python Risk Analysis Monte Carlo How rolling a virtual dice thousands of times helps investors understand what could go wrong with their money.