Quant500 Academy

6. Z-score: when a price is too high or too low

Level 5: Momentum, Mean-Reversion and Pairs Trading Strategies

To turn "the price is abnormally high or low" into a programmable rule, you need a concrete measure of how far the price sits from its average, expressed in units that are comparable across different assets. That measure is the z-score: how many standard deviations separate a value from the mean of its series.

z = (x - media) / desviacion_estandar

A z-score of 1 means the price is one standard deviation above the mean; a z-score of -2, two standard deviations below. The advantage of working in standard deviations rather than absolute price is that the same threshold (z > 1, say) means the same thing for a €20 share as for a €2,000 one.

Applied to a single asset, the strategy first computes the expanding average of the price — the mean of every day up to the current date, not a fixed window — and from there, each day's z-score:

datos = yf.download("AAPL", start="2022-02-02", progress=False)
datos["media_acumulada"] = [datos["Close"][:i+1].mean() for i in range(len(datos))]

zscores = []
for i in range(len(datos)):
    desviacion = np.std(datos["Close"][:i+1])
    if desviacion == 0:
        zscores.append(0)
    else:
        z = (datos["Close"].iloc[i] - datos["media_acumulada"].iloc[i]) / desviacion
        zscores.append(z)

Using the mean and the standard deviation accumulated up to each day, rather than computed over the whole history in one go, matters for the same reason as the shift(1) in the previous module: at the real moment of trading day i, you do not yet know the prices of the days that follow. Computing the mean with future data would be another form of look-ahead bias, though a subtler one than before.

With the z-score computed, the strategy's rule is simple: sell short when the z-score rises above 1 (the price is abnormally expensive), buy long when it falls below -1 (abnormally cheap), and close any position when the z-score returns close to zero — within half a point — because at that moment the price has already reverted to its average and the trade's edge is exhausted.

dinero = 0
posicion = 0

for i in range(len(datos)):
    if zscores[i] > 1:
        dinero += datos["Open"].iloc[i]   # the condition should be evaluated with zscores[i-1] (the z-score that already existed at yesterday's close) and executed at today's open
        posicion -= 1
    elif zscores[i] < -1:
        dinero -= datos["Close"].iloc[i]
        posicion += 1
    elif abs(zscores[i]) < 0.5:
        dinero += posicion * datos["Close"].iloc[i]
        posicion = 0

This snippet is, once again, a simplified simulation, not a full backtest: dinero accumulates the cash flow of buying and selling, with no commissions and no position sizing, and posicion can build up over several trades in a row without any limit. It is there to help you understand the mechanics of the z-score applied to a strategy; it is not ready to decide how much real capital to risk.

To go deeper into this lesson

The rest of Quant500

What you are learning, applied to the index every day:

The model portfolio, weekly in your inbox

Every Saturday, its week in one minute: how it went, the best and the worst, and which insiders bought with their own money. Plus, the day the model is rebuilt, which stocks come in and which go out. A simulation, not advice. At most one email a week, no ads, no account; one-click unsubscribe. Made by one person: Pol Gracia.

A model portfolio with simulated results, not a personalized recommendation. Your email is used for this only: privacy.

Behind the Academy and the rest of Quant500 there is one person: Pol Gracia Serrahima.