Spread#

Hedge ratio estimators and spread series diagnostics.

SpreadSeries#

class spreadpy.spread.spreadSeries.SpreadSeries(y: PriceTimeSeries, x: PriceTimeSeries, hedge_ratio_ts: Series, estimator_name: str = '')[source]#

Residual spread series \(s_t = y_t - \beta_t \cdot x_t\).

Given aligned price series y, x and a time-varying hedge ratio β_t, the spread is computed bar-by-bar. Provides stationarity diagnostics (Ornstein-Uhlenbeck half-life, ADF test) and a rolling z-score.

Parameters:
  • y (PriceTimeSeries) – Dependent leg price series.

  • x (PriceTimeSeries) – Independent leg price series.

  • hedge_ratio_ts (pd.Series) – Time series of hedge ratios β_t, aligned (or reindexed + forward-filled) to y.index.

  • estimator_name (str) – Label for the estimator used (for display and repr).

adf_statistic() Tuple[float, float][source]#

Compute the Augmented Dickey-Fuller test statistic for the spread.

Tests the null hypothesis \(H_0\) of a unit root (non-stationarity) against the alternative \(H_1\) of stationarity (mean-reversion). The ADF regression with one lag is:

\[\Delta s_t = \rho \cdot s_{t-1} + c + \delta \cdot \Delta s_{t-1} + \varepsilon_t\]

A t-statistic on \(\rho\) that is sufficiently negative (p-value < 0.05) rejects \(H_0\) and supports cointegration of the pair.

Requires statsmodels.

Returns:

Tuple (adf_statistic, p_value).

Return type:

Tuple[float, float]

half_life() float[source]#

Estimate the mean-reversion half-life of the spread via an Ornstein-Uhlenbeck OLS regression.

The discrete-time OU model is:

\[\Delta s_t = \lambda \cdot s_{t-1} + c + \varepsilon_t, \quad \varepsilon_t \sim \mathcal{N}(0, \sigma^2)\]

where \(\lambda < 0\) indicates mean-reversion. The half-life is the time (in bars) for a deviation from equilibrium to decay by half:

\[\tau_{1/2} = -\frac{\ln 2}{\lambda}\]

\(\lambda\) is estimated by regressing \(\Delta s_t\) on \(s_{t-1}\) (with constant) using OLS. If \(\lambda \geq 0\), the spread is non-stationary and the method returns float('inf') with a warning.

Returns:

Mean-reversion half-life in bars. float('inf') if the spread does not mean-revert.

Return type:

float

rolling_adf(window: int) Series[source]#

Compute rolling ADF p-values with no lookahead.

At each bar t, runs the ADF test (one lag, constant term) on the preceding window bars of spread residuals. A p-value below 0.05 rejects the unit-root null and supports stationarity at that point.

Bars with fewer than window predecessors are NaN.

Parameters:

window (int) – Rolling window length (in bars).

Returns:

Series of ADF p-values aligned with self.index.

Return type:

pd.Series

rolling_zscore(window: int) Series[source]#

Compute a rolling z-score of the spread with no lookahead.

At each bar \(t\), the z-score standardises \(s_t\) using statistics estimated over the preceding window of \(w\) bars:

\[\begin{split}\mu_{t,w} &= \frac{1}{w} \sum_{j=0}^{w-1} s_{t-j} \\ \sigma_{t,w} &= \mathrm{std}(s_{t-w+1}, \ldots, s_t) \quad (\mathrm{ddof}=1) \\ z_t &= \frac{s_t - \mu_{t,w}}{\sigma_{t,w}}\end{split}\]

Bars \(t < w\) are set to NaN (insufficient history). This is the signal used by ZScoreSignal.

Parameters:

window (int) – Rolling window length w (in bars).

Returns:

z-score series aligned with self.index.

Return type:

pd.Series

slice(start, end) SpreadSeries[source]#

Return a new SpreadSeries restricted to the closed interval [start, end].

The underlying y, x, and β_t series are all sliced consistently, so the returned object is a self-contained SpreadSeries over the sub-period with no reference to the original data outside [start, end].

