Files
ferro-ta/python/ferro_ta/analysis/regime.py
T
Pratik Bhadane 436954138f chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
2026-03-30 12:45:52 +05:30

595 lines
18 KiB
Python

"""
ferro_ta.regime — Regime detection and structural breaks.
=========================================================
Detect market regimes (trending vs ranging) and structural breaks in price or
indicator series using existing ferro-ta indicators plus rule-based methods.
Functions
---------
regime(ohlcv, method='adx', **kwargs)
Label each bar as trending (1), ranging (0), or warm-up (-1).
Supported methods: ``'adx'``, ``'combined'``.
structural_breaks(series, method='cusum', **kwargs)
Detect structural breaks. Returns a binary mask (1 = break).
Supported methods: ``'cusum'``, ``'variance'``.
regime_adx(adx, threshold=25.0)
Low-level: label bars using an ADX array directly.
regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=0.005)
Low-level: ADX + ATR-ratio labelling.
detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5)
Low-level: CUSUM-based structural break detection.
rolling_variance_break(series, short_window=10, long_window=50, threshold=2.0)
Low-level: rolling variance ratio break detection.
Rust backend
------------
ferro_ta._ferro_ta.regime_adx
ferro_ta._ferro_ta.regime_combined
ferro_ta._ferro_ta.detect_breaks_cusum
ferro_ta._ferro_ta.rolling_variance_break
"""
from __future__ import annotations
from typing import Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import (
detect_breaks_cusum as _rust_detect_breaks_cusum,
)
from ferro_ta._ferro_ta import (
regime_adx as _rust_regime_adx,
)
from ferro_ta._ferro_ta import (
regime_combined as _rust_regime_combined,
)
from ferro_ta._ferro_ta import (
rolling_variance_break as _rust_rolling_variance_break,
)
from ferro_ta._utils import _to_f64
__all__ = [
"regime",
"structural_breaks",
"regime_adx",
"regime_combined",
"detect_breaks_cusum",
"rolling_variance_break",
]
# type alias for OHLCV tuple
OHLCVTuple = tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike]
# ---------------------------------------------------------------------------
# Low-level wrappers
# ---------------------------------------------------------------------------
def regime_adx(
adx: ArrayLike,
threshold: float = 25.0,
) -> NDArray[np.int8]:
"""Label each bar as trend (1), range (0), or warm-up (-1) using ADX.
Parameters
----------
adx : array-like — ADX values (NaN during warm-up)
threshold : float — ADX level above which a bar is "trending" (default 25)
Returns
-------
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up (NaN)
"""
return np.asarray(
_rust_regime_adx(_to_f64(adx), float(threshold)),
dtype=np.int8,
)
def regime_combined(
adx: ArrayLike,
atr: ArrayLike,
close: ArrayLike,
adx_threshold: float = 25.0,
atr_pct_threshold: float = 0.005,
) -> NDArray[np.int8]:
"""Label bars using ADX + ATR-as-%-of-close rule.
A bar is "trending" when both:
- ``adx[i] > adx_threshold``
- ``atr[i] / close[i] > atr_pct_threshold``
Parameters
----------
adx : array-like — ADX values
atr : array-like — ATR values
close : array-like — close prices
adx_threshold : float — ADX threshold (default 25.0)
atr_pct_threshold : float — minimum ATR/close ratio (default 0.005)
Returns
-------
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` NaN
"""
return np.asarray(
_rust_regime_combined(
_to_f64(adx),
_to_f64(atr),
_to_f64(close),
float(adx_threshold),
float(atr_pct_threshold),
),
dtype=np.int8,
)
def detect_breaks_cusum(
series: ArrayLike,
window: int = 20,
threshold: float = 3.0,
slack: float = 0.5,
) -> NDArray[np.int8]:
"""Detect structural breaks using CUSUM (cumulative sum) approach.
Parameters
----------
series : array-like — price or indicator series to monitor
window : int — lookback window for mean/std estimation (>= 2, default 20)
threshold : float — CUSUM threshold in units of std (default 3.0)
slack : float — allowance term (default 0.5)
Returns
-------
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
"""
return np.asarray(
_rust_detect_breaks_cusum(
_to_f64(series),
int(window),
float(threshold),
float(slack),
),
dtype=np.int8,
)
def rolling_variance_break(
series: ArrayLike,
short_window: int = 10,
long_window: int = 50,
threshold: float = 2.0,
) -> NDArray[np.int8]:
"""Detect volatility regime breaks using a rolling variance ratio test.
Parameters
----------
series : array-like — returns or price series
short_window : int — recent variance lookback (>= 2, default 10)
long_window : int — baseline variance lookback (> short_window, default 50)
threshold : float — ratio short_var/long_var above which a break fires
(default 2.0)
Returns
-------
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
"""
return np.asarray(
_rust_rolling_variance_break(
_to_f64(series),
int(short_window),
int(long_window),
float(threshold),
),
dtype=np.int8,
)
# ---------------------------------------------------------------------------
# High-level API
# ---------------------------------------------------------------------------
def regime(
ohlcv: Union[OHLCVTuple, object], # also accepts pandas.DataFrame
method: str = "adx",
adx_threshold: float = 25.0,
atr_pct_threshold: float = 0.005,
adx_timeperiod: int = 14,
atr_timeperiod: int = 14,
) -> NDArray[np.int8]:
"""Label each bar as trending (1) or ranging (0) using existing indicators.
Parameters
----------
ohlcv : tuple ``(open, high, low, close, volume)`` or pandas DataFrame
method : str
- ``'adx'`` (default) — uses ADX > *adx_threshold*
- ``'combined'`` — uses ADX + ATR/close ratio
adx_threshold : float — ADX level threshold (default 25.0)
atr_pct_threshold : float — minimum ATR/close ratio for ``'combined'``
(default 0.005 = 0.5%)
adx_timeperiod : int — ADX period (default 14)
atr_timeperiod : int — ATR period for combined method (default 14)
Returns
-------
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.regime import regime
>>> rng = np.random.default_rng(1)
>>> n = 200
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
>>> open_ = close * rng.uniform(0.998, 1.002, n)
>>> high = np.maximum(close, open_) + rng.uniform(0, 0.5, n)
>>> low = np.minimum(close, open_) - rng.uniform(0, 0.5, n)
>>> vol = rng.uniform(1000, 5000, n)
>>> labels = regime((open_, high, low, close, vol))
>>> # Count trending bars (excluding warm-up)
>>> valid = labels[labels >= 0]
>>> trend_pct = (valid == 1).sum() / len(valid)
"""
from ferro_ta import ADX, ATR # local import to avoid circular dependency
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame):
cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr]
high_arr = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index]
low_arr = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index]
close_arr = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index]
else:
_, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
except ImportError:
_, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
adx_vals = np.asarray(
ADX(high_arr, low_arr, close_arr, timeperiod=adx_timeperiod), dtype=np.float64
)
if method == "adx":
return regime_adx(adx_vals, threshold=adx_threshold)
elif method == "combined":
atr_vals = np.asarray(
ATR(high_arr, low_arr, close_arr, timeperiod=atr_timeperiod),
dtype=np.float64,
)
return regime_combined(
adx_vals,
atr_vals,
close_arr,
adx_threshold=adx_threshold,
atr_pct_threshold=atr_pct_threshold,
)
else:
raise ValueError(f"Unknown regime method '{method}'. Use 'adx' or 'combined'.")
# ---------------------------------------------------------------------------
# Phase 4: Volatility/Trend regime detection (pure NumPy)
# ---------------------------------------------------------------------------
try:
from ferro_ta._ferro_ta import sma as _rust_sma
except ImportError:
_rust_sma = None
def _rolling_sma_pure(arr: np.ndarray, window: int) -> np.ndarray:
"""Rolling SMA — delegates to the Rust SMA when available."""
if _rust_sma is not None:
return np.asarray(_rust_sma(arr, window), dtype=np.float64)
# Fallback: O(n) rolling SMA using cumsum
n = len(arr)
out = np.full(n, np.nan)
if window > n:
return out
cs = np.cumsum(arr)
out[window - 1] = cs[window - 1] / window
if window < n:
out[window:] = (cs[window:] - cs[: n - window]) / window
return out
def _rolling_std_pure(arr: np.ndarray, window: int) -> np.ndarray:
"""O(n) rolling std using cumsum-of-squares on the valid (non-NaN) portion.
Handles leading NaN values (e.g., log returns where arr[0] is NaN).
NaN is returned for warm-up bars.
"""
n = len(arr)
out = np.full(n, np.nan)
if window < 2 or window > n:
return out
# Find the first non-NaN index
first_valid = 0
while first_valid < n and np.isnan(arr[first_valid]):
first_valid += 1
if first_valid >= n:
return out # all NaN
# Work on the valid slice
valid_slice = arr[first_valid:]
m = len(valid_slice)
if window > m:
return out
cs = np.cumsum(valid_slice)
cs2 = np.cumsum(valid_slice**2)
n_windows = m - window + 1
s = np.empty(n_windows)
s2 = np.empty(n_windows)
s[0] = cs[window - 1]
s2[0] = cs2[window - 1]
if n_windows > 1:
s[1:] = cs[window:] - cs[: m - window]
s2[1:] = cs2[window:] - cs2[: m - window]
mean = s / window
var = np.maximum(s2 / window - mean**2, 0.0)
stds = np.sqrt(var)
# Place back into output (first result is at index first_valid + window - 1)
start_out = first_valid + window - 1
out[start_out : start_out + n_windows] = stds
return out
def detect_volatility_regime(
close: ArrayLike,
window: int = 20,
n_regimes: int = 3,
) -> NDArray:
"""Label bars by rolling volatility percentile bucket (0 = lowest vol regime).
Uses rolling standard deviation of log returns. NaN for warm-up bars
(returned as -1 in the integer output).
Parameters
----------
close : array-like
Close price series.
window : int
Rolling window for std computation (default 20).
n_regimes : int
Number of volatility regimes (default 3: low/mid/high = 0/1/2).
Returns
-------
NDArray[int64]
Integer array where each element is in {-1, 0, ..., n_regimes-1}.
-1 indicates NaN (warm-up) bars.
"""
c = np.asarray(close, dtype=np.float64)
n = len(c)
out = np.full(n, -1, dtype=np.int64)
log_ret = np.full(n, np.nan)
with np.errstate(divide="ignore", invalid="ignore"):
log_ret[1:] = np.log(c[1:] / c[:-1])
rolling_vol = _rolling_std_pure(log_ret, window)
valid = ~np.isnan(rolling_vol)
if not np.any(valid):
return out
vol_vals = rolling_vol[valid]
pcts = [100.0 * k / n_regimes for k in range(1, n_regimes)]
boundaries = np.percentile(vol_vals, pcts) if pcts else np.array([])
labels = np.digitize(vol_vals, boundaries).astype(np.int64)
out[valid] = labels
return out
def detect_trend_regime(
close: ArrayLike,
fast: int = 50,
slow: int = 200,
) -> NDArray:
"""Label bars: 1=bull (fast SMA > slow SMA), -1=bear, 0=sideways/NaN warmup.
Parameters
----------
close : array-like
Close price series.
fast : int
Fast SMA period (default 50).
slow : int
Slow SMA period (default 200).
Returns
-------
NDArray[int64]
Integer array with values in {-1, 0, 1}.
0 for warm-up bars where either SMA is NaN.
"""
c = np.asarray(close, dtype=np.float64)
n = len(c)
out = np.zeros(n, dtype=np.int64)
fast_sma = _rolling_sma_pure(c, fast)
slow_sma = _rolling_sma_pure(c, slow)
valid = ~np.isnan(fast_sma) & ~np.isnan(slow_sma)
out[valid & (fast_sma > slow_sma)] = 1
out[valid & (fast_sma < slow_sma)] = -1
return out
def detect_combined_regime(
close: ArrayLike,
vol_window: int = 20,
fast: int = 50,
slow: int = 200,
) -> NDArray:
"""Combine trend + vol into 6-state integer regime label.
States: 0=bull+low-vol, 1=bull+mid-vol, 2=bull+high-vol,
3=bear+low-vol, 4=bear+mid-vol, 5=bear+high-vol.
NaN bars (warm-up or sideways) → -1.
Parameters
----------
close : array-like
Close price series.
vol_window : int
Rolling window for volatility regime detection.
fast, slow : int
SMA periods for trend regime detection.
Returns
-------
NDArray[int64]
Integer array with values in {-1, 0, 1, 2, 3, 4, 5}.
"""
c = np.asarray(close, dtype=np.float64)
n = len(c)
out = np.full(n, -1, dtype=np.int64)
trend = detect_trend_regime(c, fast=fast, slow=slow)
vol = detect_volatility_regime(c, window=vol_window, n_regimes=3)
bull_valid = (trend == 1) & (vol >= 0)
bear_valid = (trend == -1) & (vol >= 0)
out[bull_valid] = vol[bull_valid] # 0, 1, or 2
out[bear_valid] = 3 + vol[bear_valid] # 3, 4, or 5
return out
class RegimeFilter:
"""Filter trading signals to only fire in allowed market regimes.
Parameters
----------
allowed_regimes : list[int]
Which regime labels to trade in. Signals in other regimes are zeroed out.
vol_window : int
Rolling window for volatility regime detection.
fast, slow : int
SMA periods for trend regime detection.
"""
def __init__(
self,
allowed_regimes: list[int],
vol_window: int = 20,
fast: int = 50,
slow: int = 200,
) -> None:
self.allowed_regimes = list(allowed_regimes)
self._allowed_regimes_arr = np.array(allowed_regimes, dtype=np.int64)
self.vol_window = int(vol_window)
self.fast = int(fast)
self.slow = int(slow)
def filter(self, signals: ArrayLike, close: ArrayLike) -> NDArray:
"""Zero out signals where regime is not in allowed_regimes.
Parameters
----------
signals : array-like
Signal array (+1, -1, 0, or NaN).
close : array-like
Close price series (same length as signals).
Returns
-------
NDArray[float64]
Filtered signal array — signals in disallowed regimes are set to 0.
"""
s = np.asarray(signals, dtype=np.float64).copy()
regimes = detect_combined_regime(
close,
vol_window=self.vol_window,
fast=self.fast,
slow=self.slow,
)
in_allowed = np.isin(regimes, self._allowed_regimes_arr)
s[~in_allowed] = 0.0
return s
# ---------------------------------------------------------------------------
# (original structural_breaks below)
# ---------------------------------------------------------------------------
def structural_breaks(
series: ArrayLike,
method: str = "cusum",
window: int = 20,
threshold: float = 3.0,
slack: float = 0.5,
short_window: int = 10,
long_window: int = 50,
variance_threshold: float = 2.0,
) -> NDArray[np.int8]:
"""Detect structural breaks in a series.
Parameters
----------
series : array-like — price or returns series to monitor
method : str
- ``'cusum'`` (default) — CUSUM-based break detection
- ``'variance'`` — rolling variance ratio break detection
window : int — CUSUM lookback window (default 20)
threshold: float — CUSUM threshold in std units (default 3.0)
slack : float — CUSUM slack term (default 0.5)
short_window : int — short variance window for ``'variance'`` (default 10)
long_window : int — long variance window for ``'variance'`` (default 50)
variance_threshold : float — variance ratio threshold (default 2.0)
Returns
-------
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.regime import structural_breaks
>>> rng = np.random.default_rng(42)
>>> # Create a series with a structural break in the middle
>>> s1 = rng.normal(0, 1, 100)
>>> s2 = rng.normal(5, 3, 100) # different mean/variance
>>> series = np.concatenate([s1, s2])
>>> breaks = structural_breaks(series, method='cusum')
>>> int(breaks[100:115].any()) # break near index 100
1
"""
if method == "cusum":
return detect_breaks_cusum(
series, window=window, threshold=threshold, slack=slack
)
elif method == "variance":
return rolling_variance_break(
series,
short_window=short_window,
long_window=long_window,
threshold=variance_threshold,
)
else:
raise ValueError(
f"Unknown structural_breaks method '{method}'. Use 'cusum' or 'variance'."
)