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_estandarA 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 = 0This 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
- Z-scorein the glossary
- Momentumin the glossary
- Momentum investing: what it is and its S&P 500 recordguide