Skip to content
Dog on a Leash
Method4 min read

Building a z-score screen that isn't just a bad-news detector

A naive oversold screen returns the week's worst headlines. Four filters turn it into a list of stretched prices — with the code and the reasoning for each.

Published

The simplest possible mean-reversion screen is one line: rank every stock by how many standard deviations it sits below its 50-day moving average, take the bottom fifty.

Run it on any given Friday and you will get a list of companies that had catastrophic news that week. Fraud allegations, failed trials, guidance cuts, one bank. This is not a screen for stretched prices. It is a bad-news detector with extra arithmetic.

Here is how to fix it, one filter at a time, with the reason each is there.

The base measurement

import pandas as pd

def zscore(close: pd.Series, window: int = 50) -> pd.Series:
    """Distance from the rolling mean in units of rolling volatility."""
    mean = close.rolling(window).mean()
    std = close.rolling(window).std()
    return (close - mean) / std

Two choices are already baked in and both matter.

The window. 50 days is not sacred. It should be chosen against the half-life of the deviations you are trying to catch — a window far shorter than the half-life produces a mean that chases price, and one far longer produces a mean that ignores real regime changes. Fifty is a reasonable default for swing holding periods of one to six weeks.

Price, not returns. Using price levels means a stock in a strong uptrend will rarely register as oversold, which is usually what you want from a reversion screen.

Filter 1 — Liquidity

liquid = (
    df.groupby("ticker")["dollar_volume"]
      .rolling(20).median()
      .gt(5_000_000)
)

Median dollar volume, not mean, and not share volume. The mean is dragged around by a single frantic session, which is exactly the day you are screening on. Share volume tells you nothing without price.

Five million a day is a starting threshold for retail-size positions. The real test is whether your intended position is a meaningful fraction of a normal day’s volume — if it is more than about 1%, your own exit becomes part of the problem.

Filter 2 — Exclude the event

This is the filter that turns the screen from bad-news detector into something useful. Drop any name whose extreme z-score was manufactured by a single session’s gap.

gapped = df["overnight_return"].abs().rolling(5).max().gt(0.15)
screen = screen[~gapped]

The logic: a stock that ground down 18% over six weeks and a stock that fell 18% in one overnight gap have identical z-scores and completely different distributions of what happens next. The grinder is a candidate for reversion. The gapper is a company where something is now known that was not known before — the mean moved, and your rolling window has not caught up.

You will exclude some genuine overreactions this way. That is an acceptable price for excluding the structural repricings, which are the ones that take a year to recover from, if they ever do.

Filter 3 — Is the anchor stable?

The screen assumes there is a mean to revert to. Test it rather than assuming it. The cheapest version is a slope check on the mean itself:

slope = mean.pct_change(60)          # is the anchor itself falling?
screen = screen[slope > -0.15]       # drop names whose mean fell >15% in a quarter

A more rigorous version runs an ADF test on the price series, or on the ratio to a sector ETF, and keeps only names where stationarity is not rejected. That is more work per name and worth doing on the shortlist rather than the universe.

Add one fundamental sanity check if you have the data: names where forward earnings estimates have been cut more than ~20% in ninety days are, definitionally, names where the anchor moved.

Filter 4 — Quality, cheaply defined

You do not need a factor model. You need to not buy things that go to zero. Two crude filters do most of the work:

  • Positive free cash flow in at least three of the last four quarters.
  • Net debt / EBITDA below 4×, or no net debt at all.

The purpose is not to find good companies. It is to remove the names for which the left tail is total loss rather than a drawdown. Mean reversion has a survivorship problem baked in at the strategy level: the historical record you are learning from is written by the survivors, and the ones that did not revert are not in your data as losses — they are simply absent.

Putting it together

candidates = (
    universe
    .pipe(add_zscore, window=50)
    .query("dollar_volume_med20 > 5_000_000")
    .query("not gapped_5d")
    .query("mean_slope_60d > -0.15")
    .query("fcf_positive_quarters >= 3 and net_debt_ebitda < 4")
    .query("zscore < -2.0")
    .sort_values("zscore")
)

On a typical week this returns somewhere between zero and a dozen names, and the zero weeks are informative. A screen that always returns candidates has its threshold set to produce candidates, not to identify them.

What the screen does not do

It does not tell you to buy. It produces a shortlist to look at, and the looking still matters: read the last earnings call, find out what the market thinks it now knows, decide whether that is a change in μ or a change in mood.

It does not size the position. That is a separate decision with a separate set of rules, and it is the one that determines whether the strategy survives its inevitable bad month.

And it does not have an exit. A screen that generates entries and no exits is half a system — worse than none, because it feels complete. Exit rules are the next post: time-based on the half-life, target-based on the z-score returning to zero, and the stop that fires when the anchor moves.

Every number in this post is a starting point to be tested against your own universe and horizon, not a recommendation. I hold positions in names that pass screens like this one.

This post is research and opinion for educational purposes only. It is not investment advice and not a recommendation to buy or sell any security. Full disclaimer.