DEV Community

Patrick DeVos
Patrick DeVos

Posted on

Build a Real-Time Stock Signal Scanner in Python (No Bloomberg Terminal Required)

Most retail traders look at one indicator at a time. Professional quant desks look for confluence - multiple signals firing on the same ticker at the same moment. That's what separates a noisy RSI reading from a high-confidence trade setup.

This guide shows you how to build a Python stock scanner that finds those confluence moments using the SignalSage API - a quantitative signals API that scans for VWAP deviation, RSI exhaustion, Bollinger Band squeezes, and volume spikes happening simultaneously, then returns pre-calculated entry, target, and stop levels.

What We're Building

A Python scanner that:

  1. Hits the SignalSage API with a watchlist of tickers
  2. Filters signals by confidence score and risk level
  3. Prints a ranked alert table with entry/target/stop levels
  4. Optionally runs on a schedule to alert at market open

Prerequisites

  • Python 3.8+
  • Free RapidAPI account
  • Basic Python knowledge (no finance background required)

Step 1: Subscribe to the API

  1. Go to RapidAPI and sign up for a free account
  2. Search for "SignalSage" by Circle of Wizards
  3. Subscribe to the free BASIC plan
  4. Copy your X-RapidAPI-Key from the API console

Step 2: Install Dependencies

pip install requests tabulate colorama
Enter fullscreen mode Exit fullscreen mode

Step 3: Understanding the Signal Data

Before writing the scanner, let's look at what the API returns. A single signal looks like this:

{
  "symbol": "NVDA",
  "signalType": "Multi-Layer Mean Reversion",
  "trigger": "1.6x volume, RSI 34, Near Bollinger",
  "confidence": 85,
  "position": "LONG",
  "instrument": "CALL",
  "entryLevel": 204.37,
  "targetLevel": 208.46,
  "stopLevel": 201.92,
  "timeframe": "4d",
  "riskLevel": "moderate",
  "isActive": true
}
Enter fullscreen mode Exit fullscreen mode

The trigger field tells you exactly which indicators fired. confidence is a 0-100 score - I filter for 70+ in production. isActive distinguishes live setups from historical context.

Step 4: The Scanner

import requests
from tabulate import tabulate
from colorama import Fore, Style, init
from datetime import datetime

init(autoreset=True)

RAPIDAPI_KEY = "YOUR_RAPIDAPI_KEY_HERE"
RAPIDAPI_HOST = "signalsage.p.rapidapi.com"
BASE_URL = f"https://{RAPIDAPI_HOST}"

HEADERS = {
    "X-RapidAPI-Key": RAPIDAPI_KEY,
    "X-RapidAPI-Host": RAPIDAPI_HOST,
}

# Your watchlist - up to 10 tickers per request
WATCHLIST = ["SPY", "QQQ", "AAPL", "NVDA", "TSLA", "META", "MSFT", "GOOGL", "AMZN"]


