Files
ferro-ta/src/alerts/mod.rs
T

171 lines
5.3 KiB
Rust
Raw Normal View History

2026-03-23 23:34:28 +05:30
//! Alerts — condition evaluation helpers.
//!
//! These Rust functions evaluate conditions over price/indicator series and
//! return boolean or integer arrays indicating where conditions fire. They
//! are designed to be called once per batch (backtest) or per bar (live) and
//! return the full history of firings.
//!
//! Functions
//! ---------
//! - `check_threshold` — fires when a series crosses above/below a level
//! - `check_cross` — fires when *fast* crosses above or below *slow*
//! - `collect_alert_bars` — returns indices of bars where a bool mask is True
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// check_threshold
// ---------------------------------------------------------------------------
/// Fire an alert when *series* crosses a threshold level.
///
/// Parameters
/// ----------
/// series : 1-D float64 array — indicator values (e.g. RSI)
/// level : float — threshold value
/// direction : int
/// ``1`` → fire when series crosses **above** *level* (value goes from
/// ≤ level to > level).
/// ``-1`` → fire when series crosses **below** *level* (value goes from
/// ≥ level to < level).
///
/// Returns
/// -------
/// 1-D int8 array — 1 at the bar where the crossing occurs, 0 elsewhere.
/// Element 0 is always 0 (no crossing possible without a prior bar).
#[pyfunction]
pub fn check_threshold<'py>(
py: Python<'py>,
series: PyReadonlyArray1<'py, f64>,
level: f64,
direction: i32,
) -> PyResult<Bound<'py, PyArray1<i8>>> {
if direction != 1 && direction != -1 {
return Err(PyValueError::new_err(
"direction must be 1 (cross above) or -1 (cross below)",
));
}
let s = series.as_slice()?;
let n = s.len();
let mut out = vec![0i8; n];
if n < 2 {
return Ok(out.into_pyarray(py));
}
for i in 1..n {
let prev = s[i - 1];
let curr = s[i];
if prev.is_nan() || curr.is_nan() {
continue;
}
if direction == 1 {
// cross above: was at or below level, now above
if prev <= level && curr > level {
out[i] = 1;
}
} else {
// cross below: was at or above level, now below
if prev >= level && curr < level {
out[i] = 1;
}
}
}
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// check_cross
// ---------------------------------------------------------------------------
/// Detect cross-over / cross-under events between two series.
///
/// Parameters
/// ----------
/// fast : 1-D float64 array — the "fast" series (e.g. short SMA)
/// slow : 1-D float64 array — the "slow" series (e.g. long SMA)
///
/// Returns
/// -------
/// 1-D int8 array:
/// ``1`` at bars where *fast* crosses **above** *slow* (bullish cross)
/// ``-1`` at bars where *fast* crosses **below** *slow* (bearish cross)
/// ``0`` elsewhere
/// Element 0 is always 0.
#[pyfunction]
pub fn check_cross<'py>(
py: Python<'py>,
fast: PyReadonlyArray1<'py, f64>,
slow: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i8>>> {
let f = fast.as_slice()?;
let s = slow.as_slice()?;
let n = f.len();
if n != s.len() {
return Err(PyValueError::new_err(
"fast and slow must have the same length",
));
}
let mut out = vec![0i8; n];
if n < 2 {
return Ok(out.into_pyarray(py));
}
for i in 1..n {
let fp = f[i - 1];
let fc = f[i];
let sp = s[i - 1];
let sc = s[i];
if fp.is_nan() || fc.is_nan() || sp.is_nan() || sc.is_nan() {
continue;
}
// Bullish: fast was below slow, now above
if fp <= sp && fc > sc {
out[i] = 1;
}
// Bearish: fast was above slow, now below
else if fp >= sp && fc < sc {
out[i] = -1;
}
}
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// collect_alert_bars
// ---------------------------------------------------------------------------
/// Collect bar indices where *mask* is non-zero (i.e. condition fired).
///
/// Parameters
/// ----------
/// mask : 1-D int8 array (output of ``check_threshold`` or ``check_cross``)
///
/// Returns
/// -------
/// 1-D int64 array — indices of fired bars (ascending order)
#[pyfunction]
pub fn collect_alert_bars<'py>(
py: Python<'py>,
mask: PyReadonlyArray1<'py, i8>,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let m = mask.as_slice()?;
let indices: Vec<i64> = m
.iter()
.enumerate()
.filter(|(_, &v)| v != 0)
.map(|(i, _)| i as i64)
.collect();
Ok(indices.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(check_threshold, m)?)?;
m.add_function(wrap_pyfunction!(check_cross, m)?)?;
m.add_function(wrap_pyfunction!(collect_alert_bars, m)?)?;
Ok(())
}