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)
windowintNumber of log returns to use for computing the mean and standard deviation. Must be ≥ 2.
marketstr | NoneOptional 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:
NaNwhen history underwindow + 1bars0.0when std = 0 (flat prices)(latest_return - mean) / stdotherwise
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:
| Feature | Inputs | Interpretation |
|---|---|---|
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