Files
my-python-backteat/ferro-ta-main/python/ferro_ta/analysis/regime.py
T
2026-07-09 05:08:16 +08:00

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'."
)