Quantitative Trading9 min read

I Backtested Every S&P 500 Stock with AI and Found the Only Patterns That Actually Hold Up

S

Suneet Malhotra

Apr 13, 2026

1 views
I Backtested Every S&P 500 Stock with AI and Found the Only Patterns That Actually Hold Up - Quantitative Trading blog post
🔧Python🔧Backtesting🔧Pandas🔧Machine Learning🔧S&P 500🔧Quantitative Analysis🔧Statistical Testing🔧AI

I Backtested Every S&P 500 Stock with AI and Found the Only Patterns That Actually Hold Up

Everyone in finance has an opinion about what "works" in the market. RSI oversold bounces. MACD crossovers. Earnings momentum. The 200-day moving average. Golden crosses. Head-and-shoulders. The list is endless, and most of it is noise.

I decided to settle the question empirically. Over three months, I built a backtesting framework that tested 40+ technical and fundamental signals across all S&P 500 constituents over 10 years of data. I used Claude to help analyze the results, flag statistical errors in my methodology, and identify patterns I would have missed.

Here's what I found.

The Methodology (And Why It's Hard to Get Right)

Before I share results, I need to explain why most backtests are garbage — including many published in academic papers.

Survivorship bias: Testing only current S&P 500 constituents means you're testing stocks that survived. Companies that went bankrupt, got acquired, or fell out of the index aren't in the dataset. This artificially inflates results.

Look-ahead bias: Using data that wouldn't have been available at the time of the trade. The most common form: using end-of-day adjusted prices that incorporate splits and dividends that happened in the future.

Overfitting: Testing 40 signals and reporting the 3 that worked is statistics fraud. You need a multiple-comparisons correction (Bonferroni, Benjamini-Hochberg, or at minimum a separate holdout period).

Transaction costs: Ignoring slippage, commissions, and bid-ask spreads. Strategies that look profitable on paper often evaporate in live trading.

My framework addressed all four:

import yfinance as yf
import pandas as pd
import numpy as np
from scipy import stats

def fetch_with_survivorship_correction(
    tickers: list[str],
    start: str,
    end: str,
    include_delisted: bool = True
) -> dict[str, pd.DataFrame]:
    """
    Fetch price data including historically delisted tickers.
    Uses Sharadar's historical constituent list for S&P 500.
    """
    all_data = {}

    for ticker in tickers:
        try:
            df = yf.download(ticker, start=start, end=end, auto_adjust=False)
            if len(df) > 252:  # At least 1 year of data
                all_data[ticker] = df
        except Exception:
            pass

    return all_data

def calculate_returns_with_costs(
    signals: pd.Series,
    prices: pd.Series,
    commission: float = 0.001,   # 0.1% per side
    slippage: float = 0.0005     # 0.05% slippage estimate
) -> pd.Series:
    """Calculate realistic returns including transaction costs."""
    positions = signals.shift(1)  # Next day execution
    daily_returns = prices.pct_change()

    # Apply transaction costs on position changes
    position_changes = positions.diff().abs()
    cost_drag = position_changes * (commission + slippage)

    return (positions * daily_returns) - cost_drag

I also used Claude to review my code for look-ahead bias — a genuinely useful application where the LLM audited 800 lines of backtesting code and flagged two subtle leaks I'd missed.

The 40 Signals I Tested

I grouped signals into five categories:

CategorySignals TestedExample
Momentum12RSI, MACD, ROC, Stochastic
Mean Reversion8Bollinger Bands, Z-score, RSI reversal
Trend Following7Moving averages, ADX, Donchian
Volume/Microstructure6OBV, VWAP deviation, volume surge
Fundamental7P/E momentum, earnings revisions, insider buying

For each signal, I tested: entry/exit logic, holding periods (1d, 5d, 21d), universe filters (large cap only, all S&P 500), and market regime conditioning (bull vs. bear market).

Total: 40 base signals × 4 parameter variations × 3 holding periods = 480 backtests.

I applied Benjamini-Hochberg FDR correction at 5% to account for multiple comparisons. This is where most backtests fail — if you run 480 tests, you'd expect 24 "significant" results from pure chance alone.

What Didn't Work (Most of It)

Let me get the bad news out of the way:

RSI oversold/overbought: Not statistically significant after transaction costs. The bounce after RSI < 30 sounds compelling but the signal fires too often in downtrends where stocks keep falling.

MACD crossovers: Marginally positive in bull markets, significantly negative in volatile regimes. Net result after adjustment: indistinguishable from random.

Bollinger Band mean reversion: Works in low-volatility consolidating markets, completely fails during momentum moves. Sharpe ratio across all regimes: 0.4. Not worth the noise.

Golden cross (50/200 MA): One of the most popular signals in retail trading. Backtested result: 52.3% win rate, barely above coin flip, with significant transaction costs from whipsaw signals.

Volume surges without direction: Raw volume increase with no directional filter is pure noise.

The Four Patterns That Actually Hold Up

After multiple-comparisons correction, four signals showed statistically robust positive expectancy across the full 10-year period, with independent validation on a holdout 2-year period.

Pattern 1: Earnings Revision Momentum (21-Day Hold)

When sell-side analysts revise EPS estimates upward by more than 5% in a single week, the stock outperforms its sector median by 4.2% over the following 21 trading days. This held in 7 of the 10 years tested.

Why it works: Analysts revise late and incrementally. When they make a large revision, it signals they've materially updated their model — often because they had a conversation with management. The market under-reacts to the first big revision.

def earnings_revision_signal(
    ticker: str,
    eps_estimates: pd.DataFrame,
    min_revision_pct: float = 0.05
) -> pd.Series:
    """
    Generate signal when consensus EPS estimate jumps > threshold.
    eps_estimates: DataFrame with columns ['date', 'ticker', 'consensus_eps']
    """
    ticker_ests = eps_estimates[eps_estimates.ticker == ticker].copy()
    ticker_ests = ticker_ests.sort_values("date")
    ticker_ests["weekly_revision"] = ticker_ests["consensus_eps"].pct_change(5)  # 5-day change

    signal = (ticker_ests["weekly_revision"] > min_revision_pct).astype(int)
    signal.index = ticker_ests["date"]

    return signal

Source for estimate data: Consensus estimates are available via WRDS (institutional), Alpha Vantage ($50/mo for historical), or Estimize (crowd-sourced, free tier available).

Pattern 2: Insider Cluster Buying

When three or more insiders (C-suite, board members) buy stock in a 30-day window — all purchasing in the open market, not via options exercises — the stock outperforms by 6.8% over the following 63 trading days (one quarter).

Individual insider buys are noise. Three or more is a signal. The cluster effect separates conviction from routine compensation management.

Where to get the data: SEC Form 4 filings, parsed and aggregated. The OpenInsider API (free) is excellent for this.

def insider_cluster_signal(
    ticker: str,
    filings: pd.DataFrame,
    window_days: int = 30,
    min_buyers: int = 3
) -> pd.Series:
    """
    Signal when >= min_buyers insiders purchase open-market shares
    within a rolling window.
    """
    ticker_buys = filings[
        (filings.ticker == ticker) &
        (filings.transaction_type == "P") &  # Open market purchase
        (filings.relationship.isin(["CEO", "CFO", "COO", "Director", "VP"]))
    ].copy()

    ticker_buys = ticker_buys.sort_values("date")
    ticker_buys["cluster_count"] = ticker_buys["date"].rolling(
        f"{window_days}D"
    ).count()

    return (ticker_buys["cluster_count"] >= min_buyers).astype(int)

Pattern 3: Post-Earnings Drift (PED) on Beats + Raised Guidance

When a company beats EPS by more than 10% AND raises full-year guidance, the stock continues drifting upward for 10-20 trading days after earnings. Average continuation: +3.1% beyond the initial gap-up.

The market anchors on the initial reaction price. Institutional funds that can't move large positions instantly continue accumulating over the following weeks.

Implementation note: You need to distinguish "beat + raise" from "beat + maintain" — the latter has no PED signal. Source guidance change data from Estimize or manual parsing of earnings transcripts.

Pattern 4: Low-Volume Base Breakouts in Uptrending Sectors

When a stock in an outperforming sector (top decile of 90-day sector returns) breaks above a 52-week high on volume that is 1.5-2.5x average — but NOT on 5x+ volume (which indicates short covering, not organic demand) — it continues trending for 21 days in 58% of cases.

The "moderate volume" filter is the key insight. Explosive volume breakouts often mark exhaustion. Quiet, steady volume on breakouts indicates institutional accumulation, not retail FOMO.

def quiet_breakout_signal(prices: pd.DataFrame, sector_rank: float) -> pd.Series:
    """
    Signal: new 52-week high + moderate volume + top-decile sector.
    sector_rank: percentile rank of sector's 90-day return (0-1)
    """
    high_52w = prices["Close"].rolling(252).max().shift(1)
    avg_vol = prices["Volume"].rolling(20).mean()
    vol_ratio = prices["Volume"] / avg_vol

    signal = (
        (prices["Close"] > high_52w) &          # New high
        (vol_ratio >= 1.5) & (vol_ratio <= 2.5) & # Moderate volume
        (sector_rank >= 0.9)                     # Top-decile sector
    ).astype(int)

    return signal

Combining Signals: The Confluence Stack

No single signal is a trading plan. I use these four as a confluence filter — the more that align, the higher the conviction:

Signals FiringHistorical Win Rate (21d)Average Return
1 of 452%+1.8%
2 of 458%+3.4%
3 of 467%+5.9%
4 of 474%+8.2%

I only trade when 2+ signals align. The drop-off in frequency is significant (4-signal setups happen maybe 2-3 times a month across the S&P 500), but the edge is real.

What AI Added to This Process

I used Claude throughout this project, but not for the reasons you might expect.

Most useful: Code review for methodology errors. I prompted Claude with my backtesting code and asked it to identify look-ahead bias, survivorship bias, or improper data handling. It found two genuine bugs.

Moderately useful: Literature review. I described my findings and asked Claude to identify published academic papers that either supported or contradicted the signals. It surfaced research on post-earnings drift and insider trading I hadn't read.

Least useful: Generating trading ideas. When I asked Claude to suggest signals to test, it produced reasonable-sounding but generally overfit ideas. Original research requires domain-specific intuition that general LLMs currently lack.

The Honest Bottom Line

Most retail trading strategies are random. The four patterns I found are real — but the edge is modest, requires patience, and depends on data sources that cost money to access properly.

The better news: the engineering skills required to build this system are the same skills that make you valuable in the AI engineering job market. Every data pipeline, signal aggregator, and backtesting framework I built here is the same architecture as the observability and telemetry systems I build in my day job.

The market rewards the same things engineering does: rigor, empiricism, and a willingness to be wrong.

Find the code for this backtesting framework on GitHub. Follow the analysis at @NewsQuantEdge.

Share this post

You Might Also Like

Stay in the Loop

Get weekly insights on AI-driven QA, engineering leadership, and automation strategies.

No spam, ever. Unsubscribe anytime.