Prerequisites and Setup
This tutorial assumes you have a working Python 3.8+ environment. We will use three libraries: pandas for data manipulation, numpy for numerical operations, and yfinance for downloading historical market data. For visualization at the end, we will also use matplotlib.
Install the dependencies if you have not already:
pip install pandas numpy yfinance matplotlib
Throughout this tutorial we will work with one year of daily Apple (AAPL) data. Here is how to retrieve it:
import pandas as pd
import numpy as np
import yfinance as yf
df = yf.download("AAPL", period="1y")
print(df.head())
The yf.download() function returns a pandas DataFrame with columns Open, High, Low, Close, Adj Close, and Volume, indexed by date. We will use the Close column for our indicator calculations, and High/Low for Bollinger Bands context.
A note on Adj Close vs. Close: For indicator calculations on recent data (within the last year), the difference between Close and Adj Close is usually negligible unless there was a stock split. For longer historical studies or dividend-heavy stocks, prefer Adj Close to avoid discontinuities.
Indicator 1: RSI (Relative Strength Index)
The RSI was introduced by J. Welles Wilder Jr. in his 1978 book New Concepts in Technical Trading Systems. It measures the speed and magnitude of recent price changes to evaluate whether a security is overbought or oversold. The standard lookback period is 14.
The RSI Formula
RSI = 100 - (100 / (1 + RS))
The critical detail that many implementations get wrong is Wilder's smoothing method. The first average gain and average loss values are computed as a simple moving average (SMA) over the first 14 periods. Every subsequent value uses an exponential moving average (EMA) with a smoothing factor of alpha = 1/14. This is equivalent to an EMA with span = 2*14 - 1 = 27 in pandas terms, but it is cleaner to implement it directly.
Here is the step-by-step calculation:
- Compute the price change for each period:
delta = Close[t] - Close[t-1] - Separate gains (positive deltas) and losses (absolute value of negative deltas)
- For the first 14 periods, compute the simple average of gains and losses
- For all subsequent periods, use the recursive formula:
Avg_Gain[t] = (Avg_Gain[t-1] * 13 + Gain[t]) / 14 - Compute RS and then RSI
Complete RSI Function
def calculate_rsi(series, period=14):
"""
Calculate RSI using Wilder's smoothing method.
Parameters:
series: pandas Series of closing prices
period: lookback period (default 14)
Returns:
pandas Series with RSI values
"""
# Step 1: price changes
delta = series.diff()
# Step 2: separate gains and losses
gain = delta.where(delta > 0, 0.0)
loss = (-delta).where(delta < 0, 0.0)
# Step 3: Wilder's smoothing
# First value is SMA, subsequent values use EMA with 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()
# Step 4: RS and RSI
rs = avg_gain / avg_loss
rsi = 100.0 - (100.0 / (1.0 + rs))
return rsi
# Apply to our data
df['RSI'] = calculate_rsi(df['Close'])
Let us walk through what happens under the hood. The ewm(alpha=1/period, adjust=False) call in pandas implements exactly Wilder's smoothing: the first valid value (after min_periods observations) is computed as a simple mean, and all subsequent values use the recursive formula with a decay factor of (period - 1) / period. Setting adjust=False ensures we get the recursive (non-corrected) form that matches Wilder's original specification.
Common mistake: Using rolling(14).mean() for the entire series. This computes a simple moving average at every point, which produces different (and incorrect) RSI values compared to Wilder's method. The recursive smoothing gives more weight to recent observations, making the indicator more responsive.
Interpreting RSI Values
The traditional interpretation is straightforward: RSI above 70 suggests overbought conditions, and RSI below 30 suggests oversold conditions. However, in strong trends, the RSI can remain overbought or oversold for extended periods. Andrew Cardwell, who refined Wilder's work, noted that RSI tends to oscillate between 40 and 80 in uptrends and between 20 and 60 in downtrends.
Indicator 2: MACD (Moving Average Convergence Divergence)
The MACD was developed by Gerald Appel in 1979. It is a trend-following momentum indicator that shows the relationship between two exponential moving averages of a security's price. Despite its simplicity, it remains one of the most widely used indicators in technical analysis.
MACD Components
The MACD system consists of three components:
- MACD Line: The 12-period EMA minus the 26-period EMA of the closing price
- Signal Line: A 9-period EMA of the MACD line
- Histogram: The difference between the MACD line and the Signal line
Signal Line = EMA(9) of MACD Line
Histogram = MACD Line - Signal Line
When the MACD line crosses above the signal line, it is considered a bullish signal. When it crosses below, it is bearish. The histogram visualizes this relationship -- positive bars mean the MACD is above the signal, negative bars mean it is below. The size of the histogram bars indicates the strength of the momentum.
Complete MACD Function
def calculate_macd(series, fast=12, slow=26, signal=9):
"""
Calculate MACD, Signal line, and Histogram.
Parameters:
series: pandas Series of closing prices
fast: fast EMA period (default 12)
slow: slow EMA period (default 26)
signal: signal line EMA period (default 9)
Returns:
tuple of (macd_line, signal_line, histogram) as pandas Series
"""
# EMAs of closing price
ema_fast = series.ewm(span=fast, adjust=False).mean()
ema_slow = series.ewm(span=slow, adjust=False).mean()
# MACD line
macd_line = ema_fast - ema_slow
# Signal line: 9-period EMA of the MACD line
signal_line = macd_line.ewm(span=signal, adjust=False).mean()
# Histogram
histogram = macd_line - signal_line
return macd_line, signal_line, histogram
# Apply to our data
df['MACD'], df['MACD_Signal'], df['MACD_Hist'] = calculate_macd(df['Close'])
The ewm(span=N, adjust=False) call computes an exponential moving average with a decay factor of 2 / (N + 1). For the 12-period EMA, the smoothing factor (alpha) is 2 / 13 = 0.1538. For the 26-period EMA, it is 2 / 27 = 0.0741. The adjust=False parameter uses the recursive formula rather than the bias-corrected weighted average.
Why These Specific Parameters?
The 12-26-9 parameters were Gerald Appel's original recommendation and have become the de facto standard. The 12 and 26 periods roughly correspond to two weeks and one month of trading days (the stock market has approximately 21 trading days per month). The 9-period signal line provides a smoothed version of the MACD to filter noise.
There is nothing magical about these numbers. Many practitioners experiment with different parameter combinations. Shorter periods (such as 8-17-9) produce a more responsive indicator, while longer periods (such as 19-39-9) produce a smoother one. However, optimizing parameters to historical data is a recipe for overfitting.
MACD Divergence
One of the most watched MACD signals is divergence: when price makes a new high but the MACD makes a lower high (bearish divergence), or when price makes a new low but the MACD makes a higher low (bullish divergence). Divergence suggests that the trend's momentum is weakening, though it does not guarantee a reversal. Divergence can persist for extended periods before price catches up.
Indicator 3: Bollinger Bands
Bollinger Bands were developed by John Bollinger in the 1980s and formally introduced in his 2001 book Bollinger on Bollinger Bands. The indicator consists of three lines plotted on the price chart: a middle band (a simple moving average) and an upper and lower band set at a specified number of standard deviations above and below the middle band.
Bollinger Bands Formula
Upper Band = SMA(20) + 2 * StdDev(20)
Lower Band = SMA(20) - 2 * StdDev(20)
The standard parameters are a 20-period SMA with bands set at 2 standard deviations. Bollinger himself stated that 20 periods and 2 standard deviations are a good starting point, but not necessarily optimal for all situations. He recommended adjusting the period between 10 and 50, and if the period changes, the standard deviation multiplier should change as well (e.g., 10 periods with 1.9 standard deviations, 50 periods with 2.1).
Complete Bollinger Bands Function
def calculate_bollinger_bands(series, period=20, num_std=2):
"""
Calculate Bollinger Bands.
Parameters:
series: pandas Series of closing prices
period: SMA lookback period (default 20)
num_std: number of standard deviations (default 2)
Returns:
tuple of (middle, upper, lower) as pandas Series
"""
# Middle band: simple moving average
middle = series.rolling(window=period).mean()
# Standard deviation over the same window
std = series.rolling(window=period).std(ddof=0)
# Upper and lower bands
upper = middle + (num_std * std)
lower = middle - (num_std * std)
return middle, upper, lower
# Apply to our data
df['BB_Mid'], df['BB_Upper'], df['BB_Lower'] = calculate_bollinger_bands(df['Close'])
Note on ddof: We use std(ddof=0) to compute the population standard deviation, which matches John Bollinger's original specification. The default in pandas is ddof=1 (sample standard deviation), which would produce slightly wider bands. The difference is small for a 20-period window but worth being precise about.
Bollinger Band Width and %B
Two derived metrics are useful for quantitative analysis:
# Bandwidth: measures how wide the bands are relative to the middle band
df['BB_Width'] = (df['BB_Upper'] - df['BB_Lower']) / df['BB_Mid']
# %B: shows where price is relative to the bands (0 = lower band, 1 = upper band)
df['BB_PctB'] = (df['Close'] - df['BB_Lower']) / (df['BB_Upper'] - df['BB_Lower'])
Bandwidth is useful for detecting the "Bollinger Squeeze" -- when the bands narrow to a historically low width, it often precedes a significant price move (though the direction is not indicated). %B normalizes the price's position within the bands to a 0-1 scale, making it easier to use in quantitative models and comparisons across securities.
The Bollinger Squeeze
When Bollinger Bands contract to an unusually narrow width, it indicates a period of low volatility. Since volatility tends to be mean-reverting -- periods of low volatility are followed by periods of high volatility -- a squeeze often precedes a significant breakout. The key challenge is determining the direction of the breakout, which the bands alone do not predict.
Combining the Three Indicators
Each indicator captures different market information. The RSI measures momentum and overbought/oversold conditions. The MACD captures trend direction and momentum shifts. Bollinger Bands measure volatility and relative price levels. Using them in combination produces more robust signals than any single indicator alone.
Here is one example of a combined signal framework. This is a pedagogical example, not a trading system:
def generate_combined_signals(df):
"""
Generate combined buy/sell signals from RSI, MACD, and Bollinger Bands.
This is a demonstration of indicator combination, NOT a trading system.
"""
signals = pd.DataFrame(index=df.index)
# Buy signal: RSI < 30 AND price touches lower Bollinger Band
# AND MACD histogram turns positive (crosses from negative to positive)
macd_hist_crossover = (df['MACD_Hist'] > 0) & (df['MACD_Hist'].shift(1) <= 0)
signals['buy'] = (
(df['RSI'] < 30) &
(df['Close'] <= df['BB_Lower']) &
macd_hist_crossover
)
# Sell signal: RSI > 70 AND price touches upper Bollinger Band
# AND MACD histogram turns negative
macd_hist_crossunder = (df['MACD_Hist'] < 0) & (df['MACD_Hist'].shift(1) >= 0)
signals['sell'] = (
(df['RSI'] > 70) &
(df['Close'] >= df['BB_Upper']) &
macd_hist_crossunder
)
return signals
signals = generate_combined_signals(df)
print(f"Buy signals: {signals['buy'].sum()}")
print(f"Sell signals: {signals['sell'].sum()}")
Important: The combined signal above is extremely strict -- requiring all three conditions simultaneously. In practice, you will see very few signals on one year of data for a single stock. This is by design: a strict filter produces fewer but potentially higher-quality signals. Loosening any condition (for example, RSI below 35 instead of 30) will produce more signals but with lower average conviction.
The logic behind this particular combination: when RSI is below 30, the stock is showing oversold momentum. When the price touches the lower Bollinger Band, it has moved at least two standard deviations below its 20-day average. And when the MACD histogram turns positive, it indicates that short-term momentum is beginning to shift upward. The convergence of all three signals suggests a potential reversal from an oversold condition.
Plotting All Three Indicators
A multi-panel chart is the standard way to visualize these indicators together. Here is a complete matplotlib implementation:
import matplotlib.pyplot as plt
def plot_indicators(df, last_n=120):
"""Plot price with Bollinger Bands, RSI, and MACD on a multi-panel chart."""
data = df.tail(last_n).copy()
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True,
gridspec_kw={'height_ratios': [3, 1, 1]})
fig.patch.set_facecolor('#0a0b0e')
for ax in axes:
ax.set_facecolor('#0f1114')
ax.tick_params(colors='#8b9099')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['bottom'].set_color('#2a2e35')
ax.spines['left'].set_color('#2a2e35')
# Panel 1: Price + Bollinger Bands
axes[0].plot(data.index, data['Close'], color='#e8eaed', linewidth=1.2, label='Close')
axes[0].plot(data.index, data['BB_Mid'], color='#4facfe', linewidth=0.8, alpha=0.7, label='SMA(20)')
axes[0].plot(data.index, data['BB_Upper'], color='#8b9099', linewidth=0.6, linestyle='--')
axes[0].plot(data.index, data['BB_Lower'], color='#8b9099', linewidth=0.6, linestyle='--')
axes[0].fill_between(data.index, data['BB_Upper'], data['BB_Lower'], alpha=0.05, color='#4facfe')
axes[0].set_ylabel('Price', color='#c4c8ce')
axes[0].legend(loc='upper left', fontsize=8, facecolor='#141619', edgecolor='#2a2e35', labelcolor='#c4c8ce')
# Panel 2: RSI
axes[1].plot(data.index, data['RSI'], color='#00d4aa', linewidth=1)
axes[1].axhline(y=70, color='#ff4757', linewidth=0.6, linestyle='--', alpha=0.7)
axes[1].axhline(y=30, color='#00d4aa', linewidth=0.6, linestyle='--', alpha=0.7)
axes[1].fill_between(data.index, 70, 100, alpha=0.05, color='#ff4757')
axes[1].fill_between(data.index, 0, 30, alpha=0.05, color='#00d4aa')
axes[1].set_ylabel('RSI', color='#c4c8ce')
axes[1].set_ylim(0, 100)
# Panel 3: MACD
colors = ['#00d4aa' if v >= 0 else '#ff4757' for v in data['MACD_Hist']]
axes[2].bar(data.index, data['MACD_Hist'], color=colors, alpha=0.6, width=1)
axes[2].plot(data.index, data['MACD'], color='#4facfe', linewidth=1, label='MACD')
axes[2].plot(data.index, data['MACD_Signal'], color='#ffb347', linewidth=1, label='Signal')
axes[2].axhline(y=0, color='#2a2e35', linewidth=0.5)
axes[2].set_ylabel('MACD', color='#c4c8ce')
axes[2].legend(loc='upper left', fontsize=8, facecolor='#141619', edgecolor='#2a2e35', labelcolor='#c4c8ce')
plt.tight_layout()
plt.savefig('indicators_chart.png', dpi=150, facecolor='#0a0b0e', bbox_inches='tight')
plt.show()
plot_indicators(df)
This produces a three-panel chart: price with Bollinger Bands on top, RSI in the middle, and MACD with histogram on the bottom. The shared x-axis ensures all panels are aligned by date.
Putting It All Together: Complete Script
Here is the entire pipeline in a single, self-contained script that you can copy and run:
import pandas as pd
import numpy as np
import yfinance as yf
# Download data
df = yf.download("AAPL", period="1y")
# RSI-14 (Wilder's smoothing)
delta = df['Close'].diff()
gain = delta.where(delta > 0, 0.0)
loss = (-delta).where(delta < 0, 0.0)
avg_gain = gain.ewm(alpha=1/14, min_periods=14, adjust=False).mean()
avg_loss = loss.ewm(alpha=1/14, min_periods=14, adjust=False).mean()
rs = avg_gain / avg_loss
df['RSI'] = 100.0 - (100.0 / (1.0 + rs))
# MACD (12, 26, 9)
df['EMA12'] = df['Close'].ewm(span=12, adjust=False).mean()
df['EMA26'] = df['Close'].ewm(span=26, adjust=False).mean()
df['MACD'] = df['EMA12'] - df['EMA26']
df['MACD_Signal'] = df['MACD'].ewm(span=9, adjust=False).mean()
df['MACD_Hist'] = df['MACD'] - df['MACD_Signal']
# Bollinger Bands (20, 2)
df['BB_Mid'] = df['Close'].rolling(window=20).mean()
df['BB_Std'] = df['Close'].rolling(window=20).std(ddof=0)
df['BB_Upper'] = df['BB_Mid'] + 2 * df['BB_Std']
df['BB_Lower'] = df['BB_Mid'] - 2 * df['BB_Std']
# Display the last 5 rows
print(df[['Close', 'RSI', 'MACD', 'MACD_Signal', 'MACD_Hist',
'BB_Mid', 'BB_Upper', 'BB_Lower']].tail())
Performance Considerations
For a single security with one year of daily data (~252 rows), these calculations are instantaneous. However, if you are computing indicators across thousands of securities or using intraday data, here are some optimization tips:
- Vectorized operations: The pandas
ewm()androlling()methods are implemented in C under the hood and are already quite fast. Avoid Python loops over rows. - Numba or Cython: For custom indicators that cannot be expressed with built-in pandas operations, consider using
@numba.jit(nopython=True)to JIT-compile your indicator function. - Batch downloads: Use
yf.download(["AAPL", "MSFT", "GOOGL"], period="1y")to download multiple tickers in a single call, which is faster than individual calls. - Memory: Each new column on a DataFrame with 252 rows is trivial. With 1-minute bars over several years, memory becomes a concern -- consider computing indicators and immediately discarding intermediate columns.
Limitations and Caveats
It is important to be clear about what these calculations are and what they are not:
These are indicator calculations, not a trading system. Computing RSI, MACD, and Bollinger Bands is the easy part. The hard part is building a complete system with proper entry rules, exit rules, position sizing, risk management, transaction cost modeling, and robust out-of-sample testing. An indicator by itself generates observations, not profits.
Past indicator values do not predict future prices. The Efficient Market Hypothesis (in its weak form) states that historical price information is already reflected in current prices. While there is evidence of short-term momentum and mean reversion effects, the signal-to-noise ratio is extremely low. Never treat an indicator reading as a reliable forecast.
Always backtest before trading. If you develop a strategy using these indicators, you must test it on out-of-sample data with realistic assumptions about transaction costs, slippage, and market impact. In-sample results are nearly meaningless -- any set of rules can be made to work on data it was designed for.
Technical indicators are tools for organizing information about price action. They are most useful when combined with other sources of information -- fundamentals, insider activity, macroeconomic context, and sector analysis. No single indicator, or combination of indicators, provides a consistent edge in isolation.
Indicators Meet Insider Intelligence
Alpha Suite integrates RSI-14, momentum analysis, and volatility-based TP/SL barriers with real-time SEC Form 4 insider filing data to generate quantitative trading signals.
Get Started with Alpha Suite