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.
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.
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.
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)])
| Metric | Formula | Target | Why It Matters |
|---|---|---|---|
| Win Rate | Winning trades / Total trades | > 55% | What % of trades are profitable. Below 45% is concerning. |
| Profit Factor | Gross profit / Gross loss | > 1.5 | How much you make per dollar lost. Below 1.0 = losing money. |
| Sharpe Ratio | Mean return / Std dev of returns | > 1.0 | Risk-adjusted return. Above 2.0 is excellent. |
| Max Drawdown | Worst peak-to-trough decline | < 20% | How much pain you endure. Above 30% and most traders quit. |
| Average R/R | Mean win size / Mean loss size | > 1.5:1 | Your wins should be larger than your losses. |
| Trade Count | Total trades in backtest period | > 100 | Statistical significance. 20 trades proves nothing. |
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), }
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:
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.
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)
| Trap | How It Happens | How to Avoid It |
|---|---|---|
| Too Many Parameters | Your 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 Mining | You 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 Bias | Your 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 Bias | You 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 Size | You 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 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.
Not all strategies survive contact with reality. Here are the kill signals:
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.
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.
| Metric | Your Scanner | Buy & Hold SPY | Equal-Weight Universe | Random (Median) |
|---|---|---|---|---|
| Annual Return | +24.3% | +12.1% | +14.8% | +11.2% |
| Sharpe Ratio | 1.42 | 0.78 | 0.65 | 0.52 |
| Max Drawdown | -14.2% | -18.7% | -22.4% | -26.8% |
| Win Rate | 57% | N/A | N/A | 48% |
| Turnover (Monthly) | 35% | 0% | 8% | 35% |
| Net of Costs | +21.8% | +12.0% | +14.2% | +8.7% |
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?"
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:
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.