Building a Mean-Reversion Strategy From Scratch, Tested Honestly
Most strategy content presents the finished product: a set of rules, an attractive equity curve, and the implication that you could have had it too. What it omits is the process. The hypothesis that required narrowing, the first test that came back unremarkable, the point at which you have to decide whether a disappointing result means adjust the strategy or means you are about to overfit.
This piece is that process from start to finish. We build a mean-reversion strategy on liquid US equities, write the code, and test it honestly. The objective is not to hand over a system to trade. It is to demonstrate a repeatable method applicable to any hypothesis you hold, and to show what an honest result looks like, including the portions that do not flatter the strategy.
Everything here runs on free data using standard Python libraries.
Step 1: Start with a hypothesis, not an indicator
The most reliable way to build a poor strategy is to begin with an indicator and search for something it predicts. That sequence is backwards, and it produces exactly the behavior that leads to testing hundreds of parameter combinations until one fits.
Begin instead with a claim about why an edge should exist, an economic or behavioral reason that money is available. The rules follow as an attempt to express that claim in code.
Our hypothesis:
When a liquid, large-cap stock declines sharply over a short window without a change in the underlying business, a portion of that move is liquidity-driven and mechanical, reflecting forced selling, stop cascades, and short-term overreaction rather than information. That portion tends to partially revert over the following days.
This is a well-documented effect, short-term reversal, which is both encouraging and problematic. Encouraging because we are not inventing a pattern from noise. Problematic because a well-known effect is a crowded one, and any remaining edge is likely small and fragile after costs. We should expect a modest result rather than a spectacular one. Establishing that expectation in advance is what prevents us from torturing the data later to produce something impressive.
Note what the hypothesis excludes. It specifies liquid, large-cap names, where information moves quickly and mechanical flow represents a larger share of short-term price action, and it specifies short windows. That specificity is a feature. It constrains how many variations we are permitted to try.
Step 2: Define the rules before you see results
Write the rules now, while you have no idea whether they work. This is the single most effective defense against overfitting, because rationalizing a change becomes considerably harder once you have committed against it.
Universe: S&P 500 constituents, for liquidity and plausible slippage. See the survivorship note below, which matters more than it sounds.
Entry: Buy at the following day's open when a stock's 2-day return falls in the bottom decile of the universe that day.
Exit: Sell at the open 5 trading days later. Fixed holding period, no discretion.
Sizing: Equal weight across qualifying positions, maximum of 10 positions.
Costs: 5 basis points per side, covering commission plus a conservative slippage estimate on liquid large caps.
Four parameters in total: lookback of 2 days, threshold at the bottom decile, holding period of 5 days, position cap of 10. That is deliberately few. Every additional parameter multiplies the number of combinations available to search, and every combination is another opportunity to find something that worked by chance.
Step 3: Get the data right
Before writing anything resembling a strategy, address the data problems that quietly manufacture returns.
Survivorship bias. Pulling today's S&P 500 membership and testing it across the last ten years means testing a universe selected for having survived. Every company that collapsed or was delisted is absent, and an entire category of large losses has been removed from the history. Results will look materially better than reality.
The correct fix is a point-in-time universe, index membership as it stood on each historical date. Free data sources generally do not provide this. There are three honest responses: purchase point-in-time constituent data, approximate using a broad ETF's holdings history, or accept the bias, state it explicitly, and treat the results as an optimistic ceiling rather than an estimate. The unacceptable response is being unaware it is present.
This walkthrough uses a fixed current universe and flags the bias prominently. Any result is inflated by an unknown amount. That is a limitation to publish, not to conceal.
Corporate actions. Use adjusted prices, or a stock split will register as a 50% decline and the strategy will buy a dip that never occurred.
Look-ahead. The signal uses data through today's close and the trade executes at tomorrow's open. Never same-bar.
import numpy as np
import pandas as pd
import yfinance as yf
def load_prices(tickers, start, end):
"""
Download adjusted OHLC data. Returns opens and closes.
auto_adjust=True handles splits and dividends.
"""
raw = yf.download(
tickers,
start=start,
end=end,
auto_adjust=True,
progress=False,
group_by="column",
)
opens = raw["Open"].copy()
closes = raw["Close"].copy()
# Drop tickers with excessive missing data rather than forward-filling
# them into existence. Forward-filling a dead ticker creates artificial
# flat prices and artificial trades.
valid = closes.columns[closes.isna().mean() < 0.10]
return opens[valid], closes[valid]The valid filter matters. Forward-filling missing prices is a common convenience that generates trades on stocks that were not trading, a small decision that produces returns from nothing.
Step 4: Build the signal
def build_signal(closes, lookback=2, decile=0.10):
"""
Signal computed on data available through each day's close.
Returns a boolean DataFrame: True = candidate for entry at NEXT open.
"""
returns = closes.pct_change(lookback)
# Rank cross-sectionally: for each date, where does this stock's
# recent return sit relative to every other stock that day?
ranks = returns.rank(axis=1, pct=True)
return ranks <= decileRanking cross-sectionally rather than applying an absolute threshold, such as down more than 5%, is deliberate. An absolute threshold means something entirely different in a calm market than in a volatile one. In March 2020 nearly everything would have qualified. A relative rank adapts automatically, so the rule carries the same meaning across regimes.
Step 5: The backtest engine
The engine's only responsibility is to be pessimistic and accurate about timing. Signals from today's close, fills at tomorrow's open, costs charged on both sides.
def backtest(opens, closes, signal, hold_days=5, max_positions=10, cost_bps=5):
"""
Equal-weighted, fixed-holding-period backtest.
Signal on day T -> enter at open of T+1 -> exit at open of T+1+hold_days.
"""
dates = closes.index
cost = cost_bps / 10_000.0
daily_pnl = pd.Series(0.0, index=dates)
trades = []
for i, date in enumerate(dates):
# Need room for entry (i+1) and exit (i+1+hold_days)
if i + 1 + hold_days >= len(dates):
break
candidates = signal.loc[date]
candidates = candidates[candidates].index.tolist()
if not candidates:
continue
# Deterministic selection when candidates exceed available slots:
# take the worst recent performers. No random choice, no
# hindsight-based picking.
recent = closes.loc[date, candidates] / closes.iloc[i - 2][candidates] - 1
selected = recent.nsmallest(max_positions).index.tolist()
entry_date = dates[i + 1]
exit_date = dates[i + 1 + hold_days]
weight = 1.0 / len(selected)
for ticker in selected:
entry_px = opens.loc[entry_date, ticker]
exit_px = opens.loc[exit_date, ticker]
if np.isnan(entry_px) or np.isnan(exit_px):
continue
gross = (exit_px / entry_px) - 1
net = gross - (2 * cost) # cost charged on entry and exit
daily_pnl.loc[exit_date] += net * weight
trades.append({
"entry_date": entry_date,
"exit_date": exit_date,
"ticker": ticker,
"gross_return": gross,
"net_return": net,
})
return daily_pnl, pd.DataFrame(trades)Two details deserve attention. Costs are charged on both sides of every trade, the assumption most retail backtests omit and the one that eliminates marginal strategies. And selection among excess candidates is deterministic, so results are reproducible rather than dependent on a random seed.
Step 6: Split the data before examining anything
This is the discipline separating a test from an exercise in self-deception.
IN_SAMPLE = ("2010-01-01", "2018-12-31")
OUT_SAMPLE = ("2019-01-01", "2024-12-31")You develop on the in-sample period, examining it, tuning within it, and drawing conclusions from it. Then you run the out-of-sample period once, and whatever it returns is your answer.
That word "once" carries enormous weight. The moment you review out-of-sample results and return to adjust the strategy, that period is no longer out-of-sample. You have used it for fitting and quietly converted your only honest test into additional in-sample data. Repeat this three or four times and no unbiased estimate remains, with no way to detect the loss.
Step 7: Evaluation metrics
def evaluate(daily_pnl, trades, periods_per_year=252):
total_return = (1 + daily_pnl).prod() - 1
n_years = len(daily_pnl) / periods_per_year
cagr = (1 + total_return) ** (1 / n_years) - 1
vol = daily_pnl.std() * np.sqrt(periods_per_year)
sharpe = (daily_pnl.mean() * periods_per_year) / vol if vol > 0 else np.nan
equity = (1 + daily_pnl).cumprod()
drawdown = equity / equity.cummax() - 1
wins = trades["net_return"] > 0
avg_win = trades.loc[wins, "net_return"].mean()
avg_loss = trades.loc[~wins, "net_return"].mean()
win_rate = wins.mean()
return {
"CAGR": cagr,
"Sharpe": sharpe,
"Max Drawdown": drawdown.min(),
"Win Rate": win_rate,
"Avg Win": avg_win,
"Avg Loss": avg_loss,
"Expectancy": (win_rate * avg_win) + ((1 - win_rate) * avg_loss),
"Trades": len(trades),
}Note what appears alongside return: Sharpe, measuring return per unit of risk; maximum drawdown, measuring what you would have had to sit through; and expectancy per trade. A strategy with an attractive CAGR and a 60% drawdown is not tradeable by a human being, and a strategy whose gross edge is smaller than its cost drag is not a strategy.
Step 8: Results
Structure the results as follows.
In-sample, 2010 to 2018: CAGR, Sharpe, maximum drawdown, win rate, average win, average loss, expectancy per trade, trade count.
Out-of-sample, 2019 to 2024: the same metrics, presented alongside in-sample.
The comparison is the finding. The question is not whether out-of-sample is profitable. It is how much worse it is than in-sample and whether the degradation is explicable. Some decay is normal and expected. A modest decline across the board is what a real but small edge looks like once it stops being fitted. What should stop you is a large drop, a sign flip, or performance concentrated entirely in one period. For this family of strategy, a single volatile stretch such as early 2020 frequently carries the entire result.
Then run these robustness checks and publish all of them.
Parameter sensitivity. Re-run with lookbacks of 1, 2, and 3 days, holding periods of 3, 5, and 10 days, and deciles of 5%, 10%, and 20%. A real edge degrades smoothly as parameters move away from the chosen values. If 2-day and 5-day is profitable while 3-day and 5-day is not, you have found a coincidence rather than an effect. Publish the entire grid, not the best cell.
Regime breakdown. Split results by year and by volatility environment. An edge existing only in high-volatility periods is a legitimate finding. It means the strategy is a volatility trade and should be described as one rather than as an all-weather system.
Cost sensitivity. Re-run at 10 and 20 basis points per side. Short-holding-period strategies are highly cost-sensitive, and if the edge disappears at 15 basis points, you have learned that your execution quality is the strategy.
What honest reporting looks like
There is a substantial probability this returns something unremarkable. Short-term reversal is among the most studied effects in equities, has been widely traded for decades, and much of the raw effect is consumed by transaction costs at retail execution quality. A thin, cost-fragile, regime-dependent edge is the expected outcome.
If that is the finding, publish it as such. The value of the process was never a strategy. It was a method and a set of honest numbers about a hypothesis most people trade on faith. "We tested the assumption everyone holds and here is precisely how much survives costs" is more useful research than another equity curve, and it is the kind of finding a reputation can be built on.
The failure mode to avoid is the one that resembles diligence: returning to add a filter, a volatility screen, a sector exclusion, or a trend condition until the numbers improve. Each addition is a parameter, each parameter is another draw from the lottery, and the resulting strategy will fit the history beautifully and fail forward. If you genuinely believe a filter belongs, the honest path is to add it to the hypothesis, re-derive the rules, and test on data you have not yet used.
The method, extracted
Remove the specifics and the reusable process is this:
- State a hypothesis about why money is available. If you cannot articulate why an edge should exist, you are pattern-matching noise.
- Write the rules before seeing results. Few parameters, committed in advance.
- Fix the data before trusting anything. Survivorship, adjustments, no forward-fill, correct signal-to-execution lag.
- Charge realistic costs on both sides of every trade.
- Split the data and test out-of-sample exactly once.
- Evaluate on risk-adjusted terms, not return alone.
- Publish the robustness grid, not the best cell.
- Report what you found, including when what you found is less than you hoped.
Apply that to your own hypothesis and you will produce something rarer than a profitable backtest: a result you can believe.
Next: the complete evaluation framework, covering how to score any strategy on expectancy, robustness, regime dependence, cost sensitivity, and sizing before it receives a dollar of real money.
put it to work
The Backtest Audit Checklist turns this into something you can run against your own strategy. Free, PDF, no card needed.
get it freeevery tool behind the research lives in resources →