def get_signals(symbols: list[str]) -> dict:
    """Fetch signals for a list of symbols."""
    response = requests.get(
        f"{BASE_URL}/signals",
        headers=HEADERS,
        params={"symbols": ",".join(symbols)},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def get_analysis(symbols: list[str]) -> dict:
    """Fetch hero analysis - plain-English trade thesis for each signal."""
    response = requests.get(
        f"{BASE_URL}/analysis",
        headers=HEADERS,
        params={"symbols": ",".join(symbols)},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def risk_color(risk_level: str) -> str:
    colors = {"low": Fore.GREEN, "moderate": Fore.YELLOW, "high": Fore.RED}
    return colors.get(risk_level.lower(), Fore.WHITE)


def confidence_bar(score: int) -> str:
    filled = round(score / 10)
    return "?" * filled + "?" * (10 - filled)


def display_signals(data: dict, min_confidence: int = 65):
    signals = [s for s in data.get("signals", []) if s.get("isActive")]

    if not signals:
        status = data.get("market_status", "UNKNOWN")
        print(f"\n{Fore.YELLOW}No active signals. Market status: {status}{Style.RESET_ALL}")
        print("SignalSage returns signals during market hours (9:30am-4pm ET).")
        return

    # Filter and sort by confidence
    filtered = sorted(
        [s for s in signals if s.get("confidence", 0) >= min_confidence],
        key=lambda x: x.get("confidence", 0),
        reverse=True,
    )

    if not filtered:
        print(f"\n{Fore.YELLOW}No signals above {min_confidence}% confidence threshold.{Style.RESET_ALL}")
        return

    print(f"\n{Fore.CYAN}{'?' * 70}")
    print(f"  SIGNALSAGE - {len(filtered)} ACTIVE SIGNAL(S)  |  {datetime.now().strftime('%H:%M:%S ET')}")
    print(f"{'?' * 70}{Style.RESET_ALL}\n")

    rows = []
    for s in filtered:
        conf = s.get("confidence", 0)
        risk = s.get("riskLevel", "?")
        pos = s.get("position", "?")
        instr = s.get("instrument", "?")

        pos_color = Fore.GREEN if pos == "LONG" else Fore.RED
        rc = risk_color(risk)

        entry = s.get("entryLevel", 0)
        target = s.get("targetLevel", 0)
        stop = s.get("stopLevel", 0)
        r_multiple = round((target - entry) / (entry - stop), 1) if entry > stop else "-"

        rows.append([
            f"{s.get('emoji', '')} {s.get('symbol', '?')}",
            s.get("signalType", "?")[:28],
            f"{conf}% {confidence_bar(conf)}",
            f"{pos_color}{pos} {instr}{Style.RESET_ALL}",
            f"${entry:.2f}",
            f"{Fore.GREEN}${target:.2f}{Style.RESET_ALL}",
            f"{Fore.RED}${stop:.2f}{Style.RESET_ALL}",
            f"{rc}{r_multiple}R  {risk}{Style.RESET_ALL}",
            s.get("timeframe", "?"),
        ])

    headers = ["Ticker", "Signal Type", "Confidence", "Play", "Entry", "Target", "Stop", "R:R / Risk", "TF"]
    print(tabulate(rows, headers=headers, tablefmt="rounded_outline"))

    print(f"\n{Fore.CYAN}Triggers:{Style.RESET_ALL}")
    for s in filtered:
        print(f"  {s.get('emoji', '.')} {s['symbol']}: {s.get('trigger', '-')}")


def display_analysis(data: dict):
    analysis = data.get("analysis")
    if not analysis:
        return
    print(f"\n{Fore.CYAN}?? Market Analysis ??{Style.RESET_ALL}")
    if isinstance(analysis, str):
        words = analysis.split()
        line, lines = [], []
        for word in words:
            if sum(len(w) + 1 for w in line) + len(word) > 80:
                lines.append(" ".join(line))
                line = [word]
            else:
                line.append(word)
        if line:
            lines.append(" ".join(line))
        for l in lines:
            print(f"  {l}")
    elif isinstance(analysis, list):
        for item in analysis:
            print(f"  . {item}")


def main():
    print(f"{Fore.MAGENTA}????????????????????????????????????")
    print(f"?      SIGNALSAGE SCANNER v1.0     ?")
    print(f"????????????????????????????????????{Style.RESET_ALL}")
    print(f"Watchlist: {', '.join(WATCHLIST)}")
    print("Scanning...\n")

    try:
        signals_data = get_signals(WATCHLIST)
        analysis_data = get_analysis(WATCHLIST[:5])

        display_signals(signals_data, min_confidence=65)
        display_analysis(analysis_data)

    except requests.exceptions.HTTPError as e:
        status = e.response.status_code
        if status == 401:
            print(f"{Fore.RED}Invalid API key - check your RapidAPI key.{Style.RESET_ALL}")
        elif status == 403:
            print(f"{Fore.RED}Access denied - subscribe at rapidapi.com/rapidSuper/api/signalsage{Style.RESET_ALL}")
        elif status == 429:
            print(f"{Fore.RED}Rate limit hit - upgrade to PRO for higher limits.{Style.RESET_ALL}")
        else:
            print(f"{Fore.RED}API error {status}: {e}{Style.RESET_ALL}")
    except requests.exceptions.Timeout:
        print(f"{Fore.RED}Request timed out - the API scans live market data, try again.{Style.RESET_ALL}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Sample Output

????????????????????????????????????
?      SIGNALSAGE SCANNER v1.0     ?
????????????????????????????????????
Watchlist: SPY, QQQ, AAPL, NVDA, TSLA, META, MSFT, GOOGL, AMZN
Scanning...

??????????????????????????????????????????????????????????????????????
  SIGNALSAGE - 3 ACTIVE SIGNAL(S)  |  10:34:22 ET
??????????????????????????????????????????????????????????????????????

????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
? Ticker       ? Signal Type                  ? Confidence           ? Play          ? Entry   ? Target  ? Stop    ? R:R / Risk       ? TF ?
????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
? ??? NVDA      ? Multi-Layer Mean Reversion   ? 85% ??????????       ? LONG CALL     ? $204.37 ? $208.46 ? $201.92 ? 1.7R  moderate   ? 4d ?
? ?? SPY       ? Momentum Breakout            ? 78% ??????????       ? LONG CALL     ? $734.47 ? $749.16 ? $725.66 ? 1.6R  moderate   ? 2d ?
? ?? AAPL      ? VWAP Reversion               ? 71% ??????????       ? LONG CALL     ? $307.23 ? $313.38 ? $303.64 ? 1.7R  low        ? 3d ?
????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????

Triggers:
  ??? NVDA: 1.6x volume, RSI 34, Near Bollinger
  ?? SPY: 2.0x volume, Near Bollinger
  ?? AAPL: RSI 38, VWAP -0.8%
Enter fullscreen mode Exit fullscreen mode

How the Signals Work

SignalSage checks four conditions simultaneously for each ticker:

Indicator What it's checking
VWAP deviation Price meaningfully below intraday average (mean reversion setup)
RSI Oversold reading below 40 (momentum exhaustion)
Bollinger Band Price near or below lower band (statistical extreme)
Volume Current bar volume ? 1.5x average (institutional interest)

The confidence score reflects how many of these are firing together. A single RSI reading is noise. RSI + volume spike + Bollinger touch at VWAP is a setup worth watching.

Automating It

Run at market open every weekday with cron:

# crontab -e
30 9 * * 1-5 /usr/bin/python3 /path/to/scanner.py >> ~/signals.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Or add a Discord webhook for phone alerts:

def send_alert(signal: dict):
    webhook_url = "YOUR_DISCORD_WEBHOOK"
    msg = (
        f"**{signal['emoji']} {signal['symbol']} - {signal['signalType']}**\n"
        f"Confidence: {signal['confidence']}% | {signal['position']} {signal['instrument']}\n"
        f"Entry: ${signal['entryLevel']:.2f} ? Target: ${signal['targetLevel']:.2f} | Stop: ${signal['stopLevel']:.2f}\n"
        f"Trigger: {signal['trigger']}"
    )
    requests.post(webhook_url, json={"content": msg})
Enter fullscreen mode Exit fullscreen mode

Call send_alert(s) for each signal in filtered and you'll get a Discord ping whenever a high-confidence setup fires at open.

A Note on Market Hours

The API scans live price data from Alpaca's market feed. Outside 9:30am-4pm ET it returns "market_status": "NO_SIGNALS" - that's expected, not an error. Schedule your scanner to run after open.

BASIC plan covers personal use and a daily open scan. PRO ($9.99/mo) unlocks higher rate limits for strategies running throughout the session.


The API is live on RapidAPI - search "SignalSage" by Circle of Wizards. Free to try. If you build something interesting on top of it, drop a comment below.

Top comments (0)