Source code for spreadpy.signal.zScoreSignal
from typing import Optional
import numpy as np
import pandas as pd
from spreadpy.signal.signal import SignalGenerator, Signal, Direction
from spreadpy.spread.spreadSeries import SpreadSeries
[docs]
class ZScoreSignal(SignalGenerator):
"""
Classic z-score entry / exit signal generator.
At each bar, computes a rolling z-score of the spread residuals:
.. math::
z_t = \\frac{s_t - \\hat{\\mu}_t}{\\hat{\\sigma}_t}
where :math:`\\hat{\\mu}_t` and :math:`\\hat{\\sigma}_t` are the rolling mean and
standard deviation over the last ``window`` bars (no lookahead).
Entry / exit rules::
LONG if z_t < -entry_threshold
SHORT if z_t > +entry_threshold
FLAT if LONG and z_t > -revert_threshold (z reverted back up)
FLAT if SHORT and z_t < +revert_threshold (z reverted back down)
Setting ``revert_threshold=0`` exits at the mean crossing (:math:`z` crosses 0).
Setting it to a positive value exits before the mean is fully reached.
:param int window: Number of bars for the rolling z-score computation.
:param float entry_threshold: :math:`|z|` level above which a position is opened.
:param float revert_threshold: :math:`z` level at which mean reversion is considered
complete and the position is closed. Must be :math:`\\leq` ``entry_threshold``.
Use 0.0 to exit at the mean (default).
"""
def __init__(
self,
window: int = 60,
entry_threshold: float = 1.0,
revert_threshold: float = 0.0,
) -> None:
self.window = window
self.entry_threshold = entry_threshold
self.revert_threshold = revert_threshold
# Fitted attributes
self._mu: Optional[float] = None
self._sigma: Optional[float] = None
[docs]
def fit(self, spread: SpreadSeries) -> "ZScoreSignal":
"""Compute in-sample mean and standard deviation of the spread residuals.
These statistics are stored but not used by :meth:`generate`, which
relies on the rolling estimators instead. Calling ``fit`` is required
by the :class:`SignalGenerator` interface.
:param SpreadSeries spread: In-sample spread series.
:returns: ``self``.
:rtype: ZScoreSignal
"""
residuals = spread.residuals.dropna()
self._mu = float(residuals.mean())
self._sigma = float(residuals.std())
return self
[docs]
def generate(self, spread: SpreadSeries) -> pd.Series:
"""Compute the rolling z-score and map each bar to a :class:`Signal`.
At each bar :math:`t` the z-score is:
.. math::
z_t = \\frac{s_t - \\hat{\\mu}_{t,w}}{\\hat{\\sigma}_{t,w}}
where :math:`\\hat{\\mu}_{t,w}` and :math:`\\hat{\\sigma}_{t,w}` are the rolling
mean and standard deviation over the previous ``window`` bars (no lookahead).
Bars with fewer than ``window`` predecessors yield ``Direction.FLAT`` with
``zscore=NaN``.
:param SpreadSeries spread: Spread series to generate signals for
(may be out-of-sample).
:returns: Series of :class:`Signal` objects indexed by ``spread.index``.
:rtype: pd.Series
"""
residuals = spread.residuals
# Rolling z-score (no lookahead)
roll_mu = residuals.rolling(self.window).mean()
roll_sigma = residuals.rolling(self.window).std().replace(0, np.nan)
zscore = (residuals - roll_mu) / roll_sigma
signals = []
prev_direction = Direction.FLAT
for ts, z in zscore.items():
if np.isnan(z):
signals.append(Signal(Direction.FLAT, float("nan"), ts))
continue
# --- Exit condition (checked first) ---
if prev_direction != Direction.FLAT:
reverted = (
(prev_direction == Direction.LONG and z > -self.revert_threshold) or
(prev_direction == Direction.SHORT and z < self.revert_threshold)
)
if reverted:
sig = Signal(Direction.FLAT, z, ts, is_entry=False)
prev_direction = Direction.FLAT
signals.append(sig)
continue
# --- Entry conditions ---
if z < -self.entry_threshold:
direction = Direction.LONG
is_entry = prev_direction != Direction.LONG
elif z > self.entry_threshold:
direction = Direction.SHORT
is_entry = prev_direction != Direction.SHORT
else:
direction = prev_direction # hold existing
is_entry = False
signals.append(Signal(direction, z, ts, is_entry=is_entry))
prev_direction = direction
return pd.Series(signals, index=spread.index, name="signal")