Parameters:
  • start – Inclusive start timestamp (pd.Timestamp or compatible).

  • end – Inclusive end timestamp (pd.Timestamp or compatible).

Returns:

Spread sub-series over [start, end].

Return type:

SpreadSeries

HedgeRatioEstimator#

Abstract base class for all hedge ratio estimators.

class spreadpy.spread.hedgeRatioEstimator.HedgeRatioEstimator[source]#

Abstract base class for hedge ratio estimators.

Given two price series \(y\) (dependent leg) and \(x\) (independent leg), an estimator produces a time series \(\beta_t\) such that the spread

\[s_t = y_t - \beta_t \cdot x_t\]

is (ideally) stationary. All concrete subclasses must implement fit(), which returns a pd.Series of hedge ratios aligned with y.index.

compute_spread(y: PriceTimeSeries, x: PriceTimeSeries) SpreadSeries[source]#

Fit the estimator and return the residual spread in a single call.

Equivalent to SpreadSeries(y, x, self.fit(y, x)). The spread is defined bar-by-bar as:

\[s_t = y_t - \beta_t \cdot x_t\]
Parameters:
Returns:

Residual spread series with diagnostics.

Return type:

SpreadSeries

abstractmethod fit(y: PriceTimeSeries, x: PriceTimeSeries) Series[source]#

Estimate the hedge ratio series β_t from price series y and x.

The returned series satisfies:

\[s_t = y_t - \beta_t \cdot x_t\]

where \(s_t\) is (ideally) stationary. Implementations must ensure \(\beta_t\) is free of lookahead bias: at each bar \(t\), \(\beta_t\) may only depend on observations \(\{(y_s, x_s) : s \leq t\}\).

Parameters:
Returns:

Time series of hedge ratios aligned with y.index.

Return type:

pd.Series

ConstantOLS#

class spreadpy.spread.hedgeRatio.constantOLS.ConstantOLS(add_intercept: bool = True)[source]#

Full-sample OLS hedge ratio estimator.

Fits a single linear regression over the entire supplied period:

\[y_t = \alpha + \beta \cdot x_t + \varepsilon_t\]

and returns the constant slope \(\beta\) as the hedge ratio for all bars. Suitable as a baseline when the cointegration relationship is stable.

After calling fit(), the fitted values are available as beta_, alpha_, and r_squared_.

Parameters:

add_intercept (bool) – If True (default), fits with an intercept \(\alpha\). If False, forces the regression through the origin (\(\alpha = 0\)).

fit(y: PriceTimeSeries, x: PriceTimeSeries) Series[source]#

Estimate a single hedge ratio \(\beta\) via full-sample OLS and return it as a constant series.

With intercept (default): solves the normal equations

\[[\beta,\, \alpha]^\top = (X^\top X)^{-1} X^\top y, \quad X = [x \mid \mathbf{1}]\]

Without intercept: uses the closed-form projection

\[\beta = \frac{x^\top y}{x^\top x}\]

After fitting, beta_, alpha_, and r_squared_ are set. The coefficient of determination is:

\[R^2 = 1 - \frac{SS_{\mathrm{res}}}{SS_{\mathrm{tot}}}, \quad SS_{\mathrm{res}} = \|y - \hat{\beta}\, x - \hat{\alpha}\|^2, \quad SS_{\mathrm{tot}} = \|y - \bar{y}\|^2\]
Parameters:
Returns:

Constant hedge ratio \(\beta\) broadcast over y.index.

Return type:

pd.Series

RollingOLS#

class spreadpy.spread.hedgeRatio.rollingOLS.RollingOLS(window: int = 60, add_intercept: bool = True)[source]#

Rolling OLS hedge ratio estimator.

At each bar \(t\), fits OLS on the most recent window observations:

\[y_s = \alpha + \beta_t \cdot x_s + \varepsilon_s, \quad s \in [t - w + 1,\; t]\]

This adapts to structural shifts in the cointegration relationship without the complexity of a state-space model. Bars before the first complete window are filled with the first available estimate (forward-fill).

