Zscore

Rolling z-score of log returns, the mean reversion staple

Zscore computes the z-score of the most recent log return against the last window returns. It’s the primary input for return-based mean reversion strategies.

Import

python
from horizon.features import Zscore

Signature

python
Zscore(window: int = 20, market: str | None = None)
windowint
Number of log returns to use for computing the mean and standard deviation. Must be ≥ 2.
marketstr | None
Optional pin to a specific market id for cross-asset reference.

Math

window_rets = [r(t-window+1), ..., r(t)]
mean = sum(window_rets) / len(window_rets)
var  = sum((r - mean)² for r in window_rets) / (len(window_rets) - 1)
std  = sqrt(var)
zscore = (r(t) - mean) / std

Where r(t) = log(P(t) / P(t-1)) is the 1-bar log return.

Behavior

python
def compute(self, market_id, history, feeds):
    rets = history.last_n_returns(self.window + 1)
    if len(rets) < self.window + 1:
        return float("nan")
    window_rets = rets[-self.window:]
    mean = sum(window_rets) / len(window_rets)
    var = sum((r - mean) ** 2 for r in window_rets) / max(len(window_rets) - 1, 1)
    std = math.sqrt(var)
    if std == 0:
        return 0.0
    latest = rets[-1]
    return (latest - mean) / std

Returns:

  • NaN when history under window + 1 bars
  • 0.0 when std = 0 (flat prices)
  • (latest_return - mean) / std otherwise

Interpretation

  • z > 2: the latest return is 2+ standard deviations above the rolling mean. Usually interpreted as “overbought” or “too fast up”.
  • z under -2: the latest return is 2+ stddevs below the rolling mean. “Oversold” or “too fast down”.
  • |z| under 1: the latest return is normal noise.
  • |z| > 3: rare event, possibly a regime change or a data error.

Use cases

Classic mean reversion

python
from horizon import Strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import Zscore

class MeanReversion(Strategy):
    asset_classes = [Equity]
    features = {"z": Zscore(window=20)}

    def evaluate(self, f, universe):
        return [
            Signal.from_score(
                market=m,
                score=-f.z[m.id],   # negative z → long, positive z → short
                edge_per_stdev=15,
                horizon="3d",
            )
            for m in universe
            if abs(f.z[m.id]) > 2.0
        ]

Confirming a momentum signal with z-score context

python
features = {
    "r": Return(window=5),
    "z": Zscore(window=20),
}

def evaluate(self, f, universe):
    return [
        Signal.increase(m, edge_bps=50)
        for m in universe
        if f.r[m.id] > 0.02              # recent move is up
        and -1 < f.z[m.id] < 1            # but it's not yet an extreme z
    ]

This avoids chasing at the extremes. buy when momentum is up but z-score is still in normal range.

Why return-based, not price-based?

This feature uses log returns, not raw prices. That’s different from BollingerZ, which uses raw price levels. The two tell you different things:

FeatureInputsInterpretation
Zscore(window)Log returns“How unusual is today’s return relative to recent returns?”
BollingerZ(window)Raw prices“How far is today’s price from its rolling mean, in stddev units?”

Use Zscore for return-based mean reversion (is today’s move extreme?). Use BollingerZ for level-based mean reversion (is today’s price extreme?).

Pitfalls

Tests

python
# tests/test_features.py::TestZscore

def test_zero_on_flat(self, store_with_history: FeatureStore) -> None:
    z = Zscore(window=20)
    ns = store_with_history.compute({"z": z}, ["TEST"], {})
    assert ns.z["TEST"] == 0.0   # flat prices → std is 0 → returns 0.0

def test_positive_on_upward_spike(self) -> None:
    store = FeatureStore()
    # 20 flat bars, then one big spike
    for _ in range(20):
        noisy_price = 100.0 * math.exp(rng.gauss(0, 0.001))
        store.update_feeds({"T": FeedData(market_id="T", price=noisy_price)})
    store.update_feeds({"T": FeedData(market_id="T", price=105.0)})   # +5% spike
    z = Zscore(window=20)
    ns = store.compute({"z": z}, ["T"], {})
    assert ns.z["T"] > 3.0   # 5% move with tiny vol is many stddevs

Next