python trading open-source

Building a Python Stock Scanner with RSI Signals

๐Ÿ“… April 2, 2026 โฑ 8 min read ๐Ÿ‘ค TradeSight Project

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
Rate limit note: yfinance hits Yahoo Finance's unofficial API. If you're running this on many symbols, add a short sleep between requests or use 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:

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.

Try it yourself: 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.