Parameters:
  • window (int) – Number of bars in each rolling regression (must be \(\geq 2\)).

  • add_intercept (bool) – If True (default), includes an intercept term \(\alpha\).

fit(y: PriceTimeSeries, x: PriceTimeSeries) Series[source]#

Estimate a time-varying hedge ratio \(\beta_t\) via rolling OLS.

At each bar \(t \geq w - 1\), the hedge ratio is the OLS slope over the most recent \(w\) observations:

\[\beta_t = \operatorname{argmin}_{\beta,\, \alpha} \sum_{s=t-w+1}^{t} (y_s - \alpha - \beta\, x_s)^2\]

whose solution is:

\[\beta_t = \frac{\mathrm{Cov}_w[x,\, y]}{\mathrm{Var}_w[x]}\]

where \(\mathrm{Cov}_w\) and \(\mathrm{Var}_w\) denote the sample covariance and variance over the rolling window. For the first \(w - 1\) bars (warm-up), the first available estimate is forward-filled.

Parameters:
Returns:

Time series of hedge ratios \(\beta_t\) aligned with y.index.

Return type:

pd.Series

KalmanFilter#

class spreadpy.spread.hedgeRatio.kalmanFilter.KalmanFilterParams(sigma2_eps: float, sigma2_mu: float, sigma2_gam: float, mu0: float, gam0: float, P0: ndarray)[source]#

Hyperparameters for the KalmanFilter state-space model.

Derived from an OLS bootstrap on the first ls_window bars, following the heuristic of Palomar (2024), §15.6.3.

Parameters:
  • sigma2_eps (float) – Observation noise variance \(\sigma^2_\varepsilon\).

  • sigma2_mu (float) – State noise variance for the intercept, \(\sigma^2_\mu = \alpha \cdot \sigma^2_\varepsilon\).

  • sigma2_gam (float) – State noise variance for the hedge ratio, \(\sigma^2_\gamma = \alpha \cdot \sigma^2_\varepsilon / \mathrm{Var}[x]\).

  • mu0 (float) – Initial intercept estimate \(\mu_1\) (from OLS).

  • gam0 (float) – Initial hedge ratio estimate \(\gamma_1\) (from OLS).

  • P0 (np.ndarray) – Initial state covariance matrix (\(2 \times 2\), diagonal).

class spreadpy.spread.hedgeRatio.kalmanFilter.KalmanFilter(alpha: float = 1e-05, ls_window: int | None = None, add_intercept: bool = True)[source]#

Kalman filter hedge ratio estimator with two latent states \([\mu_t,\, \gamma_t]\).

Implements the pairs-trading state-space model of Palomar (2024), §15.6.3, eq. (15.3).

Observation equation:

\[y_t = \mu_t + \gamma_t x_t + \varepsilon_t, \quad \varepsilon_t \sim \mathcal{N}(0, \sigma^2_\varepsilon)\]

Transition equations (independent random walks):

\[\begin{split}\mu_{t+1} &= \mu_t + \eta_{\mu,t}, \quad \eta_{\mu,t} \sim \mathcal{N}(0, \sigma^2_\mu) \\ \gamma_{t+1} &= \gamma_t + \eta_{\gamma,t}, \quad \eta_{\gamma,t} \sim \mathcal{N}(0, \sigma^2_\gamma)\end{split}\]

The hedge ratio returned by fit() is the one-step-ahead predictive \(\gamma_{t|t-1}\), free of lookahead bias. The normalised spread is:

\[z_t = \frac{y_t - \gamma_{t|t-1}\, x_t - \mu_{t|t-1}}{1 + \gamma_{t|t-1}}\]

Hyperparameters are initialised via an OLS bootstrap on the first ls_window bars (Palomar, §15.6.3):

