Build a Stock Scanner Series — Part 5 of 6

Backtesting Your Scanner — Proving It Works Before Risking Real Money

A scanner without validation is a random number generator with a nice UI. Build a backtest engine that simulates 2 years of trades, measures win rate and profit factor, and uses walk-forward validation to avoid overfitting.

Walk-Forward Testing Equity Curves Win Rate & PF Overfitting Traps
Build a Stock Scanner That Actually Works5/6
The ImperativeBacktest EngineKey MetricsWalk-ForwardOverfitting TrapsKey Takeaways
The Backtesting Imperative

Why You Must Backtest

You have built filters (Part 2), a scoring engine (Part 3), and a regime detector (Part 4). The question is: does it actually work? Not "does it look good on today's data" — but does it produce profitable trades consistently over months and years?

The answer requires running your scanner historically and simulating every trade it would have generated. This is backtesting — the scientific method applied to trading.

What You Backtest

You do not backtest the scanner itself — you backtest the trades it generates. For every historical "pass" event, you simulate: entry at next day's open, stop at N x ATR below entry, target at M x ATR above entry. Then you track whether the trade hit the target or the stop.

The Backtest Pipeline

Python — Backtest Engine
class BacktestEngine:
    """Simulate scanner trades over historical data."""

    def __init__(self, scanner, universe_bars, atr_stop=1.5, atr_tp1=2.0, atr_tp2=3.0):
        self.scanner = scanner
        self.bars = universe_bars  # dict of ticker -> DataFrame
        self.atr_stop = atr_stop
        self.atr_tp1 = atr_tp1
        self.atr_tp2 = atr_tp2
        self.trade_journal = []

    def run(self, start_date, end_date):
        """Run scanner for each trading day and simulate trades."""
        dates = pd.bdate_range(start_date, end_date)

        for date in dates:
            # Build universe snapshot for this date
            snapshot = self._build_snapshot(date)
            if snapshot is None: continue

            # Run scanner (regime + filter + score)
            top10 = self.scanner.daily_scan(snapshot, date)

            # Simulate each trade
            for _, setup in top10.iterrows():
                trade = self._simulate_trade(setup, date)
                if trade:
                    self.trade_journal.append(trade)

        return pd.DataFrame(self.trade_journal)

    def _simulate_trade(self, setup, signal_date):
        """Simulate a single trade with ATR-based stops/targets."""
        ticker = setup['ticker']
        bars = self.bars[ticker]

        # Entry: next day's open
        entry_idx = bars.index.get_loc(signal_date) + 1
        if entry_idx >= len(bars): return None
        entry_price = bars.iloc[entry_idx]['open']
        atr = setup['atr_14']

        # Levels
        stop = entry_price - self.atr_stop * atr
        tp1 = entry_price + self.atr_tp1 * atr
        tp2 = entry_price + self.atr_tp2 * atr

        # Walk forward day by day
        for i in range(entry_idx + 1, min(entry_idx + 30, len(bars))):
            bar = bars.iloc[i]
            if bar['low'] <= stop:
                return self._log_trade(setup, entry_price, stop,
                    'stopped', bars.index[i])
            if bar['high'] >= tp1:
                return self._log_trade(setup, entry_price, tp1,
                    'tp1_hit', bars.index[i])

        # Max holding period reached — exit at close
        exit_price = bars.iloc[min(entry_idx+29, len(bars)-1)]['close']
        return self._log_trade(setup, entry_price, exit_price,
            'timeout', bars.index[min(entry_idx+29, len(bars)-1)])
Key Metrics

Measuring Scanner Performance

