Source code for fynance.features.roll_functions

#!/usr/bin/env python3
# coding: utf-8
# @Author: ArthurBernard
# @Email: arthur.bernard.92@gmail.com
# @Date: 2020-09-18 21:15:59
# @Last modified by: ArthurBernard
# @Last modified time: 2020-09-18 22:21:22

""" Rolling extremum and pairwise statistics.

Rolling minimum and maximum over a lagged window. Used directly as
features (e.g. price-channel breakouts) and internally by
:func:`fynance.features.scale.roll_normalize` to compute lookahead-safe
min-max scaling parameters.

Also provides trailing *pairwise* rolling statistics between two aligned
1-D series (covariance, Pearson correlation, OLS beta) and a full-sample
lead-lag cross-correlation profile — e.g. a rolling hedge ratio
(:func:`roll_beta`) or checking whether one series leads another
(:func:`cross_corr`).

Main entry points
-----------------
- :func:`roll_min` — rolling minimum over a window.
- :func:`roll_max` — rolling maximum over a window.
- :func:`roll_cov` — trailing rolling covariance between two series.
- :func:`roll_corr` — trailing rolling Pearson correlation between two series.
- :func:`roll_beta` — trailing rolling OLS slope of ``x`` on ``y``.
- :func:`cross_corr` — full-sample lead-lag cross-correlation profile.

"""

from __future__ import annotations

# Built-in packages
# Third party packages
import numpy as np

# Local packages
from numba import njit, prange
from numpy.typing import NDArray

from fynance._wrappers import WrapperArray

__all__ = ["roll_min", "roll_max", "roll_cov", "roll_corr", "roll_beta", "cross_corr"]


@njit(cache=True)
def _roll_min_1d(X, w):
    """ Rolling minimum over a trailing window of size ``w``.

    O(n) monotonic-deque implementation (each index is pushed/popped once);
    the returned minima are identical to the naive O(n*w) computation.
    """
    T = X.shape[0]
    out = np.empty(T, dtype=np.float64)
    dq = np.empty(T, dtype=np.int64)  # indices, values increasing front->back
    head = 0
    tail = 0
    for t in range(T):
        while tail > head and X[dq[tail - 1]] >= X[t]:
            tail -= 1
        dq[tail] = t
        tail += 1
        if dq[head] < t - w + 1:
            head += 1
        out[t] = X[dq[head]]
    return out


@njit(parallel=True, cache=True)
def _roll_min_2d(X, w):
    """ Column-wise rolling minimum (O(n) per column, parallel over columns). """
    T, N = X.shape
    out = np.empty((T, N), dtype=np.float64)
    for n in prange(N):
        out[:, n] = _roll_min_1d(np.ascontiguousarray(X[:, n]), w)
    return out


@njit(cache=True)
def _roll_max_1d(X, w):
    """ Rolling maximum over a trailing window of size ``w``.

    O(n) monotonic-deque implementation; identical maxima to the naive form.
    """
    T = X.shape[0]
    out = np.empty(T, dtype=np.float64)
    dq = np.empty(T, dtype=np.int64)  # indices, values decreasing front->back
    head = 0
    tail = 0
    for t in range(T):
        while tail > head and X[dq[tail - 1]] <= X[t]:
            tail -= 1
        dq[tail] = t
        tail += 1
        if dq[head] < t - w + 1:
            head += 1
        out[t] = X[dq[head]]
    return out


@njit(parallel=True, cache=True)
def _roll_max_2d(X, w):
    """ Column-wise rolling maximum (O(n) per column, parallel over columns). """
    T, N = X.shape
    out = np.empty((T, N), dtype=np.float64)
    for n in prange(N):
        out[:, n] = _roll_max_1d(np.ascontiguousarray(X[:, n]), w)
    return out



# =========================================================================== #
#                                   Min Max                                   #
# =========================================================================== #


