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.
This commit is contained in:
+18
-189
@@ -1,36 +1,12 @@
|
||||
//! Regime detection and structural breaks.
|
||||
//!
|
||||
//! Functions
|
||||
//! ---------
|
||||
//! - `regime_adx` — label each bar as trend (1) or range (0)
|
||||
//! using an ADX threshold.
|
||||
//! - `regime_combined` — combine ADX + ATR-ratio rule for more robust
|
||||
//! regime labelling.
|
||||
//! - `detect_breaks_cusum` — detect structural breaks using a CUSUM-style
|
||||
//! cumulative sum approach; returns a binary mask.
|
||||
//! - `rolling_variance_break` — find indices where rolling variance changes
|
||||
//! significantly (volatility regime break).
|
||||
//! Regime detection and structural breaks (thin PyO3 wrapper over ferro_ta_core::regime).
|
||||
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// regime_adx
|
||||
// ---------------------------------------------------------------------------
|
||||
use crate::validation;
|
||||
|
||||
/// Label each bar as **trend** (1) or **range** (0) based on ADX level.
|
||||
///
|
||||
/// A bar is labelled "trend" when ``adx[i] > threshold`` (default 25).
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// adx : 1-D float64 array — ADX values (NaN during warm-up)
|
||||
/// threshold : float — ADX level above which a bar is "trending" (default 25.0)
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D int8 array — ``1`` = trend, ``0`` = range, ``-1`` = NaN (warm-up)
|
||||
#[pyfunction]
|
||||
pub fn regime_adx<'py>(
|
||||
py: Python<'py>,
|
||||
@@ -38,44 +14,11 @@ pub fn regime_adx<'py>(
|
||||
threshold: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<i8>>> {
|
||||
let a = adx.as_slice()?;
|
||||
let out: Vec<i8> = a
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
if v.is_nan() {
|
||||
-1i8
|
||||
} else if v > threshold {
|
||||
1i8
|
||||
} else {
|
||||
0i8
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
let result = ferro_ta_core::regime::regime_adx(a, threshold);
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// regime_combined
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Label each bar as trend (1) or range (0) using ADX + ATR-ratio rule.
|
||||
///
|
||||
/// A bar is "trending" when:
|
||||
/// ``adx[i] > adx_threshold`` **AND** ``atr[i] / close[i] > atr_pct_threshold``
|
||||
///
|
||||
/// The second condition (ATR as % of price) ensures that the trend has
|
||||
/// meaningful volatility (avoids labelling flat micro-trends as trending).
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// adx : 1-D float64 — ADX values
|
||||
/// atr : 1-D float64 — ATR values
|
||||
/// close : 1-D float64 — close prices (for ATR normalisation)
|
||||
/// adx_threshold : float — ADX threshold (default 25.0)
|
||||
/// atr_pct_threshold : float — minimum ATR/close ratio (default 0.005 = 0.5%)
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D int8 — ``1`` = trend, ``0`` = range, ``-1`` = NaN
|
||||
#[pyfunction]
|
||||
pub fn regime_combined<'py>(
|
||||
py: Python<'py>,
|
||||
@@ -89,52 +32,12 @@ pub fn regime_combined<'py>(
|
||||
let r = atr.as_slice()?;
|
||||
let c = close.as_slice()?;
|
||||
let n = a.len();
|
||||
if n != r.len() || n != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"adx, atr, and close must have the same length",
|
||||
));
|
||||
}
|
||||
let out: Vec<i8> = (0..n)
|
||||
.map(|i| {
|
||||
let av = a[i];
|
||||
let rv = r[i];
|
||||
let cv = c[i];
|
||||
if av.is_nan() || rv.is_nan() || cv.is_nan() || cv == 0.0 {
|
||||
-1i8
|
||||
} else if av > adx_threshold && (rv / cv) > atr_pct_threshold {
|
||||
1i8
|
||||
} else {
|
||||
0i8
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
validation::validate_equal_length(&[(n, "adx"), (r.len(), "atr"), (c.len(), "close")])?;
|
||||
let result = ferro_ta_core::regime::regime_combined(a, r, c, adx_threshold, atr_pct_threshold);
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detect_breaks_cusum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect structural breaks using a CUSUM (cumulative sum) approach.
|
||||
///
|
||||
/// CUSUM accumulates deviations from a rolling mean. When the cumulative
|
||||
/// sum exceeds ``threshold * std(series)``, a break is flagged.
|
||||
///
|
||||
/// Algorithm (simplified one-sided CUSUM on demeaned series):
|
||||
/// 1. Compute a rolling mean and std over *window* bars.
|
||||
/// 2. Accumulate the standardised deviation: ``S_i = max(0, S_{i-1} + z_i - slack)``.
|
||||
/// 3. When ``S_i > threshold``, mark a break and reset the accumulator.
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// series : 1-D float64 array — the series to monitor (e.g. close prices)
|
||||
/// window : int — lookback for mean/std estimation (>= 2)
|
||||
/// threshold : float — CUSUM threshold in units of std (default 3.0)
|
||||
/// slack : float — allowance term (default 0.5)
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D int8 array — ``1`` at break bars, ``0`` elsewhere
|
||||
/// Detect structural breaks using a CUSUM approach.
|
||||
#[pyfunction]
|
||||
pub fn detect_breaks_cusum<'py>(
|
||||
py: Python<'py>,
|
||||
@@ -143,60 +46,13 @@ pub fn detect_breaks_cusum<'py>(
|
||||
threshold: f64,
|
||||
slack: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<i8>>> {
|
||||
if window < 2 {
|
||||
return Err(PyValueError::new_err("window must be >= 2"));
|
||||
}
|
||||
validation::validate_timeperiod(window, "window", 2)?;
|
||||
let s = series.as_slice()?;
|
||||
let n = s.len();
|
||||
let mut out = vec![0i8; n];
|
||||
if n < window {
|
||||
return Ok(out.into_pyarray(py));
|
||||
}
|
||||
let mut cusum_pos = 0.0_f64;
|
||||
let mut cusum_neg = 0.0_f64;
|
||||
for i in window..n {
|
||||
// Rolling mean and std over [i-window, i)
|
||||
let slice = &s[(i - window)..i];
|
||||
let mean: f64 = slice.iter().sum::<f64>() / window as f64;
|
||||
let var: f64 =
|
||||
slice.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (window - 1) as f64;
|
||||
let std = var.sqrt();
|
||||
if std == 0.0 || std.is_nan() || s[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let z = (s[i] - mean) / std;
|
||||
cusum_pos = (cusum_pos + z - slack).max(0.0);
|
||||
cusum_neg = (cusum_neg - z - slack).max(0.0);
|
||||
if cusum_pos > threshold || cusum_neg > threshold {
|
||||
out[i] = 1;
|
||||
cusum_pos = 0.0;
|
||||
cusum_neg = 0.0;
|
||||
}
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
let result = ferro_ta_core::regime::detect_breaks_cusum(s, window, threshold, slack);
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rolling_variance_break
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect volatility regime breaks using a rolling variance change test.
|
||||
///
|
||||
/// Compares the variance in a short lookback window (*short_window*) to a
|
||||
/// longer reference window (*long_window*). When their ratio exceeds
|
||||
/// *threshold*, a volatility break is flagged.
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// series : 1-D float64 array — returns or price series
|
||||
/// short_window : int — short lookback for recent variance (>= 2)
|
||||
/// long_window : int — long lookback for baseline variance (> short_window)
|
||||
/// threshold : float — ratio short_var / long_var above which a break fires
|
||||
/// (default 2.0)
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D int8 array — ``1`` at break bars, ``0`` elsewhere
|
||||
/// Detect volatility regime breaks using rolling variance ratio.
|
||||
#[pyfunction]
|
||||
pub fn rolling_variance_break<'py>(
|
||||
py: Python<'py>,
|
||||
@@ -205,44 +61,17 @@ pub fn rolling_variance_break<'py>(
|
||||
long_window: usize,
|
||||
threshold: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<i8>>> {
|
||||
if short_window < 2 {
|
||||
return Err(PyValueError::new_err("short_window must be >= 2"));
|
||||
}
|
||||
validation::validate_timeperiod(short_window, "short_window", 2)?;
|
||||
if long_window <= short_window {
|
||||
return Err(PyValueError::new_err("long_window must be > short_window"));
|
||||
return Err(PyValueError::new_err(
|
||||
"long_window must be > short_window",
|
||||
));
|
||||
}
|
||||
let s = series.as_slice()?;
|
||||
let n = s.len();
|
||||
let mut out = vec![0i8; n];
|
||||
if n < long_window {
|
||||
return Ok(out.into_pyarray(py));
|
||||
}
|
||||
|
||||
let variance = |slice: &[f64]| -> f64 {
|
||||
let k = slice.len();
|
||||
let mean: f64 = slice.iter().sum::<f64>() / k as f64;
|
||||
slice.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (k - 1) as f64
|
||||
};
|
||||
|
||||
for i in long_window..n {
|
||||
let long_slice = &s[(i - long_window)..i];
|
||||
let short_slice = &s[(i - short_window)..i];
|
||||
let long_var = variance(long_slice);
|
||||
let short_var = variance(short_slice);
|
||||
if long_var == 0.0 || long_var.is_nan() || short_var.is_nan() {
|
||||
continue;
|
||||
}
|
||||
if short_var / long_var > threshold {
|
||||
out[i] = 1;
|
||||
}
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
let result = ferro_ta_core::regime::rolling_variance_break(s, short_window, long_window, threshold);
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(regime_adx, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(regime_combined, m)?)?;
|
||||
|
||||
Reference in New Issue
Block a user