MetricFormulaTargetWhy It Matters
Win RateWinning trades / Total trades> 55%What % of trades are profitable. Below 45% is concerning.
Profit FactorGross profit / Gross loss> 1.5How much you make per dollar lost. Below 1.0 = losing money.
Sharpe RatioMean return / Std dev of returns> 1.0Risk-adjusted return. Above 2.0 is excellent.
Max DrawdownWorst peak-to-trough decline< 20%How much pain you endure. Above 30% and most traders quit.
Average R/RMean win size / Mean loss size> 1.5:1Your wins should be larger than your losses.
Trade CountTotal trades in backtest period> 100Statistical significance. 20 trades proves nothing.
Python — Metrics Calculator
def compute_metrics(journal_df):
    """Compute key backtest metrics from trade journal."""
    wins = journal_df[journal_df['pnl'] > 0]
    losses = journal_df[journal_df['pnl'] <= 0]

    win_rate = len(wins) / len(journal_df) * 100
    profit_factor = wins['pnl'].sum() / abs(losses['pnl'].sum()) if len(losses) > 0 else float('inf')
    avg_rr = wins['pnl'].mean() / abs(losses['pnl'].mean()) if len(losses) > 0 else float('inf')

    # Sharpe (annualized)
    daily_returns = journal_df.groupby('exit_date')['pnl_pct'].sum()
    sharpe = daily_returns.mean() / daily_returns.std() * (252 ** 0.5)

    # Max drawdown
    cumulative = (1 + daily_returns / 100).cumprod()
    peak = cumulative.expanding().max()
    drawdown = (cumulative - peak) / peak * 100
    max_dd = drawdown.min()

    return {
        'trades': len(journal_df),
        'win_rate': round(win_rate, 1),
        'profit_factor': round(profit_factor, 2),
        'sharpe': round(sharpe, 2),
        'max_drawdown': round(max_dd, 1),
        'avg_rr': round(avg_rr, 2),
    }
Walk-Forward Validation

The Gold Standard: Walk-Forward

A simple backtest (train on all data, test on all data) is almost guaranteed to overfit. Walk-forward validation solves this by sliding a training window forward through time:

Walk-Forward Steps

  1. Window 1: Train on months 1-24. Test on months 25-30. Record results.
  2. Window 2: Train on months 7-30. Test on months 31-36. Record results.
  3. Window 3: Train on months 13-36. Test on months 37-42. Record results.
  4. Continue sliding until you exhaust your data.
  5. Your backtest result is the average of all test windows — not the cherry-picked best one.

If your scanner performs well in-sample but terribly out-of-sample, it is overfit. The walk-forward result is the honest estimate of live performance.

Python — Walk-Forward
def walk_forward(engine, full_data, train_months=24, test_months=6, step=6):
    """Walk-forward validation with sliding windows."""
    results = []
    start = full_data.index.min()
    end = full_data.index.max()

    window_start = start
    while True:
        train_end = window_start + pd.DateOffset(months=train_months)
        test_end = train_end + pd.DateOffset(months=test_months)
        if test_end > end: break

        # Train: optimize thresholds on training data
        train_data = full_data[window_start:train_end]
        engine.optimize(train_data)

        # Test: run scanner on unseen data
        test_data = full_data[train_end:test_end]
        journal = engine.run(test_data)
        metrics = compute_metrics(journal)
        metrics['window'] = f"{train_end:%Y-%m} to {test_end:%Y-%m}"
        results.append(metrics)

        window_start += pd.DateOffset(months=step)

    return pd.DataFrame(results)
Overfitting Traps

How to Ruin a Perfectly Good Scanner

TrapHow It HappensHow to Avoid It
Too Many ParametersYour filter has 12 criteria with specific thresholds. It fits the past perfectly and the future terribly.Keep it to 4-6 criteria per strategy. If you need 12 filters to make it work, the strategy is not real.
Threshold MiningYou test RSI thresholds from 20 to 40 in steps of 1 and pick the "best" one. That is data mining.Use round numbers (30, 35, 40). If RSI 32.7 works but 30 does not, the edge is noise.
Survivorship BiasYour universe only includes stocks that still exist today. All the bankrupt ones are invisible.Include delisted stocks in backtests. Use point-in-time constituent lists.
Look-Ahead BiasYou accidentally use tomorrow's data to make today's decision (e.g., closing price for a signal that triggers at the open).Entry always at next day's open. All indicators use data available at signal time.
Small Sample SizeYou backtest 6 months and get 30 trades. That is not statistically significant.Minimum 100 trades. Minimum 2 years of data. Minimum 2 market regimes.