\[\sigma^2_\varepsilon = \mathrm{Var}[\varepsilon^{\mathrm{OLS}}], \quad \sigma^2_\mu = \alpha \cdot \sigma^2_\varepsilon, \quad \sigma^2_\gamma = \frac{\alpha \cdot \sigma^2_\varepsilon}{\mathrm{Var}[x]}\]
Parameters:
  • alpha (float) – Controls state noise relative to observation noise. Smaller values → slower adaptation of \(\mu\) and \(\gamma\). Typical range: 1e-6 to 1e-4. Palomar uses 1e-5 for this model.

  • ls_window (Optional[int]) – Number of bars for the OLS initialisation. None uses the entire series (recommended: pass train_size from the engine).

  • add_intercept (bool) – If False, the intercept state \(\mu_t\) is pinned to zero for the entire filter run (mu0=0, P0[0,0]=0, sigma2_mu=0). The Kalman gain on \(\mu\) is then identically zero at every step, so the state vector effectively reduces to a 1-state model without any structural change to _run_filter. Useful when the relationship between y and x is expected to be purely proportional (e.g. realised-variance series where a constant offset is not meaningful). Default: True.

Note

Use log-prices. The filter assumes constant observation noise \(\sigma^2_\varepsilon\). With raw prices, \(\sigma^2_\varepsilon \propto \text{price}^2\), making the Kalman gain \(K_t\) systematically mis-calibrated when prices drift. Passing log(y), log(x) makes \(\sigma^2_\varepsilon\) approximately homoscedastic and gives more stable hedge ratio estimates. In BacktestEngine, set log_prices=True to apply this automatically while keeping actual prices for P&L accounting.

fit(y: PriceTimeSeries, x: PriceTimeSeries) Series[source]#

Run the Kalman filter on (y, x) and return the one-step-ahead predictive hedge ratio \(\gamma_{t|t-1}\).

Using the predictive state rather than the filtered state \(\gamma_{t|t}\) ensures no lookahead bias: at bar \(t\), \(\gamma_{t|t-1}\) is computed from observations \(\{(y_s, x_s) : s \leq t-1\}\) only.

After calling this method the following attributes are set:

  • params_ — fitted KalmanFilterParams

  • mu_ts_ — filtered intercept series \(\mu_{t|t}\)

  • normalized_spread_ — lookahead-free normalised spread

    \[z_t = \frac{y_t - \gamma_{t|t-1}\, x_t - \mu_{t|t-1}}{1 + \gamma_{t|t-1}}\]
Parameters:
Returns:

Predictive hedge ratio series \(\gamma_{t|t-1}\) aligned with y.index.

Return type:

pd.Series

KalmanFilterWithVelocity#

class spreadpy.spread.hedgeRatio.kalmanFilterWithVelocity.KalmanFilterWithVelocityParams(sigma2_eps: float, sigma2_mu: float, sigma2_gam: float, sigma2_dgam: float, mu0: float, gam0: float, P0: ndarray)[source]#

Hyperparameters for the KalmanFilterWithVelocity state-space model.

Derived from an OLS bootstrap following Palomar (2024), §15.6.3, eq. (15.4).

Parameters:
  • sigma2_eps (float) – Observation noise variance \(\sigma^2_\varepsilon\).

  • sigma2_mu (float) – State noise variance for the intercept \(\mu_t\).

  • sigma2_gam (float) – State noise variance for the hedge ratio \(\gamma_t\).

  • sigma2_dgam (float) – State noise variance for the velocity \(\dot{\gamma}_t\).

  • mu0 (float) – Initial intercept estimate \(\mu_1\) (from OLS).

  • gam0 (float) – Initial hedge ratio estimate \(\gamma_1\) (from OLS).

  • P0 (np.ndarray) – Initial state covariance matrix (\(3 \times 3\), diagonal).

class spreadpy.spread.hedgeRatio.kalmanFilterWithVelocity.KalmanFilterWithVelocity(alpha: float = 1e-06, alpha_dgam: float | None = None, ls_window: int | None = None, add_intercept: bool = True)[source]#

Kalman filter hedge ratio estimator with augmented state \([\mu_t,\, \gamma_t,\, \dot{\gamma}_t]\).

