📊 Data source: yfinance (free)
🔧 Difficulty: Intermediate
📈 Output: Daily buy/sell alert signals + CSV export
Python Investor’s Toolkit: Part 1: Momentum · Part 2: Monte Carlo · Part 3: Mean Reversion · Part 4: Earnings Quality · Part 5: Macro Regime
Part 3 of 5 — The Python Investor’s Toolkit: Practical quant tools built with free data.
Momentum (Part 1) tells you which stocks are trending. But trends don’t go straight — they oscillate. Mean reversion strategies exploit those oscillations: when a stock gets statistically oversold within an ongoing uptrend, it tends to snap back. That snap-back is where a mean reversion trader earns their edge.
This scanner combines two classic technical indicators — RSI (Relative Strength Index) and Bollinger Bands — to identify stocks that are simultaneously oversold on both measures. A single-indicator signal has too many false positives; the dual confirmation cuts noise significantly.
What You’ll Need
pip install yfinance pandas numpy
How the Two Indicators Work Together
| Indicator | What It Measures | Oversold Level | Overbought Level |
|---|---|---|---|
| RSI (14-day) | Speed/magnitude of recent price moves | < 30 | > 70 |
| %B (Bollinger) | Price position within its volatility band | < 0.05 (below lower band) | > 0.95 (above upper band) |
| Combined Signal | Both indicators agree on extremity | Strong Buy (mean reversion) | Strong Sell (take profit) |
💡 Why %B Instead of Just Price vs. Band: %B normalizes a stock’s position within its Bollinger Bands to a 0–1 scale. A %B of 0 means the price is exactly at the lower band; a %B of 1 means it’s at the upper band. Values below 0 or above 1 indicate the price has broken through the band — a rare and significant signal when combined with an extreme RSI reading.
The Code
import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
# -------------------------------------------------------
# Indicator functions
# -------------------------------------------------------
def calculate_rsi(prices, period=14):
"""Wilder's RSI — the standard implementation used by most platforms."""
delta = prices.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
# Wilder smoothing (exponential, not simple moving average)
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 calculate_bollinger_bands(prices, period=20, std_dev=2):
"""Standard 20-period Bollinger Bands with 2 standard deviation bands."""
sma = prices.rolling(window=period).mean()
std = prices.rolling(window=period).std()
upper = sma + (std * std_dev)
lower = sma - (std * std_dev)
return upper, sma, lower
def get_signals(ticker, lookback_days=200):
"""
Calculate RSI and Bollinger Band signals for a single ticker.
Returns signal strength: +3 (strong buy) to -3 (strong sell), 0 = neutral.
"""
end = datetime.today()
start = end - timedelta(days=lookback_days + 60)
data = yf.download(ticker, start=start, end=end, progress=False)
if data.empty:
return None
close = data['Adj Close']
rsi = calculate_rsi(close)
bb_upper, bb_mid, bb_lower = calculate_bollinger_bands(close)
# %B: where is price within the bands? (0 = lower band, 1 = upper band)
pct_b = (close - bb_lower) / (bb_upper - bb_lower)
current_price = float(close.iloc[-1])
current_rsi = float(rsi.iloc[-1])
current_pct_b = float(pct_b.iloc[-1])
# Classify signal
if current_rsi < 30 and current_pct_b < 0.05: signal, strength = "STRONG BUY", 3
elif current_rsi < 40 and current_pct_b < 0.20: signal, strength = "BUY", 2
elif current_rsi < 45 and current_pct_b < 0.30: signal, strength = "WEAK BUY", 1
elif current_rsi > 70 and current_pct_b > 0.95: signal, strength = "STRONG SELL", -3
elif current_rsi > 60 and current_pct_b > 0.80: signal, strength = "SELL", -2
elif current_rsi > 55 and current_pct_b > 0.70: signal, strength = "WEAK SELL", -1
else: signal, strength = "NEUTRAL", 0
return {
'ticker' : ticker,
'price' : round(current_price, 2),
'rsi' : round(current_rsi, 1),
'pct_b' : round(current_pct_b, 3),
'bb_lower' : round(float(bb_lower.iloc[-1]), 2),
'bb_upper' : round(float(bb_upper.iloc[-1]), 2),
'signal' : signal,
'strength' : strength
}
# -------------------------------------------------------
# Scanner: run across a watchlist
# -------------------------------------------------------
def scan_watchlist(tickers):
"""Scan a list of tickers and print actionable signals."""
print(f"Scanning {len(tickers)} tickers...")
results = [r for t in tickers if (r := get_signals(t)) is not None]
df = pd.DataFrame(results).sort_values('strength', ascending=False)
buys = df[df['strength'] > 0]
sells = df[df['strength'] < 0]
if not buys.empty:
print("n=== BUY SIGNALS (Oversold) ===")
print(buys[['ticker','price','rsi','pct_b','signal']].to_string(index=False))
if not sells.empty:
print("n=== SELL SIGNALS (Overbought) ===")
print(sells[['ticker','price','rsi','pct_b','signal']].to_string(index=False))
if buys.empty and sells.empty:
print("nNo actionable signals today — all stocks are in neutral territory.")
df.to_csv('mean_reversion_signals.csv', index=False)
print(f"nAll results saved to mean_reversion_signals.csv")
return df
# -------------------------------------------------------
# Run it — replace with your watchlist
# -------------------------------------------------------
watchlist = [
'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'NVDA', 'TSLA',
'JPM', 'BAC', 'GS', 'WMT', 'JNJ', 'XOM', 'CVX',
'SPY', 'QQQ', 'IWM', 'GLD', 'TLT'
]
results = scan_watchlist(watchlist)
Running It Daily (Automation Tip)
The real value of this tool is consistency — running it every day before market open and acting on signals systematically. On Windows, schedule it with Task Scheduler to run at 8:30 AM. On macOS/Linux, add a cron job:
# Run the scanner Monday–Friday at 8:30 AM
30 8 * * 1-5 /usr/bin/python3 /path/to/mean_reversion_scanner.py >> /path/to/scanner.log 2>&1
📈 Key Insight: Mean reversion works best on high-quality stocks within established uptrends — not on broken businesses or stocks in genuine freefall. Before acting on a STRONG BUY signal, check that the stock is above its 200-day moving average. If it’s below, the “oversold” reading may simply reflect a deteriorating business, not a temporary dip.
⚠️ Watch Out: Mean reversion signals have higher hit rates in range-bound, low-volatility markets. During strong trending markets or high-volatility regimes (VIX > 30), oversold conditions can persist or deepen significantly. Check the current VIX level before sizing into a mean reversion trade — reduce position size by 50% when VIX is above 25.
📊 Portfolio Takeaway
Only act on STRONG BUY signals (strength +3, RSI < 30 and %B < 0.05) — and only when the stock is above its 200-day moving average. Keep position sizes small (1–2% of portfolio per signal), since mean reversion can take weeks and may deepen before recovering. When VIX is above 25, skip mean reversion trades entirely or cut size by half — oversold conditions in high-volatility regimes can persist far longer than the model expects.
What’s Next
Parts 1–3 of this series have focused on price-based signals. Part 4 goes deeper: using SEC EDGAR’s free API to analyze earnings quality directly from financial statements — identifying companies where reported earnings are backed by real cash flow vs. accounting accruals.
Series: The Python Investor’s Toolkit
Part 1: Momentum Scanner
Part 2: Monte Carlo Portfolio Stress-Test
Part 3: Mean Reversion Alert System (this post)
Part 4: Earnings Quality Analyzer (SEC EDGAR)
Part 5: Macro Regime Detector (FRED API)
