595 lines
18 KiB
Python
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'."
|
|
)
|