The Regime Backtest

The ultimate validation: does your scanner perform BETTER in the right regime? Run your backtest, then split results by regime. Momentum trades should have higher win rates in Risk-On. Mean-reversion trades should outperform in Risk-Off. If there is no difference, your regime detector is not adding value.

When to Kill a Strategy

Not all strategies survive contact with reality. Here are the kill signals:

Benchmarking Your Scanner

Is Your Scanner Actually Adding Value?

A backtest that shows positive returns is meaningless without context. The critical question is: does your scanner beat the alternatives? If your scanner returns 18% annually but SPY returned 22%, you are paying yourself in complexity for underperformance. Every scanner must be measured against appropriate benchmarks.

The Three Benchmarks Every Scanner Must Beat

Benchmark 1 — Buy & Hold SPY: The simplest possible strategy. If your scanner cannot beat buying SPY and holding it, the scanner is destroying value. This is the absolute minimum bar. Approximately 80% of professional fund managers fail to beat this benchmark over 10 years.

Benchmark 2 — Equal-Weight Universe: Buy every stock in your scanner's universe with equal weight and rebalance monthly. This tests whether your selection adds value beyond just exposure to the universe. If your scanner picks from the Russell 2000 but cannot beat an equal-weight Russell 2000 portfolio, your scoring system is not working.

Benchmark 3 — Random Selection: Pick N random stocks from the same universe at each rebalance. Run this 1,000 times and compute the distribution. Your scanner should beat the 75th percentile of random selection — otherwise, a monkey throwing darts would match your results a quarter of the time.

MetricYour ScannerBuy & Hold SPYEqual-Weight UniverseRandom (Median)
Annual Return+24.3%+12.1%+14.8%+11.2%
Sharpe Ratio1.420.780.650.52
Max Drawdown-14.2%-18.7%-22.4%-26.8%
Win Rate57%N/AN/A48%
Turnover (Monthly)35%0%8%35%
Net of Costs+21.8%+12.0%+14.2%+8.7%

Calculating Information Ratio

The Information Ratio (IR) measures your scanner's skill relative to its benchmark, adjusted for tracking error. It answers: "How much extra return do I earn per unit of extra risk I take versus the benchmark?"

IR = (Scanner Return - Benchmark Return) / Tracking Error
Where Tracking Error = Std Dev of (Scanner Return - Benchmark Return) across periods

Example: Scanner = +24.3%, SPY = +12.1%, Tracking Error = 8.5%
IR = (24.3 - 12.1) / 8.5 = 1.44 — Excellent (> 1.0 is top-tier)

Regime-Conditional Benchmarking

Global benchmarks can hide regime-specific failures. Your scanner might crush the benchmark in Risk-On but get destroyed in Risk-Off, with the overall numbers still looking acceptable. Always split your benchmark comparison by regime:

The Cost-Adjusted Reality Check

Many scanners beat their benchmark on a gross basis but underperform net of costs. A scanner with 35% monthly turnover generates ~8.4 round-trip trades per position per year. At 0.30% cost per round trip, that is 2.5% annual drag. Your gross alpha must exceed this hurdle just to break even versus buy-and-hold. High-turnover strategies need proportionally higher gross returns to justify their existence. Always report net-of-cost results alongside gross returns.

Knowledge Check

Test Your Understanding