Extends KalmanFilter with a velocity state \(\dot{\gamma}_t\) that allows the hedge ratio to follow a locally linear trend. Implements Palomar (2024), §15.6.3, eq. (15.4).

Observation equation:

\[y_t = \mu_t + \gamma_t x_t + \varepsilon_t, \quad \varepsilon_t \sim \mathcal{N}(0, \sigma^2_\varepsilon)\]

Transition equations:

\[\begin{split}\mu_{t+1} &= \mu_t + \eta_{\mu,t} \\ \gamma_{t+1} &= \gamma_t + \dot{\gamma}_t + \eta_{\gamma,t} \\ \dot{\gamma}_{t+1} &= \dot{\gamma}_t + \eta_{\dot{\gamma},t}\end{split}\]

\(\gamma\) follows a locally linear trend driven by its velocity \(\dot{\gamma}\). The velocity is a slow random walk (\(\sigma^2_{\dot{\gamma}} \ll \sigma^2_\gamma\)).

The predictive hedge ratio \(\gamma_{t|t-1}\) is returned by fit(). The normalised spread (no lookahead) is:

\[z_t = \frac{y_t - \gamma_{t|t-1}\, x_t - \mu_{t|t-1}}{1 + \gamma_{t|t-1}}\]

Hyperparameters are initialised via OLS bootstrap (same heuristic as KalmanFilter), with an additional term for \(\sigma^2_{\dot{\gamma}}\):

\[\sigma^2_{\dot{\gamma}} = \alpha_{\dot{\gamma}} \cdot \sigma^2_\varepsilon / \mathrm{Var}[x]\]
Parameters:
  • alpha (float) – State noise scale for \(\mu\) and \(\gamma\). Palomar recommends 1e-6 for this model.

  • alpha_dgam (Optional[float]) – State noise scale for \(\dot{\gamma}\). Should satisfy alpha_dgam < alpha so that velocity varies slowly. Defaults to alpha / 10.

  • ls_window (Optional[int]) – Bars used for OLS initialisation. None uses the full series.

  • add_intercept (bool) – If False, the intercept state \(\mu_t\) is pinned to zero for the entire filter run (mu0=0, P0[0,0]=0, sigma2_mu=0). The Kalman gain on \(\mu\) is then identically zero at every step, so the state vector effectively reduces to a 2-state \([\gamma_t, \dot{\gamma}_t]\) model without any structural change to _run_filter. Useful when the relationship between y and x is expected to be purely proportional (e.g. realised-variance series). Default: True.

Note

Use log-prices. The filter assumes constant observation noise \(\sigma^2_\varepsilon\). With raw prices, \(\sigma^2_\varepsilon \propto \text{price}^2\), making the Kalman gain \(K_t\) systematically mis-calibrated when prices drift. Passing log(y), log(x) makes \(\sigma^2_\varepsilon\) approximately homoscedastic and gives more stable hedge ratio estimates. In BacktestEngine, set log_prices=True to apply this automatically while keeping actual prices for P&L accounting.

fit(y: PriceTimeSeries, x: PriceTimeSeries) Series[source]#

Run the Kalman filter on (y, x) and return the one-step-ahead predictive hedge ratio \(\gamma_{t|t-1}\).

Using the predictive state rather than the filtered state \(\gamma_{t|t}\) ensures no lookahead bias: at bar \(t\), \(\gamma_{t|t-1}\) is computed from observations \(\{(y_s, x_s) : s \leq t-1\}\) only.

After calling this method the following attributes are set:

  • params_ — fitted KalmanFilterWithVelocityParams

  • mu_ts_ — filtered intercept series \(\mu_{t|t}\)

  • velocity_ts_ — filtered velocity series \(\dot{\gamma}_{t|t}\)

  • normalized_spread_ — lookahead-free normalised spread

    \[z_t = \frac{y_t - \gamma_{t|t-1}\, x_t - \mu_{t|t-1}}{1 + \gamma_{t|t-1}}\]
Parameters:
Returns:

Predictive hedge ratio series \(\gamma_{t|t-1}\) aligned with y.index.

Return type:

pd.Series