DEV Community

Ayrat Murtazin
Ayrat Murtazin

Posted on

Analyzing Rolling Z-Scores in Stock Trading with Python

Mean reversion is one of the oldest and most robust ideas in quantitative finance: prices that deviate significantly from their recent average tend to revert. The rolling z-score is a clean, interpretable way to quantify that deviation in real time. Rather than relying on subjective chart patterns, it gives you a normalized, statistically grounded measure of how far a stock's price has moved from its recent equilibrium.

In this article, you will build a complete rolling z-score trading signal from scratch using Python. We will pull real price data with yfinance, compute rolling z-scores with pandas, generate long and short signals at defined thresholds, visualize the results with matplotlib, and evaluate the strategy's behavior across a historical window. Every section is runnable as-is.


Most algo trading content gives you theory.
This gives you the code.

3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.

5,000 quant traders already run these:

Subscribe | AlgoEdge Insights

Analyzing Rolling Z-Scores in Stock Trading with Python

This article covers:

  • Section 1 — The Rolling Z-Score Concept:** What a z-score measures, why the rolling version is useful for trading, and the mathematical intuition behind it
  • Section 2 — Python Implementation:** Full setup, data download, z-score computation, signal generation, and visualization across four subsections
  • Section 3 — Results and Strategy Analysis:** What the backtest reveals, how the signals behave, and what realistic performance expectations look like
  • Section 4 — Use Cases:** Where rolling z-scores fit in a practical trading workflow
  • Section 5 — Limitations and Edge Cases:** Honest discussion of where the method breaks down

1. The Rolling Z-Score as a Trading Signal

A z-score answers a simple question: how many standard deviations away from the mean is this observation? In a static dataset, that calculation is straightforward. In financial markets, you are working with a time series that evolves every day, so you need a version that re-anchors itself continuously. That is the rolling z-score — at each point in time, it computes the z-score using only the most recent n observations.

The formula is familiar. For a price series P at time t, with a lookback window of n periods:

z(t) = (P(t) - mean(P[t-n:t])) / std(P[t-n:t])
Enter fullscreen mode Exit fullscreen mode

When z(t) is strongly positive, the price is well above its recent average — the asset may be overbought relative to its own recent history. When z(t) is strongly negative, it is below average and may be oversold. A mean-reversion strategy uses these extremes as entry signals: short when the z-score exceeds a high threshold, go long when it falls below a low threshold, and exit when it reverts toward zero.

What makes this approach appealing is its adaptability. A 30-day rolling window captures short-term deviations without being distorted by long-term price trends. If a stock has been trending upward for a year, a static z-score computed over the full year would almost never trigger a signal. The rolling version recalibrates to the recent regime, making signals more timely and contextually relevant. Think of it as a local barometer rather than a global one — it tells you how extreme conditions are right now relative to the recent past, not relative to all of history.

2. Python Implementation

2.1 Setup and Parameters

The strategy is controlled by four key parameters. TICKER sets the stock to analyze — swap this to any valid symbol. WINDOW is the lookback period in trading days used to compute the rolling mean and standard deviation. UPPER_THRESHOLD and LOWER_THRESHOLD define the z-score levels that trigger short and long signals respectively. Wider thresholds produce fewer but more extreme signals; tighter thresholds produce more frequent trades with smaller expected reversion.

# === Dependencies ===
# pip install yfinance pandas numpy matplotlib

import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

# === Strategy Parameters ===
TICKER = "SPY"
START_DATE = "2020-01-01"
END_DATE = "2024-12-31"
WINDOW = 30           # Rolling lookback in trading days
UPPER_THRESHOLD = 1.5  # Z-score level to trigger short signal
LOWER_THRESHOLD = -1.5 # Z-score level to trigger long signal
Enter fullscreen mode Exit fullscreen mode

Implementation chart

2.2 Data Download and Z-Score Computation

