I Backtested Every S&P 500 Stock with AI and Found the Only Patterns That Actually Hold Up
Suneet Malhotra
Apr 13, 2026
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:
| Category | Signals Tested | Example |
|---|---|---|
| Momentum | 12 | RSI, MACD, ROC, Stochastic |
| Mean Reversion | 8 | Bollinger Bands, Z-score, RSI reversal |
| Trend Following | 7 | Moving averages, ADX, Donchian |
| Volume/Microstructure | 6 | OBV, VWAP deviation, volume surge |
| Fundamental | 7 | P/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 Firing | Historical Win Rate (21d) | Average Return |
|---|---|---|
| 1 of 4 | 52% | +1.8% |
| 2 of 4 | 58% | +3.4% |
| 3 of 4 | 67% | +5.9% |
| 4 of 4 | 74% | +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
The Edge I Assume Is Already Decaying
Wall Street spent this week arguing that AI is cutting the useful life of a trading edge from seven years to eighteen months. If that is even half right, it changes what a signal is worth.
Quantitative TradingEvery Strategy Has a Size It Stops Working At
My backtest assumes my fills are free. That assumption is true right up until it is not, and nothing in the account tells me where the line is.
Agentic AIEverything in My Context Window Is an Instruction
This routine reads the open web, then commits to a live site with no human in the loop. Those two facts sit in the same context window, and the model has no way to tell them apart.
Career & Best PracticesThe If Statement My Audit Never Read
On May 20 I published a rule for which steps of a routine are safe to run twice, and put my repo pull in the safest bucket. On July 9 that step failed. It never ran.
Latest Blog Posts
Everything in My Context Window Is an Instruction
This routine reads the open web, then commits to a live site with no human in the loop. Those two facts sit in the same context window, and the model has no way to tell them apart.
The Edge I Assume Is Already Decaying
Wall Street spent this week arguing that AI is cutting the useful life of a trading edge from seven years to eighteen months. If that is even half right, it changes what a signal is worth.
The If Statement My Audit Never Read
On May 20 I published a rule for which steps of a routine are safe to run twice, and put my repo pull in the safest bucket. On July 9 that step failed. It never ran.
Related Tools & Demos
Multi-Model LLM Harness
One interface to call any AI model — capability routing, fallback chains, budgets, circuit breakers, and a quality feedback loop. A practical architecture pattern write-up.
Automated Trading System
Multi-engine trading platform with real-time risk management, regime-based strategy selection, and automated order execution.
View Source Code →Personal Health Analytics
Multi-modal health data platform integrating wearables, lab results, and lifestyle tracking with predictive habit modeling.
View Source Code →
Stay in the Loop
Get weekly insights on AI-driven QA, engineering leadership, and automation strategies.
No spam, ever. Unsubscribe anytime.