Quiz 1: Your backtest shows a 87% win rate and Sharpe ratio of 4.2. What's your reaction?
Answer: Suspicion, not celebration. A Sharpe of 4.2 and 87% win rate are almost certainly red flags indicating one or more biases: look-ahead bias (using future data), survivorship bias (missing delisted stocks), or overfitting (parameters tuned to historical noise). Results this good in finance are extraordinarily rare — even Renaissance Technologies reportedly achieves Sharpe ratios around 2-3. Review the methodology thoroughly. Check for data leaks, ensure stops aren't unrealistically wide, and run the same scanner in a 30-day forward test. Live results will reveal the truth.
Quiz 2: What is look-ahead bias? Give a concrete example with a scanner.
Answer: Look-ahead bias is using information in a backtest that wasn't available at the time of the simulated trade. Concrete example: your scanner uses "quarterly revenue beat" as a bullish signal. In the backtest, you check revenue on the quarter-end date (March 31). But the 10-Q filing wasn't published until April 28. Between March 31 and April 28, you're using data you wouldn't have had in real-time. The fix is to use point-in-time databases where fundamental data only becomes available on the SEC filing date, not the period-end date. This is one of the most insidious biases because it's easy to introduce accidentally and hard to detect.
Quiz 3: Why is walk-forward optimization better than a single in-sample backtest?
Answer: A single in-sample backtest uses the same data for both training and testing, which is the textbook definition of overfitting. You're optimizing parameters to fit historical noise, not genuine market patterns. Walk-forward optimization trains on one time window and tests on a completely separate future window, repeating this across 4-6 windows. This produces multiple independent out-of-sample tests. A strategy that works in 5 out of 6 walk-forward windows spanning different market conditions (bull, bear, transition) has demonstrated genuine predictive power — not just historical curve-fitting.
Quiz 4: Your scanner has a 60% win rate and average R:R of 1.5:1. What is the expectancy per trade?
Answer: Expectancy = (Win% x Avg Win) - (Loss% x Avg Loss) = (0.60 x 1.5R) - (0.40 x 1.0R) = 0.90R - 0.40R = +0.50R per trade. This means for every dollar risked, you expect to make $0.50 on average. Over 200 trades, that's +100R — an excellent result. This combination places the scanner firmly in the "Good to Excellent" range. The key insight: you don't need a high win rate if your R:R is favorable. A 45% win rate with 2.5:1 R:R also produces +0.575R expectancy.
Quiz 5: Why should your backtest include transaction costs, and how much should you model?
Answer: A strategy with 0.3% average return per trade can be net negative after costs. Realistic cost modeling: Commission: $5 per trade (or $0 for commission-free brokers but still model spread). Bid-ask spread: Half the average spread — approximately 0.03-0.05% for large-caps, 0.15-0.30% for small-caps. Slippage: 0.05-0.10% for liquid stocks, 0.20-0.50% for small-caps (your order moves the price). Market impact: For positions above $100K, add 0.05-0.20% depending on average daily volume. Total round-trip cost: typically 0.20-0.50% for liquid stocks, 0.50-1.50% for small-caps. If your average trade return is below this threshold, the strategy is net negative despite looking profitable gross of costs.
Quiz 6: Your walk-forward shows great results in 5/6 windows but terrible in window 3 (the 2022 bear market). What do you do?
Answer: Don't throw out the scanner. First, diagnose: window 3 corresponds to a specific regime (Risk-Off/bear market). If your scanner performed well in all other regimes but failed in the bear, the fix is likely regime detection (Part 4) — the scanner needs to switch strategies when the regime changes. Check whether the regime classifier correctly identified the 2022 bear market. If it did but the scanner still ran momentum strategies, there's a bug in the strategy rotation logic. If the classifier missed it, recalibrate the regime thresholds. The key principle: a scanner that works in 5/6 environments and correctly avoids the 6th (via regime detection) is a strong scanner.
Key Takeaways

Key Takeaways — Part 5

Part 6 of 6
Going Live — From Prototype to Daily Edge
Build a Stock Scanner That Actually Works5/6