We pull adjusted closing prices directly from Yahoo Finance using yfinance. The rolling z-score is then computed using pandas rolling methods. Note that we use ddof=1 in the standard deviation to match the sample standard deviation convention. The first WINDOW - 1 rows will be NaN by design — there is not enough history to compute a meaningful z-score until the window is fully populated.

# === Download Price Data ===
raw = yf.download(TICKER, start=START_DATE, end=END_DATE, auto_adjust=True, progress=False)
prices = raw["Close"].squeeze()
prices.name = TICKER

# === Compute Rolling Z-Score ===
rolling_mean = prices.rolling(window=WINDOW).mean()
rolling_std = prices.rolling(window=WINDOW).std(ddof=1)
z_score = (prices - rolling_mean) / rolling_std

# === Combine into a single DataFrame ===
df = pd.DataFrame({
    "price": prices,
    "rolling_mean": rolling_mean,
    "rolling_std": rolling_std,
    "z_score": z_score
}).dropna()

print(f"Dataset: {len(df)} trading days  |  Z-score range: [{df['z_score'].min():.2f}, {df['z_score'].max():.2f}]")
Enter fullscreen mode Exit fullscreen mode

2.3 Signal Generation and Position Tracking

Signals are generated based on the z-score crossing the defined thresholds. A long signal fires when the z-score drops below LOWER_THRESHOLD, a short signal fires when it exceeds UPPER_THRESHOLD, and the position is closed when the z-score crosses back through zero. We track daily strategy returns by shifting the position signal by one day to avoid lookahead bias — you can only act on today's signal at tomorrow's open.

# === Generate Trading Signals ===
# 1 = long, -1 = short, 0 = flat
df["signal"] = 0
df.loc[df["z_score"] < LOWER_THRESHOLD, "signal"] = 1
df.loc[df["z_score"] > UPPER_THRESHOLD, "signal"] = -1

# === Forward-fill position (hold until z-score crosses zero) ===
position = []
current_pos = 0
for z, sig in zip(df["z_score"], df["signal"]):
    if sig != 0:
        current_pos = sig
    elif current_pos == 1 and z >= 0:
        current_pos = 0
    elif current_pos == -1 and z <= 0:
        current_pos = 0
    position.append(current_pos)

df["position"] = position

# === Compute Daily Returns ===
df["market_return"] = df["price"].pct_change()
df["strategy_return"] = df["market_return"] * df["position"].shift(1)

# === Cumulative Performance ===
df["cumulative_market"] = (1 + df["market_return"]).cumprod()
df["cumulative_strategy"] = (1 + df["strategy_return"]).cumprod()

# === Summary Statistics ===
total_trades = df["position"].diff().abs().gt(0).sum()
sharpe = df["strategy_return"].mean() / df["strategy_return"].std() * np.sqrt(252)
print(f"Total position changes : {total_trades}")
print(f"Annualized Sharpe Ratio: {sharpe:.2f}")
print(f"Strategy total return  : {(df['cumulative_strategy'].iloc[-1] - 1) * 100:.1f}%")
print(f"Buy-and-hold return    : {(df['cumulative_market'].iloc[-1] - 1) * 100:.1f}%")
Enter fullscreen mode Exit fullscreen mode

2.4 Visualization

The chart is split into three panels. The top panel shows the raw price series with the rolling mean overlaid so you can see when price is extended. The middle panel shows the z-score over time with threshold lines drawn as horizontal dashed references — this is where you identify signal clusters. The bottom panel plots cumulative returns for both the strategy and the buy-and-hold benchmark, which provides an immediate visual read on whether the mean-reversion logic adds value over the period.

plt.style.use("dark_background")

fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
fig.suptitle(f"{TICKER} — Rolling Z-Score Mean Reversion Strategy (Window={WINDOW}d)",
             fontsize=14, fontweight="bold", y=0.98)