[docs] @WrapperArray('dtype', 'axis', 'window') def roll_min(X: NDArray, w: int | None = None, axis: int = 0, dtype=None) -> NDArray: r""" Compute simple rolling minimum of size `w` for each `X`' series. .. math:: roll\_min^w_t(X) = min(X_{t - w + 1}, ..., X_t) Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Elements to compute the rolling minimum. w : int, optional Size of the lagged window of the rolling minimum, must be positive. If ``w is None`` or ``w=0``, then ``w=X.shape[axis]``. Default is None. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. Returns ------- np.ndarray[dtype, ndim=1 or 2] Simple rolling minimum of each series. Examples -------- >>> X = np.array([60, 100, 80, 120, 160, 80]) >>> roll_min(X, w=3, dtype=np.float64, axis=0) array([60., 60., 60., 80., 80., 80.]) >>> X = np.array([[60, 60], [100, 100], [80, 80], ... [120, 120], [160, 160], [80, 80]]) >>> roll_min(X, w=3, dtype=np.float64, axis=0) array([[60., 60.], [60., 60.], [60., 60.], [80., 80.], [80., 80.], [80., 80.]]) >>> roll_min(X, w=3, dtype=np.float64, axis=1) array([[ 60., 60.], [100., 100.], [ 80., 80.], [120., 120.], [160., 160.], [ 80., 80.]]) See Also -------- roll_max """ return _roll_min(X, w)
def _roll_min(X, w): if len(X.shape) == 2: return _roll_min_2d(X, w) return _roll_min_1d(X, w)
[docs] @WrapperArray('dtype', 'axis', 'window') def roll_max(X: NDArray, w: int | None = None, axis: int = 0, dtype=None) -> NDArray: r""" Compute simple rolling maximum of size `w` for each `X`' series. .. math:: roll\_max^w_t(X) = max(X_{t - w + 1}, ..., X_t) Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Elements to compute the rolling maximum. w : int, optional Size of the lagged window of the rolling maximum, must be positive. If ``w is None`` or ``w=0``, then ``w=X.shape[axis]``. Default is None. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. Returns ------- np.ndarray[dtype, ndim=1 or 2] Simple rolling maximum of each series. Examples -------- >>> X = np.array([60, 100, 80, 120, 160, 80]) >>> roll_max(X, w=3, dtype=np.float64, axis=0) array([ 60., 100., 100., 120., 160., 160.]) >>> X = np.array([[60, 60], [100, 100], [80, 80], ... [120, 120], [160, 160], [80, 80]]) >>> roll_max(X, w=3, dtype=np.float64, axis=0) array([[ 60., 60.], [100., 100.], [100., 100.], [120., 120.], [160., 160.], [160., 160.]]) >>> roll_max(X, w=3, dtype=np.float64, axis=1) array([[ 60., 60.], [100., 100.], [ 80., 80.], [120., 120.], [160., 160.], [ 80., 80.]]) See Also -------- roll_min """ return _roll_max(X, w)
def _roll_max(X, w): if len(X.shape) == 2: return _roll_max_2d(X, w) return _roll_max_1d(X, w) # =========================================================================== # # Pairwise rolling statistics # # =========================================================================== # def _validate_pair(x: NDArray, y: NDArray) -> tuple[NDArray, NDArray]: """ Cast ``x``/``y`` to contiguous float64 1-D arrays and validate them. """ x = np.ascontiguousarray(np.asarray(x, dtype=np.float64)) y = np.ascontiguousarray(np.asarray(y, dtype=np.float64)) if x.ndim != 1 or y.ndim != 1: raise ValueError( f"x and y must be 1-D, got ndim={x.ndim} and ndim={y.ndim}" ) if x.shape[0] != y.shape[0]: raise ValueError( f"x and y must have the same length, got {x.shape[0]} and " f"{y.shape[0]}" ) if np.isnan(x).any() or np.isnan(y).any(): raise ValueError("x and y must not contain NaN") return x, y def _validate_window(w: int) -> int: """ Validate the trailing window size ``w`` (must be an int >= 2). """ if not isinstance(w, (int, np.integer)) or w < 2: raise ValueError(f"w must be an integer >= 2, got {w!r}") return int(w) @njit(cache=True) def _roll_cov_1d(x, y, w): """ Trailing rolling covariance (ddof=0) of two aligned 1-D series. """ T = x.shape[0] out = np.empty(T, dtype=np.float64) for t in range(T): if t < w - 1: out[t] = np.nan continue start = t - w + 1 sx = 0.0 sy = 0.0 for i in range(start, t + 1): sx += x[i] sy += y[i] mx = sx / w my = sy / w sxy = 0.0 for i in range(start, t + 1): sxy += (x[i] - mx) * (y[i] - my) out[t] = sxy / w return out @njit(cache=True) def _roll_corr_1d(x, y, w): """ Trailing rolling Pearson correlation (ddof cancels) of two series. """ T = x.shape[0] out = np.empty(T, dtype=np.float64) for t in range(T): if t < w - 1: out[t] = np.nan continue start = t - w + 1 sx = 0.0 sy = 0.0 for i in range(start, t + 1): sx += x[i] sy += y[i] mx = sx / w my = sy / w sxy = 0.0 sxx = 0.0 syy = 0.0 for i in range(start, t + 1): dx = x[i] - mx dy = y[i] - my sxy += dx * dy sxx += dx * dx syy += dy * dy denom = np.sqrt(sxx * syy) if denom == 0.0: out[t] = np.nan else: out[t] = sxy / denom return out @njit(cache=True) def _roll_beta_1d(x, y, w): """ Trailing rolling OLS slope of ``x`` on ``y`` (ddof cancels). """ T = x.shape[0] out = np.empty(T, dtype=np.float64) for t in range(T): if t < w - 1: out[t] = np.nan continue start = t - w + 1 sx = 0.0 sy = 0.0 for i in range(start, t + 1): sx += x[i] sy += y[i] mx = sx / w my = sy / w sxy = 0.0 syy = 0.0 for i in range(start, t + 1): dx = x[i] - mx dy = y[i] - my sxy += dx * dy syy += dy * dy if syy == 0.0: out[t] = np.nan else: out[t] = sxy / syy return out @njit(cache=True) def _cross_corr_1d(x, y, max_lag): """ Full-sample corr(x[t], y[t - lag]) for lag in [-max_lag, max_lag]. """ T = x.shape[0] n_lags = 2 * max_lag + 1 out = np.empty(n_lags, dtype=np.float64) for idx in range(n_lags): lag = idx - max_lag if lag >= 0: n = T - lag sx = 0.0 sy = 0.0 for i in range(n): sx += x[lag + i] sy += y[i] mx = sx / n my = sy / n sxy = 0.0 sxx = 0.0 syy = 0.0 for i in range(n): dx = x[lag + i] - mx dy = y[i] - my sxy += dx * dy sxx += dx * dx syy += dy * dy else: k = -lag n = T - k sx = 0.0 sy = 0.0 for i in range(n): sx += x[i] sy += y[k + i] mx = sx / n my = sy / n sxy = 0.0 sxx = 0.0 syy = 0.0 for i in range(n): dx = x[i] - mx dy = y[k + i] - my sxy += dx * dy sxx += dx * dx syy += dy * dy denom = np.sqrt(sxx * syy) if denom == 0.0: out[idx] = np.nan else: out[idx] = sxy / denom return out
[docs] def roll_cov(x: NDArray, y: NDArray, w: int = 63) -> NDArray: r""" Trailing rolling covariance between two aligned 1-D series. .. math:: roll\_cov^w_t(x, y) = \frac{1}{w} \sum_{i=t-w+1}^{t} (x_i - \bar{x}_t)(y_i - \bar{y}_t) where :math:`\bar{x}_t` and :math:`\bar{y}_t` are the means of ``x`` and ``y`` over the trailing window :math:`[t - w + 1, t]` (inclusive of ``t``, the "house" trailing-window convention shared with :func:`roll_min`/:func:`roll_max`). The variance used is the *biased* (``ddof=0``) estimator. Parameters ---------- x : np.ndarray[float64, ndim=1] First series. Cast to ``float64`` if needed; must not contain NaN. y : np.ndarray[float64, ndim=1] Second series, same length as ``x``. Cast to ``float64`` if needed; must not contain NaN. w : int, optional Size of the trailing window, must be an integer >= 2. Default is 63. Returns ------- np.ndarray[float64, ndim=1] Trailing rolling covariance. The first ``w - 1`` entries are ``np.nan`` (insufficient history). Examples -------- >>> x = np.array([1., 2., 3., 4., 5., 6.]) >>> y = 2 * x >>> roll_cov(x, y, w=2) array([nan, 0.5, 0.5, 0.5, 0.5, 0.5]) See Also -------- roll_corr, roll_beta """ x, y = _validate_pair(x, y) w = _validate_window(w) return _roll_cov_1d(x, y, w)
[docs] def roll_corr(x: NDArray, y: NDArray, w: int = 63) -> NDArray: r""" Trailing rolling Pearson correlation between two aligned 1-D series. .. math:: roll\_corr^w_t(x, y) = \frac{ \sum_{i=t-w+1}^{t} (x_i - \bar{x}_t)(y_i - \bar{y}_t) }{ \sqrt{\sum_{i=t-w+1}^{t} (x_i - \bar{x}_t)^2} \sqrt{\sum_{i=t-w+1}^{t} (y_i - \bar{y}_t)^2} } over the trailing window :math:`[t - w + 1, t]` (inclusive of ``t``, see :func:`roll_cov`). The ``1/w`` normalization of the covariance and the two variances cancels out, so the result does not depend on ``ddof``. Parameters ---------- x : np.ndarray[float64, ndim=1] First series. Cast to ``float64`` if needed; must not contain NaN. y : np.ndarray[float64, ndim=1] Second series, same length as ``x``. Cast to ``float64`` if needed; must not contain NaN. w : int, optional Size of the trailing window, must be an integer >= 2. Default is 63. Returns ------- np.ndarray[float64, ndim=1] Trailing rolling correlation, in ``[-1, 1]``. The first ``w - 1`` entries are ``np.nan`` (insufficient history). If either series has zero variance within a window, that entry is ``np.nan`` (checked explicitly before dividing — no ``RuntimeWarning`` is raised). Examples -------- >>> x = np.array([1., 2., 3., 4., 5.]) >>> y = 2 * x >>> roll_corr(x, y, w=3) array([nan, nan, 1., 1., 1.]) See Also -------- roll_cov, roll_beta """ x, y = _validate_pair(x, y) w = _validate_window(w) return _roll_corr_1d(x, y, w)
[docs] def roll_beta(x: NDArray, y: NDArray, w: int = 63) -> NDArray: r""" Trailing rolling OLS slope of ``x`` regressed on ``y``. .. math:: roll\_beta^w_t(x, y) = \frac{roll\_cov^w_t(x, y)}{roll\_var^w_t(y)} i.e. the slope of the univariate OLS regression of ``x`` on ``y`` over the trailing window :math:`[t - w + 1, t]` (inclusive of ``t``, see :func:`roll_cov`). Typical use is a rolling hedge ratio: how many units of ``y`` are needed to hedge one unit of ``x``. As with :func:`roll_corr`, the ``1/w`` normalization cancels, so the result does not depend on ``ddof``. Parameters ---------- x : np.ndarray[float64, ndim=1] Dependent series. Cast to ``float64`` if needed; must not contain NaN. y : np.ndarray[float64, ndim=1] Independent (regressor) series, same length as ``x``. Cast to ``float64`` if needed; must not contain NaN. w : int, optional Size of the trailing window, must be an integer >= 2. Default is 63. Returns ------- np.ndarray[float64, ndim=1] Trailing rolling OLS slope. The first ``w - 1`` entries are ``np.nan`` (insufficient history). If ``y`` has zero variance within a window, that entry is ``np.nan`` (checked explicitly before dividing — no ``RuntimeWarning`` is raised). Examples -------- ``y = 2 * x`` has a constant beta of 0.5 (``cov(x, y) / var(y) = 2 var(x) / 4 var(x)``), regardless of the window content: >>> x = np.array([1., 2., 3., 4., 5.]) >>> y = 2 * x >>> roll_beta(x, y, w=3) array([nan, nan, 0.5, 0.5, 0.5]) See Also -------- roll_cov, roll_corr """ x, y = _validate_pair(x, y) w = _validate_window(w) return _roll_beta_1d(x, y, w)
[docs] def cross_corr(x: NDArray, y: NDArray, max_lag: int = 20) -> NDArray: r""" Full-sample lead-lag cross-correlation profile of two series. .. math:: cross\_corr_{lag}(x, y) = corr(x_t, y_{t - lag}), \quad lag \in \{-max\_lag, \dots, max\_lag\} where the correlation is computed over the full overlapping sample (all valid ``t``, i.e. ``T - |lag|`` pairs), *not* a trailing window. **Lag convention.** Entry ``lag`` correlates ``x[t]`` with ``y[t - lag]``. A positive ``lag`` therefore pairs ``x`` at time ``t`` with ``y`` *earlier* in the series (at ``t - lag``): if that pairing is where the correlation peaks, ``y``'s past values line up with ``x``'s current values, i.e. ``y`` **leads** ``x`` by ``lag`` bars. Conversely a negative ``lag`` that maximizes the correlation means ``y`` **lags** ``x`` (``x`` leads ``y``) by ``|lag|`` bars. For example, if ``y[t] = x[t - 3]`` (``y`` is ``x`` delayed by 3 bars, so ``x`` leads ``y`` by 3), the profile peaks at ``lag = -3``, since ``y[t - (-3)] = y[t + 3] = x[t]``. Parameters ---------- x : np.ndarray[float64, ndim=1] First series. Cast to ``float64`` if needed; must not contain NaN. y : np.ndarray[float64, ndim=1] Second series, same length as ``x``. Cast to ``float64`` if needed; must not contain NaN. max_lag : int, optional Maximum absolute lag to scan, must be a non-negative integer and strictly less than ``len(x)``. Default is 20. Returns ------- np.ndarray[float64, ndim=1] Cross-correlation profile of length ``2 * max_lag + 1``, ordered from ``lag=-max_lag`` to ``lag=max_lag``. Entries where either side has zero variance over the overlap are ``np.nan`` (checked explicitly before dividing — no ``RuntimeWarning`` is raised). Examples -------- ``y`` is ``x`` delayed by 3 bars (``x`` leads ``y`` by 3): the profile peaks at ``lag = -3``. >>> rng = np.random.default_rng(0) >>> x = rng.normal(size=200) >>> y = np.roll(x, 3) >>> y[:3] = rng.normal(size=3) # avoid a spurious wrap-around match >>> profile = cross_corr(x, y, max_lag=5) >>> profile.shape (11,) >>> int(np.arange(-5, 6)[np.argmax(profile)]) -3 See Also -------- roll_corr """ x, y = _validate_pair(x, y) if not isinstance(max_lag, (int, np.integer)) or max_lag < 0: raise ValueError( f"max_lag must be a non-negative integer, got {max_lag!r}" ) if max_lag >= x.shape[0]: raise ValueError( f"max_lag={max_lag} must be strictly less than len(x)={x.shape[0]}" ) return _cross_corr_1d(x, y, int(max_lag))