Building a Python Stock Scanner with RSI Signals
I've been building TradeSight โ an open-source algorithmic trading system written in Python. At its core is a stock scanner that uses momentum signals (RSI, MACD, volume) to surface trade setups across 50+ symbols in real time. This post breaks down how the RSI scanner works, what I got wrong the first time, and the live paper trading results so far.
๐ Live Paper Trading Results (as of April 2026)
+6.43% return over 3 weeks | $532 portfolio | 3 active strategies | 0 crashes since circuit breaker added
What is RSI and Why Use It?
The Relative Strength Index (RSI), developed by J. Welles Wilder in 1978, measures momentum by comparing the magnitude of recent gains to recent losses. Values below 30 suggest oversold conditions (potential buy), values above 70 suggest overbought (potential sell or short).
RSI is imperfect โ it generates false signals during strong trends and in volatile markets. But it's a good starting point for a scanner because it's fast to compute, widely understood, and produces clear thresholds. Here's the core calculation:
import pandas as pd
def rsi(series: pd.Series, window: int = 14) -> pd.Series:
"""
Compute RSI for a price series.
Returns a Series of RSI values (0-100).
"""
delta = series.diff().dropna()
gains = delta.clip(lower=0)
losses = (-delta).clip(lower=0)
avg_gain = gains.ewm(com=window - 1, adjust=False).mean()
avg_loss = losses.ewm(com=window - 1, adjust=False).mean()
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
One thing I got wrong initially: using rolling().mean() instead of
ewm(). Wilder's original formula uses exponential smoothing, not a simple
moving average. The difference is subtle early on but compounds with more data.
Fetching Data with yfinance
yfinance is the easiest way to pull historical OHLCV data in Python without
paying for a data subscription. TradeSight uses it to pull daily bars for a watchlist of 50+
symbols:
import yfinance as yf
import pandas as pd
WATCHLIST = [
'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA', 'META', 'TSLA',
'SPY', 'QQQ', 'IWM', # ETFs for market context
# ... 40+ more
]
def fetch_data(symbols: list, period: str = '3mo') -> dict:
"""Fetch OHLCV data for multiple symbols. Returns dict of DataFrames."""
result = {}
for sym in symbols:
try:
ticker = yf.Ticker(sym)
hist = ticker.history(period=period)
if len(hist) > 30: # skip symbols with thin history
result[sym] = hist
except Exception as e:
print(f"[WARN] {sym}: {e}")
return result
yf.download() with a list of tickers โ it batches them more efficiently.
The Scanner: Scoring Each Symbol
Instead of a binary buy/sell signal, TradeSight computes a score for each symbol based on multiple signals. RSI is one component; MACD crossover and relative volume are others. The scanner returns a ranked list:
def scan_symbol(df: pd.DataFrame) -> dict | None:
"""
Score a symbol based on RSI + MACD + volume signals.
Returns None if data is insufficient.
"""
if len(df) < 30:
return None
close = df['Close']
volume = df['Volume']
# RSI signal
rsi_val = rsi(close).iloc[-1]
rsi_signal = 0
if rsi_val < 35:
rsi_signal = 1 # oversold โ bullish
elif rsi_val > 65:
rsi_signal = -1 # overbought โ bearish
# Relative volume (today vs 20-day avg)
avg_vol = volume.rolling(20).mean().iloc[-1]
rel_vol = volume.iloc[-1] / avg_vol if avg_vol > 0 else 1.0
# MACD crossover (simple version)
ema_fast = close.ewm(span=12).mean()
ema_slow = close.ewm(span=26).mean()
macd = ema_fast - ema_slow
signal_line = macd.ewm(span=9).mean()
macd_cross = 1 if (macd.iloc[-1] > signal_line.iloc[-1] and
macd.iloc[-2] <= signal_line.iloc[-2]) else 0
score = rsi_signal + macd_cross + min(rel_vol / 2.0, 1.0)
return {'rsi': round(rsi_val, 1), 'rel_vol': round(rel_vol, 2),
'macd_cross': macd_cross, 'score': round(score, 2)}
Adding a Circuit Breaker
Paper trading went fine until one session where a strategy kept re-entering losing positions. The fix was a simple daily loss limit โ if total P&L drops below -2% in a session, all new trades are blocked until the next day:
class CircuitBreaker:
def __init__(self, max_daily_loss: float = -0.02):
self.max_daily_loss = max_daily_loss
self.session_start_equity = None
self.triggered = False
def check(self, current_equity: float) -> bool:
"""Returns True if trading should continue, False if circuit is open."""
if self.session_start_equity is None:
self.session_start_equity = current_equity
return True
daily_return = (current_equity - self.session_start_equity) / self.session_start_equity
if daily_return <= self.max_daily_loss:
self.triggered = True
return not self.triggered
Simple, but it eliminated the drawdown spiral that was eating into paper gains.
Results and What's Next
Three weeks of paper trading with RSI + MACD strategies across SPY, QQQ, and individual names: +6.43% return on a $532 virtual portfolio. Not extraordinary, but consistent and without any major drawdowns after the circuit breaker was added.
Next steps for TradeSight:
- Add mean-reversion strategy (Bollinger Bands + RSI combo)
- Real-time data via WebSocket (Alpaca Markets)
- Backtesting module with walk-forward validation
- Risk-adjusted scoring (Sharpe ratio per strategy)
The full source is on GitHub. It's MIT licensed and actively being developed. If you're building something similar or want to contribute, issues and PRs are open.
git clone https://github.com/rmbell09-lang/tradesight && pip install -r requirements.txt
โ runs out of the box with paper trading mode enabled by default.