# --- Panel 1: Price + Rolling Mean ---
ax1 = axes[0]
ax1.plot(df.index, df["price"], color="#4FC3F7", linewidth=1.2, label="Price")
ax1.plot(df.index, df["rolling_mean"], color="#FFB300", linewidth=1.0,
         linestyle="--", label=f"{WINDOW}d Rolling Mean")
ax1.set_ylabel("Price (USD)", fontsize=10)
ax1.legend(loc="upper left", fontsize=9)
ax1.grid(alpha=0.2)

# --- Panel 2: Z-Score with Thresholds ---
ax2 = axes[1]
ax2.plot(df.index, df["z_score"], color="#CE93D8", linewidth=1.0, label="Z-Score")
ax2.axhline(UPPER_THRESHOLD, color="#EF5350", linestyle="--", linewidth=1.0,
            label=f"Short threshold ({UPPER_THRESHOLD})")
ax2.axhline(LOWER_THRESHOLD, color="#66BB6A", linestyle="--", linewidth=1.0,
            label=f"Long threshold ({LOWER_THRESHOLD})")
ax2.axhline(0, color="white", linestyle=":", linewidth=0.8, alpha=0.5)
ax2.fill_between(df.index, df["z_score"], UPPER_THRESHOLD,
                 where=(df["z_score"] > UPPER_THRESHOLD), color="#EF5350", alpha=0.25)
ax2.fill_between(df.index, df["z_score"], LOWER_THRESHOLD,
                 where=(df["z_score"] < LOWER_THRESHOLD), color="#66BB6A", alpha=0.25)
ax2.set_ylabel("Z-Score", fontsize=10)
ax2.legend(loc="upper left", fontsize=9)
ax2.grid(alpha=0.2)

# --- Panel 3: Cumulative Returns ---
ax3 = axes[2]
ax3.plot(df.index, df["cumulative_strategy"], color="#FFCA28", linewidth=1.3,
         label="Z-Score Strategy")
ax3.plot(df.index, df["cumulative_market"], color="#78909C", linewidth=1.0,
         linestyle="--", label="Buy & Hold")
ax3.set_ylabel("Cumulative Return", fontsize=10)
ax3.set_xlabel("Date", fontsize=10)
ax3.legend(loc="upper left", fontsize=9)
ax3.grid(alpha=0.2)

ax3.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
plt.xticks(rotation=30, fontsize=8)
plt.tight_layout()
plt.savefig("rolling_zscore_strategy.png", dpi=150, bbox_inches="tight")
plt.show()
Enter fullscreen mode Exit fullscreen mode

Figure 1. Three-panel chart showing SPY price with rolling mean (top), the 30-day rolling z-score with long/short threshold bands (middle), and cumulative strategy vs. buy-and-hold returns (bottom) — red and green shading highlights periods where active signals were live.


Enjoying this strategy so far? This is only a taste of what's possible.

Go deeper with my newsletter: longer, more detailed articles + full Google Colab implementations for every approach.

Or get everything in one powerful package with AlgoEdge Insights: 30+ Python-Powered Trading Strategies — The Complete 2026 Playbook — it comes with detailed write-ups + dedicated Google Colab code/links for each of the 30+ strategies, so you can code, test, and trade them yourself immediately.

Exclusive for readers: 20% off the book with code MEDIUM20.

Join newsletter for free or Claim Your Discounted Book and take your trading to the next level!

3. Results and Strategy Analysis

Running this implementation on SPY from 2020 through 2024 reveals several characteristic behaviors of mean-reversion strategies on liquid index ETFs. The z-score oscillates within a relatively tight band during calm trending periods and spikes sharply during high-volatility episodes — the COVID drawdown in early 2020 and the 2022 rate-hike selloff both produced sustained periods where the z-score remained below the long threshold, generating extended long positioning.

The Sharpe ratio will vary meaningfully depending on the window and threshold combination chosen. With a 30-day window and ±1.5 thresholds on SPY, the strategy tends to produce a Sharpe in the range of 0.4 to 0.7 depending on the exact period — lower than a simple buy-and-hold in strongly trending bull markets, but with reduced drawdown during violent selloffs. The strategy earns its edge not from frequency but from selectivity: it only enters when deviation is statistically significant, and it exits cleanly when prices revert.

One important observation is that the strategy underperforms in strong trending regimes. When SPY trends upward continuously for months, z-scores remain near zero or mildly elevated, and the short signals triggered at UPPER_THRESHOLD get stopped out repeatedly. This is a feature, not a bug — the method is designed for oscillating, range-bound behavior. Understanding the market regime is therefore a prerequisite for deploying this signal with confidence.

4. Use Cases

  • Pairs trading and spread analysis. Rolling z-scores on a price spread between two correlated assets (e.g., XOM vs. CVX) are a foundational component of statistical arbitrage. The z-score tells you when the spread is historically wide enough to justify a convergence trade.

  • Volatility-adjusted position sizing. Because the z-score is normalized by rolling standard deviation, it naturally scales with volatility. High-volatility regimes require a larger price move to produce the same z-score, which means signal quality is self-adjusting without additional filtering logic.

  • Market regime filtering. Layer the z-score signal with a trend indicator (e.g., price above/below 200-day moving average) to restrict mean-reversion trades to sideways regimes only. This alone can materially improve risk-adjusted performance.

  • Multi-asset screening. Apply the z-score computation across a universe of stocks and rank them daily by z-score magnitude. Go long the most oversold names and short the most overbought in a market-neutral construction — this is the building block of a basic equity long/short strategy.

5. Limitations and Edge Cases

Trending markets punish mean reversion. The single most important limitation is that this strategy assumes prices oscillate around a stable mean. In strongly trending conditions, z-score signals generate false entries. You will short into a rising market and get stopped out repeatedly. Always check the broader trend context before deploying.

Window length sensitivity. The choice of WINDOW has a significant effect on signal frequency and quality. A 10-day window is reactive but noisy. A 60-day window is smoother but lags significantly. There is no universally optimal window — it must be calibrated to the specific asset's autocorrelation structure.

No transaction costs or slippage. The backtest above does not account for bid-ask spread, commissions, or market impact. For liquid instruments like SPY, this is less of a concern. For small-cap equities or thinly traded names, costs can easily eliminate the theoretical edge.

Distributional assumptions. The z-score implicitly assumes returns are approximately normally distributed. In practice, equity returns exhibit fat tails and skewness. A z-score of 2.5 occurs more frequently than a Gaussian model would predict, meaning threshold crossings are less "extreme" than they appear statistically.

Lookback period survivorship. Using a fixed historical window means the rolling statistics are influenced by whichever market regime happened to fall inside that window. After a high-volatility episode, the rolling standard deviation remains elevated for WINDOW days even if actual volatility has normalized — this can suppress valid signals temporarily.

Concluding Thoughts

The rolling z-score is a genuinely useful building block in a quantitative trading toolkit. It is simple to implement, interpretable, statistically grounded, and immediately applicable to real data. More importantly, it forces you to think about price behavior in relative terms rather than absolute ones — a discipline that transfers directly to more sophisticated strategies like pairs trading, factor investing, and statistical arbitrage.

From here, the natural next experiments are to test different window lengths systematically, add a trend filter to suppress signals in one-directional markets, and apply the same logic to price spreads rather than individual securities. Each of those extensions moves you incrementally closer to a production-quality signal.

If you want to go further — full notebooks with Hidden Markov Models for regime detection, Monte Carlo simulation engines, and volatility surface modeling — those are covered in depth in the AlgoEdge Insights newsletter. The code in each issue is runnable, backtested, and explained at the same level of detail as this article.


Most algo trading content gives you theory.
This gives you the code.

3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.

5,000 quant traders already run these:

Subscribe | AlgoEdge Insights

Top comments (0)