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:
Pratik Bhadane
2026-03-30 12:45:52 +05:30
parent 2d776b6f90
commit 436954138f
174 changed files with 29297 additions and 10773 deletions
+20 -192
View File
@@ -1,18 +1,9 @@
//! Tick / Trade Aggregation Pipeline — Rust implementations.
//!
//! Aggregates raw tick/trade data into OHLCV bars:
//! - **time bars** — fixed duration buckets (label-based via Python timestamps)
//! - **volume bars** — fixed volume threshold per bar
//! - **tick bars** — fixed number of ticks per bar
//!
//! The Python layer (ferro_ta.aggregation) provides the timestamp bucketing
//! for time bars; this module handles the compute-intensive OHLCV accumulation.
//! Tick/trade aggregation (thin PyO3 wrapper over ferro_ta_core::aggregation).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
/// Return type for functions that return five OHLCV 1-D arrays.
type Ohlcv5<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
@@ -21,7 +12,6 @@ type Ohlcv5<'py> = (
Bound<'py, PyArray1<f64>>,
);
/// Return type for time bars: five OHLCV arrays plus labels.
type Ohlcv5AndLabels<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
@@ -31,21 +21,7 @@ type Ohlcv5AndLabels<'py> = (
Bound<'py, PyArray1<i64>>,
);
// ---------------------------------------------------------------------------
// aggregate_tick_bars
// ---------------------------------------------------------------------------
/// Aggregate tick/trade data into tick bars (every N ticks become one bar).
///
/// Parameters
/// ----------
/// price, size : 1-D float64 arrays (equal length, one entry per trade/tick)
/// ticks_per_bar : int — number of ticks per bar (must be >= 1)
///
/// Returns
/// -------
/// Tuple of five 1-D arrays: (open, high, low, close, volume)
/// where volume = sum of sizes in each bar.
#[pyfunction]
#[pyo3(signature = (price, size, ticks_per_bar))]
pub fn aggregate_tick_bars<'py>(
@@ -65,58 +41,17 @@ pub fn aggregate_tick_bars<'py>(
"price and size must be non-empty and equal length",
));
}
let n_bars = n.div_ceil(ticks_per_bar);
let mut out_open = Vec::with_capacity(n_bars);
let mut out_high = Vec::with_capacity(n_bars);
let mut out_low = Vec::with_capacity(n_bars);
let mut out_close = Vec::with_capacity(n_bars);
let mut out_vol = Vec::with_capacity(n_bars);
let mut i = 0;
while i < n {
let end = (i + ticks_per_bar).min(n);
let bar_p = &p[i..end];
let bar_s = &s[i..end];
let bar_open = bar_p[0];
let bar_high = bar_p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let bar_low = bar_p.iter().cloned().fold(f64::INFINITY, f64::min);
let bar_close = *bar_p.last().unwrap();
let bar_vol: f64 = bar_s.iter().sum();
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
i = end;
}
let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_tick_bars(p, s, ticks_per_bar);
Ok((
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// aggregate_volume_bars_ticks
// ---------------------------------------------------------------------------
/// Aggregate tick data into volume bars (fixed volume threshold).
///
/// Accumulates ticks until cumulative size >= `volume_threshold`, then emits
/// a bar.
///
/// Parameters
/// ----------
/// price, size : 1-D float64 arrays (equal length)
/// volume_threshold : float — cumulative size threshold per bar (must be > 0)
///
/// Returns
/// -------
/// Tuple of five 1-D arrays: (open, high, low, close, volume)
#[pyfunction]
#[pyo3(signature = (price, size, volume_threshold))]
pub fn aggregate_volume_bars_ticks<'py>(
@@ -136,78 +71,17 @@ pub fn aggregate_volume_bars_ticks<'py>(
"price and size must be non-empty and equal length",
));
}
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut bar_open = p[0];
let mut bar_high = p[0];
let mut bar_low = p[0];
let mut bar_close = p[0];
let mut bar_vol = s[0];
for i in 1..n {
bar_high = bar_high.max(p[i]);
bar_low = bar_low.min(p[i]);
bar_close = p[i];
bar_vol += s[i];
if bar_vol >= volume_threshold {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
if i + 1 < n {
bar_open = p[i + 1];
bar_high = p[i + 1];
bar_low = p[i + 1];
bar_close = p[i + 1];
bar_vol = s[i + 1];
} else {
bar_vol = 0.0;
}
}
}
// Push remaining partial bar
if bar_vol > 0.0 {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
}
let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_volume_bars_ticks(p, s, volume_threshold);
Ok((
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// aggregate_time_bars
// ---------------------------------------------------------------------------
/// Aggregate tick data into time bars using pre-computed integer bucket labels.
///
/// Each tick is assigned a `label` (e.g. unix_ts // period_secs). Ticks with
/// the same label are accumulated into one bar. Labels must be non-decreasing.
///
/// Parameters
/// ----------
/// price, size : 1-D float64 arrays
/// labels : 1-D int64 array — bucket label per tick (non-decreasing)
///
/// Returns
/// -------
/// Tuple of five 1-D arrays: (open, high, low, close, volume)
/// and a 1-D int64 array of unique labels (one per bar).
#[pyfunction]
#[pyo3(signature = (price, size, labels))]
pub fn aggregate_time_bars<'py>(
@@ -225,63 +99,17 @@ pub fn aggregate_time_bars<'py>(
"price, size, and labels must be non-empty and equal length",
));
}
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut out_labels: Vec<i64> = Vec::new();
let mut cur_label = lbl[0];
let mut bar_open = p[0];
let mut bar_high = p[0];
let mut bar_low = p[0];
let mut bar_close = p[0];
let mut bar_vol = s[0];
for i in 1..n {
if lbl[i] != cur_label {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
out_labels.push(cur_label);
cur_label = lbl[i];
bar_open = p[i];
bar_high = p[i];
bar_low = p[i];
bar_close = p[i];
bar_vol = s[i];
} else {
bar_high = bar_high.max(p[i]);
bar_low = bar_low.min(p[i]);
bar_close = p[i];
bar_vol += s[i];
}
}
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
out_labels.push(cur_label);
let (ro, rh, rl, rc, rv, rlbl) = ferro_ta_core::aggregation::aggregate_time_bars(p, s, lbl);
Ok((
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
out_labels.into_pyarray(py),
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
rlbl.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(aggregate_tick_bars, m)?)?;
m.add_function(wrap_pyfunction!(aggregate_volume_bars_ticks, m)?)?;
+13 -110
View File
@@ -1,40 +1,20 @@
//! 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
//! Alerts — condition evaluation helpers (thin PyO3 wrapper over ferro_ta_core::alerts).
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)
/// series : 1-D float64 array
/// 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).
/// direction : int — ``1`` (cross above) or ``-1`` (cross below)
///
/// 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).
/// 1-D int8 array — 1 at crossing bars, 0 elsewhere.
#[pyfunction]
pub fn check_threshold<'py>(
py: Python<'py>,
@@ -48,50 +28,15 @@ pub fn check_threshold<'py>(
));
}
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))
let result = ferro_ta_core::alerts::check_threshold(s, level, direction);
Ok(result.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.
/// 1-D int8 array: ``1`` = bullish, ``-1`` = bearish, ``0`` = none.
#[pyfunction]
pub fn check_cross<'py>(
py: Python<'py>,
@@ -100,68 +45,26 @@ pub fn check_cross<'py>(
) -> PyResult<Bound<'py, PyArray1<i8>>> {
let f = fast.as_slice()?;
let s = slow.as_slice()?;
let n = f.len();
if n != s.len() {
if f.len() != 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))
let result = ferro_ta_core::alerts::check_cross(f, s);
Ok(result.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)
/// Collect bar indices where *mask* is non-zero.
#[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))
let result = ferro_ta_core::alerts::collect_alert_bars(m);
Ok(result.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)?)?;
+10 -183
View File
@@ -1,41 +1,12 @@
//! Performance attribution and trade analysis.
//!
//! Functions
//! ---------
//! - `trade_stats` — compute win rate, avg win/loss, hold time,
//! profit factor from a list of trade PnLs and hold durations.
//! - `monthly_contribution` — group bar returns by month index and sum, for
//! time-based performance attribution.
//! - `signal_attribution` — given signal labels per bar and bar returns,
//! compute the PnL contribution of each signal.
//! Performance attribution (thin PyO3 wrapper over ferro_ta_core::attribution).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use std::collections::HashMap;
// ---------------------------------------------------------------------------
// trade_stats
// ---------------------------------------------------------------------------
use crate::validation;
/// Compute trade-level statistics from trade PnL and hold durations.
///
/// Parameters
/// ----------
/// pnl : 1-D float64 array — per-trade profit/loss (positive = win)
/// hold_bars : 1-D float64 array — hold duration in bars for each trade
/// (same length as *pnl*)
///
/// Returns
/// -------
/// tuple of 5 floats:
/// ``(win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars)``
///
/// - **win_rate** : fraction of trades with PnL > 0
/// - **avg_win** : mean PnL of winning trades (or 0 if none)
/// - **avg_loss** : mean PnL of losing trades (negative; or 0 if none)
/// - **profit_factor** : gross profit / |gross loss| (inf if no losses)
/// - **avg_hold_bars** : mean hold duration across all trades
#[pyfunction]
pub fn trade_stats(
pnl: PyReadonlyArray1<'_, f64>,
@@ -47,68 +18,11 @@ pub fn trade_stats(
if n == 0 {
return Err(PyValueError::new_err("pnl must be non-empty"));
}
if n != h.len() {
return Err(PyValueError::new_err(
"pnl and hold_bars must have the same length",
));
}
let mut wins: Vec<f64> = Vec::new();
let mut losses: Vec<f64> = Vec::new();
for &v in p.iter() {
if v > 0.0 {
wins.push(v);
} else if v < 0.0 {
losses.push(v);
}
}
let win_rate = wins.len() as f64 / n as f64;
let avg_win = if wins.is_empty() {
0.0
} else {
wins.iter().sum::<f64>() / wins.len() as f64
};
let avg_loss = if losses.is_empty() {
0.0
} else {
losses.iter().sum::<f64>() / losses.len() as f64
};
let gross_profit: f64 = wins.iter().sum();
let gross_loss: f64 = losses.iter().map(|v| v.abs()).sum();
let profit_factor = if gross_loss == 0.0 {
f64::INFINITY
} else {
gross_profit / gross_loss
};
let avg_hold = h.iter().sum::<f64>() / n as f64;
Ok((win_rate, avg_win, avg_loss, profit_factor, avg_hold))
validation::validate_equal_length(&[(n, "pnl"), (h.len(), "hold_bars")])?;
Ok(ferro_ta_core::attribution::trade_stats(p, h))
}
// ---------------------------------------------------------------------------
// monthly_contribution
// ---------------------------------------------------------------------------
/// Group per-bar returns by month index and sum each month's contribution.
///
/// The ``month_index`` array assigns each bar to a month bucket (0-based
/// integer, e.g. 0 = January year 1, 1 = February year 1, …). The function
/// returns the **unique sorted month indices** and the corresponding
/// **total return** for each month.
///
/// Parameters
/// ----------
/// bar_returns : 1-D float64 array — per-bar strategy returns
/// month_index : 1-D int64 array — month bucket for each bar (same length)
///
/// Returns
/// -------
/// tuple ``(months, contributions)``:
/// - ``months`` : 1-D int64 array — sorted unique month indices
/// - ``contributions`` : 1-D float64 array — summed return per month
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn monthly_contribution<'py>(
@@ -119,48 +33,12 @@ pub fn monthly_contribution<'py>(
let ret = bar_returns.as_slice()?;
let mi = month_index.as_slice()?;
let n = ret.len();
if n != mi.len() {
return Err(PyValueError::new_err(
"bar_returns and month_index must have the same length",
));
}
// Accumulate contributions by month
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !ret[i].is_nan() {
*map.entry(mi[i]).or_insert(0.0) += ret[i];
}
}
// Sort by month index
let mut months: Vec<i64> = map.keys().copied().collect();
months.sort_unstable();
let contributions: Vec<f64> = months.iter().map(|m| map[m]).collect();
validation::validate_equal_length(&[(n, "bar_returns"), (mi.len(), "month_index")])?;
let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(ret, mi);
Ok((months.into_pyarray(py), contributions.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// signal_attribution
// ---------------------------------------------------------------------------
/// Attribute per-bar returns to each signal label.
///
/// Each bar has a *signal_label* (integer) indicating which signal or rule
/// triggered the trade. ``-1`` means "no signal / flat". The function sums
/// bar returns per signal label.
///
/// Parameters
/// ----------
/// bar_returns : 1-D float64 array — per-bar strategy returns
/// signal_labels : 1-D int64 array — signal label per bar (same length)
///
/// Returns
/// -------
/// tuple ``(labels, contributions)``:
/// - ``labels`` : 1-D int64 array — sorted unique signal labels
/// - ``contributions`` : 1-D float64 array — summed return per label
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn signal_attribution<'py>(
@@ -171,33 +49,12 @@ pub fn signal_attribution<'py>(
let ret = bar_returns.as_slice()?;
let lbl = signal_labels.as_slice()?;
let n = ret.len();
if n != lbl.len() {
return Err(PyValueError::new_err(
"bar_returns and signal_labels must have the same length",
));
}
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !ret[i].is_nan() {
*map.entry(lbl[i]).or_insert(0.0) += ret[i];
}
}
let mut labels: Vec<i64> = map.keys().copied().collect();
labels.sort_unstable();
let contributions: Vec<f64> = labels.iter().map(|l| map[l]).collect();
validation::validate_equal_length(&[(n, "bar_returns"), (lbl.len(), "signal_labels")])?;
let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(ret, lbl);
Ok((labels.into_pyarray(py), contributions.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// extract_trades
// ---------------------------------------------------------------------------
/// Extract trade-level pnl and hold durations from positions and strategy returns.
///
/// A trade is a maximal contiguous run of non-zero position values.
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn extract_trades<'py>(
@@ -208,41 +65,11 @@ pub fn extract_trades<'py>(
let pos = positions.as_slice()?;
let ret = strategy_returns.as_slice()?;
let n = pos.len();
if n != ret.len() {
return Err(PyValueError::new_err(
"positions and strategy_returns must have the same length",
));
}
let mut pnl = Vec::<f64>::new();
let mut hold = Vec::<f64>::new();
let mut i = 0usize;
while i < n {
if pos[i] == 0.0 {
i += 1;
continue;
}
let mut j = i + 1;
while j < n && pos[j] == pos[i] {
j += 1;
}
let mut trade_pnl = 0.0_f64;
for v in ret.iter().take(j).skip(i) {
trade_pnl += *v;
}
pnl.push(trade_pnl);
hold.push((j - i) as f64);
i = j;
}
validation::validate_equal_length(&[(n, "positions"), (ret.len(), "strategy_returns")])?;
let (pnl, hold) = ferro_ta_core::attribution::extract_trades(pos, ret);
Ok((pnl.into_pyarray(py), hold.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(trade_stats, m)?)?;
m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?;
+294
View File
@@ -0,0 +1,294 @@
//! PyO3 wrapper around `ferro_ta_core::commission::CommissionModel`.
//!
//! Exposes all fields as Python properties, provides static preset constructors,
//! and supports JSON persistence (save/load).
use ferro_ta_core::commission::CommissionModel as CoreModel;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use std::fs;
/// Advanced commission and tax model for Indian and global markets.
///
/// All `_rate` fields are fractions (e.g. 0.001 = 0.1%).
/// Per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g. INR).
///
/// ## Example
/// ```python
/// from ferro_ta._ferro_ta import CommissionModel
///
/// # Use a built-in preset
/// m = CommissionModel.equity_delivery_india()
/// cost = m.total_cost(100_000.0, 1.0, True)
/// print(f"Buy cost: ₹{cost:.2f}")
///
/// # Save and reload
/// m.save("/tmp/my_commission.json")
/// m2 = CommissionModel.load("/tmp/my_commission.json")
/// ```
#[pyclass(module = "ferro_ta._ferro_ta", name = "CommissionModel")]
#[derive(Clone, Default)]
pub struct PyCommissionModel {
pub(crate) inner: CoreModel,
}
#[pymethods]
impl PyCommissionModel {
/// Create a zero-commission model (all fields = 0, lot_size = 1).
#[new]
pub fn new() -> Self {
Self::default()
}
// ---- Brokerage fields -----------------------------------------------
#[getter]
pub fn flat_per_order(&self) -> f64 {
self.inner.flat_per_order
}
#[setter]
pub fn set_flat_per_order(&mut self, v: f64) {
self.inner.flat_per_order = v;
}
#[getter]
pub fn rate_of_value(&self) -> f64 {
self.inner.rate_of_value
}
#[setter]
pub fn set_rate_of_value(&mut self, v: f64) {
self.inner.rate_of_value = v;
}
#[getter]
pub fn per_lot(&self) -> f64 {
self.inner.per_lot
}
#[setter]
pub fn set_per_lot(&mut self, v: f64) {
self.inner.per_lot = v;
}
#[getter]
pub fn max_brokerage(&self) -> f64 {
self.inner.max_brokerage
}
#[setter]
pub fn set_max_brokerage(&mut self, v: f64) {
self.inner.max_brokerage = v;
}
#[getter]
pub fn spread_bps(&self) -> f64 {
self.inner.spread_bps
}
#[setter]
pub fn set_spread_bps(&mut self, v: f64) {
self.inner.spread_bps = v;
}
// ---- STT fields -----------------------------------------------------
#[getter]
pub fn stt_rate(&self) -> f64 {
self.inner.stt_rate
}
#[setter]
pub fn set_stt_rate(&mut self, v: f64) {
self.inner.stt_rate = v;
}
#[getter]
pub fn stt_on_buy(&self) -> bool {
self.inner.stt_on_buy
}
#[setter]
pub fn set_stt_on_buy(&mut self, v: bool) {
self.inner.stt_on_buy = v;
}
#[getter]
pub fn stt_on_sell(&self) -> bool {
self.inner.stt_on_sell
}
#[setter]
pub fn set_stt_on_sell(&mut self, v: bool) {
self.inner.stt_on_sell = v;
}
// ---- Exchange / regulatory fields -----------------------------------
#[getter]
pub fn exchange_charges_rate(&self) -> f64 {
self.inner.exchange_charges_rate
}
#[setter]
pub fn set_exchange_charges_rate(&mut self, v: f64) {
self.inner.exchange_charges_rate = v;
}
#[getter]
pub fn regulatory_charges_rate(&self) -> f64 {
self.inner.regulatory_charges_rate
}
#[setter]
pub fn set_regulatory_charges_rate(&mut self, v: f64) {
self.inner.regulatory_charges_rate = v;
}
#[getter]
pub fn gst_rate(&self) -> f64 {
self.inner.gst_rate
}
#[setter]
pub fn set_gst_rate(&mut self, v: f64) {
self.inner.gst_rate = v;
}
#[getter]
pub fn stamp_duty_rate(&self) -> f64 {
self.inner.stamp_duty_rate
}
#[setter]
pub fn set_stamp_duty_rate(&mut self, v: f64) {
self.inner.stamp_duty_rate = v;
}
#[getter]
pub fn lot_size(&self) -> f64 {
self.inner.lot_size
}
#[setter]
pub fn set_lot_size(&mut self, v: f64) {
self.inner.lot_size = v;
}
#[getter]
pub fn short_borrow_rate_annual(&self) -> f64 {
self.inner.short_borrow_rate_annual
}
#[setter]
pub fn set_short_borrow_rate_annual(&mut self, v: f64) {
self.inner.short_borrow_rate_annual = v;
}
// ---- Compute --------------------------------------------------------
/// Total transaction cost in absolute currency units.
///
/// Args:
/// trade_value: price × quantity in base currency
/// num_lots: number of lots transacted
/// is_buy: True for buy (entry) leg, False for sell (exit) leg
pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 {
self.inner.total_cost(trade_value, num_lots, is_buy)
}
/// Cost as fraction of `initial_capital` (for normalised equity loops).
///
/// Returns 0.0 if `initial_capital` ≤ 0.
pub fn cost_fraction(
&self,
trade_value: f64,
num_lots: f64,
is_buy: bool,
initial_capital: f64,
) -> f64 {
self.inner
.cost_fraction(trade_value, num_lots, is_buy, initial_capital)
}
// ---- Presets (static constructors) ----------------------------------
/// Zero-commission model (all fields = 0).
#[staticmethod]
pub fn zero() -> Self {
Self {
inner: CoreModel::zero(),
}
}
/// Indian equity delivery preset (0.1% brokerage capped ₹20, STT both sides, full levies).
#[staticmethod]
pub fn equity_delivery_india() -> Self {
Self {
inner: CoreModel::equity_delivery_india(),
}
}
/// Indian equity intraday preset (0.03% brokerage capped ₹20, STT sell only, full levies).
#[staticmethod]
pub fn equity_intraday_india() -> Self {
Self {
inner: CoreModel::equity_intraday_india(),
}
}
/// Indian index futures preset (₹20 flat, STT sell only, lot_size=25).
#[staticmethod]
pub fn futures_india() -> Self {
Self {
inner: CoreModel::futures_india(),
}
}
/// Indian index options preset (₹20 flat, STT on premium sell side, lot_size=25).
#[staticmethod]
pub fn options_india() -> Self {
Self {
inner: CoreModel::options_india(),
}
}
/// Simple proportional model — `rate` fraction applied both ways, no taxes.
#[staticmethod]
pub fn proportional(rate: f64) -> Self {
Self {
inner: CoreModel::proportional(rate),
}
}
// ---- JSON persistence -----------------------------------------------
/// Serialize this model to a JSON string.
pub fn to_json(&self) -> PyResult<String> {
self.inner
.to_json()
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Deserialize a `CommissionModel` from a JSON string.
#[staticmethod]
pub fn from_json(s: &str) -> PyResult<Self> {
CoreModel::from_json(s)
.map(|inner| Self { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Save this model to a JSON file at `path`.
pub fn save(&self, path: &str) -> PyResult<()> {
let json = self.to_json()?;
fs::write(path, json).map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Load a `CommissionModel` from a JSON file at `path`.
#[staticmethod]
pub fn load(path: &str) -> PyResult<Self> {
let s = fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
Self::from_json(&s)
}
fn __repr__(&self) -> String {
format!(
"CommissionModel(flat={}, rate_pct={:.4}%, stt={:.4}%, lot_size={})",
self.inner.flat_per_order,
self.inner.rate_of_value * 100.0,
self.inner.stt_rate * 100.0,
self.inner.lot_size,
)
}
fn __eq__(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
+133
View File
@@ -0,0 +1,133 @@
//! PyO3 wrapper around `ferro_ta_core::currency::Currency`.
use ferro_ta_core::currency::Currency as CoreCurrency;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
/// Immutable currency descriptor with formatting support.
///
/// ## Example
/// ```python
/// from ferro_ta._ferro_ta import Currency
///
/// inr = Currency.INR()
/// print(inr.format(123456.78)) # ₹1,23,456.78
///
/// usd = Currency.from_code("USD")
/// print(usd.format(1234567.89)) # $1,234,567.89
/// ```
#[pyclass(name = "Currency", module = "ferro_ta._ferro_ta", frozen)]
#[derive(Clone)]
pub struct PyCurrency {
pub(crate) inner: &'static CoreCurrency,
}
#[pymethods]
impl PyCurrency {
/// Format *amount* according to this currency's style.
pub fn format(&self, amount: f64) -> String {
self.inner.format(amount)
}
#[getter]
pub fn code(&self) -> &str {
self.inner.code
}
#[getter]
pub fn symbol(&self) -> &str {
self.inner.symbol
}
#[getter]
pub fn decimal_places(&self) -> u8 {
self.inner.decimal_places
}
#[getter]
pub fn lakh_grouping(&self) -> bool {
self.inner.lakh_grouping
}
// ---- Static constructors (presets) ----
#[staticmethod]
pub fn from_code(code: &str) -> PyResult<Self> {
CoreCurrency::from_code(code)
.map(|c| PyCurrency { inner: c })
.ok_or_else(|| {
PyValueError::new_err(format!(
"Unknown currency code '{code}'. Supported: INR, USD, EUR, GBP, JPY, USDT"
))
})
}
/// Indian Rupee.
#[staticmethod]
#[allow(non_snake_case)]
pub fn INR() -> Self {
PyCurrency {
inner: &CoreCurrency::INR,
}
}
/// US Dollar.
#[staticmethod]
#[allow(non_snake_case)]
pub fn USD() -> Self {
PyCurrency {
inner: &CoreCurrency::USD,
}
}
/// Euro.
#[staticmethod]
#[allow(non_snake_case)]
pub fn EUR() -> Self {
PyCurrency {
inner: &CoreCurrency::EUR,
}
}
/// British Pound.
#[staticmethod]
#[allow(non_snake_case)]
pub fn GBP() -> Self {
PyCurrency {
inner: &CoreCurrency::GBP,
}
}
/// Japanese Yen.
#[staticmethod]
#[allow(non_snake_case)]
pub fn JPY() -> Self {
PyCurrency {
inner: &CoreCurrency::JPY,
}
}
/// Tether USD.
#[staticmethod]
#[allow(non_snake_case)]
pub fn USDT() -> Self {
PyCurrency {
inner: &CoreCurrency::USDT,
}
}
fn __repr__(&self) -> String {
format!("Currency({:?})", self.inner.code)
}
fn __eq__(&self, other: &Self) -> bool {
self.inner.code == other.inner.code
}
fn __hash__(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.inner.code.hash(&mut hasher);
hasher.finish()
}
}
+714 -149
View File
@@ -1,34 +1,126 @@
//! Rust-backed strategy signal generation and backtest core.
//!
//! These functions move the hot loops from Python into Rust while preserving
//! the public Python behavior.
//! Thin PyO3 wrappers delegating to `ferro_ta_core::backtest`.
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
pub mod commission;
pub mod currency;
use commission::PyCommissionModel;
use currency::PyCurrency;
use ferro_ta_core::backtest as core_bt;
use ndarray::Array2;
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use rayon::prelude::*;
fn nan_to_num_with_numpy_defaults(v: f64) -> f64 {
if v.is_nan() {
0.0
} else if v.is_infinite() {
if v.is_sign_positive() {
f64::MAX
} else {
-f64::MAX
use crate::validation;
// ---------------------------------------------------------------------------
// BacktestConfig pyclass wrapping core struct
// ---------------------------------------------------------------------------
#[pyclass(name = "BacktestConfig")]
#[derive(Clone)]
pub struct BacktestConfig {
#[pyo3(get, set)]
pub fill_mode: String,
#[pyo3(get, set)]
pub stop_loss_pct: f64,
#[pyo3(get, set)]
pub take_profit_pct: f64,
#[pyo3(get, set)]
pub trailing_stop_pct: f64,
#[pyo3(get, set)]
pub slippage_bps: f64,
#[pyo3(get, set)]
pub initial_capital: f64,
#[pyo3(get, set)]
pub commission_per_trade: f64,
#[pyo3(get, set)]
pub max_hold_bars: usize,
#[pyo3(get, set)]
pub slippage_pct_range: f64,
#[pyo3(get, set)]
pub breakeven_pct: f64,
#[pyo3(get, set)]
pub periods_per_year: f64,
#[pyo3(get, set)]
pub margin_ratio: f64,
#[pyo3(get, set)]
pub margin_call_pct: f64,
#[pyo3(get, set)]
pub daily_loss_limit: f64,
#[pyo3(get, set)]
pub total_loss_limit: f64,
#[pyo3(get, set)]
pub commission: Option<PyCommissionModel>,
}
#[pymethods]
impl BacktestConfig {
#[new]
#[pyo3(signature = (
fill_mode = "market_open",
stop_loss_pct = 0.0,
take_profit_pct = 0.0,
trailing_stop_pct = 0.0,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
max_hold_bars = 0,
slippage_pct_range = 0.0,
breakeven_pct = 0.0,
periods_per_year = 252.0,
margin_ratio = 0.0,
margin_call_pct = 0.5,
daily_loss_limit = 0.0,
total_loss_limit = 0.0,
commission = None,
))]
#[allow(clippy::too_many_arguments)]
pub fn new(
fill_mode: &str,
stop_loss_pct: f64,
take_profit_pct: f64,
trailing_stop_pct: f64,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
max_hold_bars: usize,
slippage_pct_range: f64,
breakeven_pct: f64,
periods_per_year: f64,
margin_ratio: f64,
margin_call_pct: f64,
daily_loss_limit: f64,
total_loss_limit: f64,
commission: Option<PyCommissionModel>,
) -> Self {
BacktestConfig {
fill_mode: fill_mode.to_string(),
stop_loss_pct,
take_profit_pct,
trailing_stop_pct,
slippage_bps,
initial_capital,
commission_per_trade,
max_hold_bars,
slippage_pct_range,
breakeven_pct,
periods_per_year,
margin_ratio,
margin_call_pct,
daily_loss_limit,
total_loss_limit,
commission,
}
} else {
v
}
}
// ---------------------------------------------------------------------------
// Strategy signal helpers
// Signal generators
// ---------------------------------------------------------------------------
/// RSI threshold strategy:
/// +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise.
/// Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))]
pub fn rsi_threshold_signals<'py>(
@@ -40,26 +132,10 @@ pub fn rsi_threshold_signals<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?;
let rsi = ferro_ta_core::momentum::rsi(prices, timeperiod);
let out: Vec<f64> = rsi
.iter()
.map(|&v| {
if v.is_nan() {
f64::NAN
} else if v <= oversold {
1.0
} else if v >= overbought {
-1.0
} else {
0.0
}
})
.collect();
let out = core_bt::rsi_threshold_signals(prices, timeperiod, oversold, overbought);
Ok(out.into_pyarray(py))
}
/// SMA crossover strategy:
/// +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, fast = 10, slow = 30))]
pub fn sma_crossover_signals<'py>(
@@ -70,32 +146,12 @@ pub fn sma_crossover_signals<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(fast, "fast", 1)?;
validation::validate_timeperiod(slow, "slow", 1)?;
if fast >= slow {
return Err(PyValueError::new_err(format!(
"fast ({fast}) must be less than slow ({slow})"
)));
}
let prices = close.as_slice()?;
let sma_fast = ferro_ta_core::overlap::sma(prices, fast);
let sma_slow = ferro_ta_core::overlap::sma(prices, slow);
let out: Vec<f64> = sma_fast
.iter()
.zip(sma_slow.iter())
.map(|(&f, &s)| {
if f.is_nan() || s.is_nan() {
f64::NAN
} else if f > s {
1.0
} else {
-1.0
}
})
.collect();
let out = core_bt::sma_crossover_signals(prices, fast, slow)
.map_err(|e| PyValueError::new_err(e))?;
Ok(out.into_pyarray(py))
}
/// MACD crossover strategy:
/// +1 when MACD line > signal line, -1 otherwise. Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))]
pub fn macd_crossover_signals<'py>(
@@ -108,137 +164,646 @@ pub fn macd_crossover_signals<'py>(
validation::validate_timeperiod(fastperiod, "fastperiod", 1)?;
validation::validate_timeperiod(slowperiod, "slowperiod", 1)?;
validation::validate_timeperiod(signalperiod, "signalperiod", 1)?;
if fastperiod >= slowperiod {
return Err(PyValueError::new_err(format!(
"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
)));
}
let prices = close.as_slice()?;
let (macd_line, signal_line, _) =
ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod);
let out: Vec<f64> = macd_line
.iter()
.zip(signal_line.iter())
.map(|(&m, &s)| {
if m.is_nan() || s.is_nan() {
f64::NAN
} else if m > s {
1.0
} else {
-1.0
}
})
.collect();
let out = core_bt::macd_crossover_signals(prices, fastperiod, slowperiod, signalperiod)
.map_err(|e| PyValueError::new_err(e))?;
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Backtest core
// Backtest core (close-only)
// ---------------------------------------------------------------------------
/// Backtest core loop over close prices and strategy signals.
///
/// Returns `(positions, bar_returns, strategy_returns, equity)`.
#[pyfunction]
#[pyo3(signature = (close, signals, commission_per_trade = 0.0, slippage_bps = 0.0))]
#[pyo3(signature = (
close, signals,
commission = None,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
))]
#[allow(clippy::type_complexity)]
pub fn backtest_core<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
signals: PyReadonlyArray1<'py, f64>,
commission_per_trade: f64,
commission: Option<PyRef<'py, PyCommissionModel>>,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
) -> PyResult<(
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let c = close.as_slice()?;
let s = signals.as_slice()?;
validation::validate_equal_length(&[(c.len(), "close"), (s.len(), "signals")])?;
let cm = commission.as_ref().map(|c| &c.inner);
let result = core_bt::backtest_core(c, s, cm, slippage_bps, initial_capital, commission_per_trade)
.map_err(|e| PyValueError::new_err(e))?;
Ok((
result.positions.into_pyarray(py),
result.bar_returns.into_pyarray(py),
result.strategy_returns.into_pyarray(py),
result.equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// OHLCV backtest
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (
open, high, low, close, signals,
fill_mode = "market_open",
stop_loss_pct = 0.0,
take_profit_pct = 0.0,
trailing_stop_pct = 0.0,
commission = None,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
limit_prices = None,
max_hold_bars = 0,
slippage_pct_range = 0.0,
breakeven_pct = 0.0,
periods_per_year = 252.0,
margin_ratio = 0.0,
margin_call_pct = 0.5,
daily_loss_limit = 0.0,
total_loss_limit = 0.0,
))]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn backtest_ohlcv_core<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
signals: PyReadonlyArray1<'py, f64>,
fill_mode: &str,
stop_loss_pct: f64,
take_profit_pct: f64,
trailing_stop_pct: f64,
commission: Option<PyRef<'py, PyCommissionModel>>,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
limit_prices: Option<PyReadonlyArray1<'py, f64>>,
max_hold_bars: usize,
slippage_pct_range: f64,
breakeven_pct: f64,
periods_per_year: f64,
margin_ratio: f64,
margin_call_pct: f64,
daily_loss_limit: f64,
total_loss_limit: f64,
) -> PyResult<(
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let s = signals.as_slice()?;
let n = c.len();
validation::validate_equal_length(&[(n, "close"), (s.len(), "signals")])?;
let mut positions = vec![0.0_f64; n];
if n > 1 {
for i in 1..n {
positions[i] = nan_to_num_with_numpy_defaults(s[i - 1]);
}
validation::validate_equal_length(&[
(n, "close"),
(o.len(), "open"),
(h.len(), "high"),
(l.len(), "low"),
(s.len(), "signals"),
])?;
let config = core_bt::BacktestConfig {
fill_mode: fill_mode.to_string(),
stop_loss_pct,
take_profit_pct,
trailing_stop_pct,
slippage_bps,
initial_capital,
commission_per_trade,
max_hold_bars,
slippage_pct_range,
breakeven_pct,
periods_per_year,
margin_ratio,
margin_call_pct,
daily_loss_limit,
total_loss_limit,
commission: commission.as_ref().map(|c| c.inner.clone()),
};
let lp_opt: Option<&[f64]> = limit_prices.as_ref().and_then(|lp| lp.as_slice().ok());
let result = core_bt::backtest_ohlcv_core(o, h, l, c, s, &config, lp_opt)
.map_err(|e| PyValueError::new_err(e))?;
Ok((
result.positions.into_pyarray(py),
result.fill_prices.into_pyarray(py),
result.bar_returns.into_pyarray(py),
result.strategy_returns.into_pyarray(py),
result.equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Performance metrics
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (strategy_returns, equity, periods_per_year = 252.0, risk_free_rate = 0.0, benchmark_returns = None))]
pub fn compute_performance_metrics<'py>(
py: Python<'py>,
strategy_returns: PyReadonlyArray1<'py, f64>,
equity: PyReadonlyArray1<'py, f64>,
periods_per_year: f64,
risk_free_rate: f64,
benchmark_returns: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyDict>> {
let r = strategy_returns.as_slice()?;
let eq = equity.as_slice()?;
let br = benchmark_returns.as_ref().and_then(|b| b.as_slice().ok());
let metrics = core_bt::compute_performance_metrics(r, eq, periods_per_year, risk_free_rate, br)
.map_err(|e| PyValueError::new_err(e))?;
let dict = PyDict::new(py);
dict.set_item("total_return", metrics.total_return)?;
dict.set_item("cagr", metrics.cagr)?;
dict.set_item("annualized_vol", metrics.annualized_vol)?;
dict.set_item("sharpe", metrics.sharpe)?;
dict.set_item("sortino", metrics.sortino)?;
dict.set_item("calmar", metrics.calmar)?;
dict.set_item("max_drawdown", metrics.max_drawdown)?;
dict.set_item("avg_drawdown", metrics.avg_drawdown)?;
dict.set_item("max_drawdown_duration_bars", metrics.max_drawdown_duration_bars as i64)?;
dict.set_item("avg_drawdown_duration_bars", metrics.avg_drawdown_duration_bars)?;
dict.set_item("ulcer_index", metrics.ulcer_index)?;
dict.set_item("omega_ratio", metrics.omega_ratio)?;
dict.set_item("win_rate", metrics.win_rate)?;
dict.set_item("profit_factor", metrics.profit_factor)?;
dict.set_item("r_expectancy", metrics.r_expectancy)?;
dict.set_item("avg_win", metrics.avg_win)?;
dict.set_item("avg_loss", metrics.avg_loss)?;
dict.set_item("tail_ratio", metrics.tail_ratio)?;
dict.set_item("skewness", metrics.skewness)?;
dict.set_item("kurtosis", metrics.kurtosis)?;
dict.set_item("best_bar", metrics.best_bar)?;
dict.set_item("worst_bar", metrics.worst_bar)?;
dict.set_item("n_trades", metrics.n_trades as i64)?;
dict.set_item("n_position_changes", metrics.n_position_changes as i64)?;
if let Some(v) = metrics.benchmark_total_return {
dict.set_item("benchmark_total_return", v)?;
}
if let Some(v) = metrics.benchmark_cagr {
dict.set_item("benchmark_cagr", v)?;
}
if let Some(v) = metrics.benchmark_annualized_vol {
dict.set_item("benchmark_annualized_vol", v)?;
}
if let Some(v) = metrics.benchmark_sharpe {
dict.set_item("benchmark_sharpe", v)?;
}
if let Some(v) = metrics.alpha {
dict.set_item("alpha", v)?;
}
if let Some(v) = metrics.beta {
dict.set_item("beta", v)?;
}
if let Some(v) = metrics.tracking_error {
dict.set_item("tracking_error", v)?;
}
if let Some(v) = metrics.information_ratio {
dict.set_item("information_ratio", v)?;
}
let mut bar_returns = vec![0.0_f64; n];
for i in 1..n {
bar_returns[i] = (c[i] - c[i - 1]) / c[i - 1];
}
Ok(dict)
}
let mut strategy_returns = vec![0.0_f64; n];
for i in 0..n {
strategy_returns[i] = positions[i] * bar_returns[i];
}
// ---------------------------------------------------------------------------
// Trade extraction
// ---------------------------------------------------------------------------
let mut position_changed = vec![false; n];
for i in 1..n {
position_changed[i] = positions[i] != positions[i - 1];
}
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn extract_trades_ohlcv<'py>(
py: Python<'py>,
positions: PyReadonlyArray1<'py, f64>,
fill_prices: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<(
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let pos = positions.as_slice()?;
let fp = fill_prices.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
if slippage_bps > 0.0 {
let slip = slippage_bps / 10_000.0;
for i in 0..n {
if position_changed[i] {
strategy_returns[i] -= slip;
}
}
}
validation::validate_equal_length(&[
(pos.len(), "positions"),
(fp.len(), "fill_prices"),
(h.len(), "high"),
(l.len(), "low"),
])?;
let mut equity = vec![1.0_f64; n];
if n > 0 {
if commission_per_trade <= 0.0 {
let mut gross = 1.0_f64;
for i in 0..n {
gross *= 1.0 + strategy_returns[i];
equity[i] = gross;
}
} else {
let mut gross_equity = vec![1.0_f64; n];
let mut gross = 1.0_f64;
for i in 0..n {
gross *= 1.0 + strategy_returns[i];
gross_equity[i] = gross;
}
let trades = core_bt::extract_trades_ohlcv(pos, fp, h, l)
.map_err(|e| PyValueError::new_err(e))?;
if gross_equity.contains(&0.0) {
equity[0] = 1.0;
for i in 1..n {
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]);
if position_changed[i] {
equity[i] -= commission_per_trade;
}
}
} else {
let mut discounted_commissions = 0.0_f64;
for i in 0..n {
if position_changed[i] {
discounted_commissions += commission_per_trade / gross_equity[i];
}
equity[i] = gross_equity[i] * (1.0 - discounted_commissions);
}
}
}
let mut entry_bars: Vec<i64> = Vec::with_capacity(trades.len());
let mut exit_bars: Vec<i64> = Vec::with_capacity(trades.len());
let mut directions: Vec<f64> = Vec::with_capacity(trades.len());
let mut entry_prices: Vec<f64> = Vec::with_capacity(trades.len());
let mut exit_prices: Vec<f64> = Vec::with_capacity(trades.len());
let mut pnl_pcts: Vec<f64> = Vec::with_capacity(trades.len());
let mut duration_bars_vec: Vec<i64> = Vec::with_capacity(trades.len());
let mut maes: Vec<f64> = Vec::with_capacity(trades.len());
let mut mfes: Vec<f64> = Vec::with_capacity(trades.len());
for t in &trades {
entry_bars.push(t.entry_bar);
exit_bars.push(t.exit_bar);
directions.push(t.direction);
entry_prices.push(t.entry_price);
exit_prices.push(t.exit_price);
pnl_pcts.push(t.pnl_pct);
duration_bars_vec.push(t.duration_bars);
maes.push(t.mae);
mfes.push(t.mfe);
}
Ok((
positions.into_pyarray(py),
bar_returns.into_pyarray(py),
strategy_returns.into_pyarray(py),
equity.into_pyarray(py),
entry_bars.into_pyarray(py),
exit_bars.into_pyarray(py),
directions.into_pyarray(py),
entry_prices.into_pyarray(py),
exit_prices.into_pyarray(py),
pnl_pcts.into_pyarray(py),
duration_bars_vec.into_pyarray(py),
maes.into_pyarray(py),
mfes.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Multi-asset backtest
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (
close_2d, weights_2d,
commission_per_trade = 0.0,
slippage_bps = 0.0,
parallel = true,
max_asset_weight = 1.0,
max_gross_exposure = 0.0,
max_net_exposure = 0.0,
))]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn backtest_multi_asset_core<'py>(
py: Python<'py>,
close_2d: PyReadonlyArray2<'py, f64>,
weights_2d: PyReadonlyArray2<'py, f64>,
commission_per_trade: f64,
slippage_bps: f64,
parallel: bool,
max_asset_weight: f64,
max_gross_exposure: f64,
max_net_exposure: f64,
) -> PyResult<(
Bound<'py, PyArray2<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let c_arr = close_2d.as_array();
let w_arr = weights_2d.as_array();
let (n_bars, n_assets) = c_arr.dim();
if w_arr.dim() != (n_bars, n_assets) {
return Err(PyValueError::new_err(format!(
"weights_2d shape {:?} must match close_2d shape {:?}",
w_arr.dim(),
c_arr.dim()
)));
}
// Transpose to (n_assets, n_bars) for the core function
let mut close_cm: Vec<Vec<f64>> = vec![vec![0.0; n_bars]; n_assets];
let mut weights_cm: Vec<Vec<f64>> = vec![vec![0.0; n_bars]; n_assets];
for j in 0..n_assets {
for i in 0..n_bars {
close_cm[j][i] = c_arr[[i, j]];
weights_cm[j][i] = w_arr[[i, j]];
}
}
// For parallel execution, use rayon directly on the core's single_asset_backtest.
// Apply portfolio constraints first via the core function's logic.
// Apply constraints
if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 {
for i in 0..n_bars {
if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 {
for j in 0..n_assets {
let w = weights_cm[j][i];
if w.abs() > max_asset_weight {
weights_cm[j][i] = w.signum() * max_asset_weight;
}
}
}
if max_gross_exposure > 0.0 {
let gross: f64 = (0..n_assets).map(|j| weights_cm[j][i].abs()).sum();
if gross > max_gross_exposure {
let scale = max_gross_exposure / gross;
for j in 0..n_assets {
weights_cm[j][i] *= scale;
}
}
}
if max_net_exposure > 0.0 {
let net: f64 = (0..n_assets).map(|j| weights_cm[j][i]).sum();
if net.abs() > max_net_exposure {
let excess = net - net.signum() * max_net_exposure;
let adj_per_asset = excess / n_assets as f64;
for j in 0..n_assets {
weights_cm[j][i] -= adj_per_asset;
}
}
}
}
}
// Run per-asset backtests (parallel or serial)
let asset_strategy_returns: Vec<Vec<f64>> = py.allow_threads(|| {
let run_asset = |j: usize| -> Vec<f64> {
let (_, strat_rets, _) = core_bt::single_asset_backtest(
&close_cm[j],
&weights_cm[j],
commission_per_trade,
slippage_bps,
);
strat_rets
};
if parallel {
(0..n_assets).into_par_iter().map(run_asset).collect()
} else {
(0..n_assets).map(run_asset).collect()
}
});
// Assemble asset_returns 2D array (n_bars, n_assets)
let mut asset_ret_arr = Array2::<f64>::zeros((n_bars, n_assets));
for j in 0..n_assets {
for i in 0..n_bars {
asset_ret_arr[[i, j]] = asset_strategy_returns[j][i];
}
}
// Portfolio returns
let mut portfolio_returns = vec![0.0_f64; n_bars];
for i in 0..n_bars {
let mut s = 0.0_f64;
for j in 0..n_assets {
s += asset_ret_arr[[i, j]];
}
portfolio_returns[i] = s;
}
// Portfolio equity
let mut portfolio_equity = vec![1.0_f64; n_bars];
let mut cum = 1.0_f64;
for i in 0..n_bars {
cum *= 1.0 + portfolio_returns[i];
portfolio_equity[i] = cum;
}
Ok((
asset_ret_arr.into_pyarray(py),
portfolio_returns.into_pyarray(py),
portfolio_equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Monte Carlo bootstrap
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (strategy_returns, n_sims = 1000, seed = 42, block_size = 1))]
pub fn monte_carlo_bootstrap<'py>(
py: Python<'py>,
strategy_returns: PyReadonlyArray1<'py, f64>,
n_sims: usize,
seed: u64,
block_size: usize,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let r = strategy_returns.as_slice()?;
let n = r.len();
// Use rayon for parallel Monte Carlo (preserving the original parallel behavior)
if n < 2 {
return Err(PyValueError::new_err(
"strategy_returns must have at least 2 elements",
));
}
if n_sims == 0 {
return Err(PyValueError::new_err("n_sims must be >= 1"));
}
let bsize = block_size.max(1).min(n);
let mut result = Array2::<f64>::zeros((n_sims, n));
py.allow_threads(|| {
result
.as_slice_mut()
.unwrap()
.par_chunks_mut(n)
.enumerate()
.for_each(|(sim_idx, row)| {
let mut state = seed
.wrapping_mul(6_364_136_223_846_793_005_u64)
.wrapping_add((sim_idx as u64).wrapping_mul(2_862_933_555_777_941_757_u64));
core_bt::lcg_next(&mut state);
core_bt::lcg_next(&mut state);
if bsize == 1 {
for dst in row.iter_mut() {
*dst = r[core_bt::lcg_index(&mut state, n)];
}
} else {
let mut filled = 0_usize;
while filled < n {
let start = core_bt::lcg_index(&mut state, n);
let take = bsize.min(n - filled);
for k in 0..take {
row[filled + k] = r[(start + k) % n];
}
filled += take;
}
}
let mut cum = 1.0_f64;
for elem in row.iter_mut().take(n) {
cum *= 1.0 + *elem;
*elem = cum;
}
});
});
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Walk-forward indices
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (n_bars, train_bars, test_bars, anchored = false, step_bars = 0))]
pub fn walk_forward_indices<'py>(
py: Python<'py>,
n_bars: usize,
train_bars: usize,
test_bars: usize,
anchored: bool,
step_bars: usize,
) -> PyResult<Bound<'py, PyArray2<i64>>> {
let folds = core_bt::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars)
.map_err(|e| PyValueError::new_err(e))?;
let n_folds = folds.len();
let mut arr = Array2::<i64>::zeros((n_folds, 4));
for (i, fold) in folds.iter().enumerate() {
for j in 0..4 {
arr[[i, j]] = fold[j];
}
}
Ok(arr.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Kelly criterion
// ---------------------------------------------------------------------------
#[pyfunction]
pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult<f64> {
core_bt::kelly_fraction(win_rate, avg_win, avg_loss).map_err(|e| PyValueError::new_err(e))
}
#[pyfunction]
pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult<f64> {
core_bt::half_kelly_fraction(win_rate, avg_win, avg_loss).map_err(|e| PyValueError::new_err(e))
}
// ---------------------------------------------------------------------------
// StreamingBacktest
// ---------------------------------------------------------------------------
#[pyclass(name = "StreamingBacktest")]
pub struct StreamingBacktest {
inner: core_bt::StreamingBacktest,
}
#[pymethods]
impl StreamingBacktest {
#[new]
#[pyo3(signature = (commission_per_trade=0.0, slippage_bps=0.0))]
pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self {
StreamingBacktest {
inner: core_bt::StreamingBacktest::new(commission_per_trade, slippage_bps),
}
}
pub fn on_bar<'py>(
&mut self,
py: Python<'py>,
close: f64,
signal: f64,
) -> PyResult<Bound<'py, PyDict>> {
let result = self.inner.on_bar(close, signal);
let d = PyDict::new(py);
d.set_item("position", result.position)?;
d.set_item("bar_return", result.bar_return)?;
d.set_item("equity", result.equity)?;
d.set_item("n_trades", result.n_trades)?;
Ok(d)
}
#[getter]
pub fn equity(&self) -> f64 {
self.inner.equity
}
#[getter]
pub fn position(&self) -> f64 {
self.inner.position
}
#[getter]
pub fn n_trades(&self) -> usize {
self.inner.n_trades
}
pub fn summary<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let s = self.inner.summary();
let d = PyDict::new(py);
d.set_item("equity", s.equity)?;
d.set_item("n_trades", s.n_trades)?;
d.set_item("total_commission", s.total_commission)?;
d.set_item("win_rate", s.win_rate)?;
d.set_item("avg_win", s.avg_win)?;
d.set_item("avg_loss", s.avg_loss)?;
d.set_item("kelly_fraction", s.kelly_fraction)?;
Ok(d)
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(rsi_threshold_signals, m)?)?;
m.add_function(wrap_pyfunction!(sma_crossover_signals, m)?)?;
m.add_function(wrap_pyfunction!(macd_crossover_signals, m)?)?;
m.add_function(wrap_pyfunction!(backtest_core, m)?)?;
m.add_function(wrap_pyfunction!(backtest_ohlcv_core, m)?)?;
m.add_function(wrap_pyfunction!(compute_performance_metrics, m)?)?;
m.add_function(wrap_pyfunction!(extract_trades_ohlcv, m)?)?;
m.add_function(wrap_pyfunction!(backtest_multi_asset_core, m)?)?;
m.add_function(wrap_pyfunction!(monte_carlo_bootstrap, m)?)?;
m.add_function(wrap_pyfunction!(walk_forward_indices, m)?)?;
m.add_function(wrap_pyfunction!(kelly_fraction, m)?)?;
m.add_function(wrap_pyfunction!(half_kelly_fraction, m)?)?;
m.add_class::<BacktestConfig>()?;
m.add_class::<StreamingBacktest>()?;
m.add_class::<PyCommissionModel>()?;
m.add_class::<PyCurrency>()?;
Ok(())
}
+159 -396
View File
@@ -8,19 +8,57 @@
//! [Rayon](https://docs.rs/rayon) after releasing the GIL. For small inputs
//! the sequential path (`parallel = false`) may be faster due to thread-pool
//! overhead.
//!
//! All indicator logic lives in `ferro_ta_core::batch`. This module is a thin
//! PyO3 wrapper that converts numpy ↔ Rust types and optionally adds Rayon
//! parallelism.
use ndarray::{Array2, ArrayView2};
use ndarray::Array2;
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use rayon::prelude::*;
use ta::indicators::{Maximum, Minimum};
use ta::Next;
fn transpose_to_series_major(data: ArrayView2<'_, f64>) -> Array2<f64> {
let (n_samples, n_series) = data.dim();
Array2::from_shape_vec((n_series, n_samples), data.t().iter().copied().collect())
.expect("shape matches transposed data")
// ---------------------------------------------------------------------------
// numpy ↔ Vec<Vec<f64>> helpers
// ---------------------------------------------------------------------------
/// Convert a numpy (n_samples, n_series) array into `Vec<Vec<f64>>` where
/// `result[j]` is column j (one time-series of length n_samples).
fn numpy2d_to_columns(arr: &ndarray::ArrayView2<'_, f64>) -> Vec<Vec<f64>> {
let (_n_samples, n_series) = arr.dim();
(0..n_series)
.map(|j| arr.column(j).to_vec())
.collect()
}
/// Convert `Vec<Vec<f64>>` (columns) back into a numpy (n_samples, n_series) array.
fn columns_to_numpy2d<'py>(
py: Python<'py>,
n_samples: usize,
columns: Vec<Vec<f64>>,
) -> Bound<'py, PyArray2<f64>> {
let n_series = columns.len();
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
for (j, col) in columns.into_iter().enumerate() {
for (i, val) in col.into_iter().enumerate() {
result[[i, j]] = val;
}
}
result.into_pyarray(py)
}
/// Convert a pair of column-vectors into a pair of numpy 2-D arrays.
fn column_pair_to_numpy2d<'py>(
py: Python<'py>,
n_samples: usize,
cols_a: Vec<Vec<f64>>,
cols_b: Vec<Vec<f64>>,
) -> (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>) {
(
columns_to_numpy2d(py, n_samples, cols_a),
columns_to_numpy2d(py, n_samples, cols_b),
)
}
fn validate_same_shape(
@@ -38,245 +76,41 @@ fn validate_same_shape(
}
}
fn finish_single_output<'py>(
py: Python<'py>,
n_samples: usize,
n_series: usize,
col_results: Vec<Vec<f64>>,
) -> Bound<'py, PyArray2<f64>> {
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
for (series_idx, values) in col_results.into_iter().enumerate() {
debug_assert_eq!(values.len(), n_samples);
for (sample_idx, value) in values.into_iter().enumerate() {
result[[sample_idx, series_idx]] = value;
}
}
result.into_pyarray(py)
fn map_core_err(err: String) -> PyErr {
PyValueError::new_err(err)
}
fn finish_pair_output<'py>(
py: Python<'py>,
n_samples: usize,
n_series: usize,
col_results: Vec<(Vec<f64>, Vec<f64>)>,
) -> (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>) {
let mut result_k = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
let mut result_d = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
// ---------------------------------------------------------------------------
// Parallel-aware unary batch helper
// ---------------------------------------------------------------------------
for (series_idx, (k_values, d_values)) in col_results.into_iter().enumerate() {
debug_assert_eq!(k_values.len(), n_samples);
debug_assert_eq!(d_values.len(), n_samples);
for (sample_idx, value) in k_values.into_iter().enumerate() {
result_k[[sample_idx, series_idx]] = value;
}
for (sample_idx, value) in d_values.into_iter().enumerate() {
result_d[[sample_idx, series_idx]] = value;
}
}
(result_k.into_pyarray(py), result_d.into_pyarray(py))
}
fn run_unary_batch<'py, F>(
/// Run a unary batch function. When `parallel` is true, split column extraction
/// across Rayon threads and process in parallel; otherwise delegate sequentially
/// to `ferro_ta_core::batch`.
fn run_unary_batch_par<'py, F>(
py: Python<'py>,
data: PyReadonlyArray2<'py, f64>,
parallel: bool,
process_col: F,
) -> Bound<'py, PyArray2<f64>>
per_col: F,
) -> PyResult<Bound<'py, PyArray2<f64>>>
where
F: Fn(&[f64]) -> Vec<f64> + Sync,
{
let arr = data.as_array();
let (n_samples, n_series) = arr.dim();
let series_major = transpose_to_series_major(arr);
let (n_samples, _n_series) = arr.dim();
let columns = numpy2d_to_columns(&arr);
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let run = |series_idx: usize| {
let column_row = series_major.row(series_idx);
let column = column_row
.as_slice()
.expect("series-major rows are contiguous");
process_col(column)
};
if parallel {
(0..n_series).into_par_iter().map(run).collect()
columns.par_iter().map(|col| per_col(col)).collect()
} else {
(0..n_series).map(run).collect()
columns.iter().map(|col| per_col(col)).collect()
}
});
finish_single_output(py, n_samples, n_series, col_results)
Ok(columns_to_numpy2d(py, n_samples, col_results))
}
fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> PyResult<()> {
if names.len() != timeperiods.len() {
return Err(PyValueError::new_err(format!(
"names length ({}) must equal timeperiods length ({})",
names.len(),
timeperiods.len()
)));
}
for (name, &timeperiod) in names.iter().zip(timeperiods.iter()) {
if timeperiod == 0 {
return Err(PyValueError::new_err(format!(
"{name}: timeperiod must be >= 1"
)));
}
}
Ok(())
}
fn compute_cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let typical_price: Vec<f64> = high
.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect();
let mut result = vec![f64::NAN; n];
for end in (timeperiod - 1)..n {
let window = &typical_price[(end + 1 - timeperiod)..=end];
let mean = window.iter().sum::<f64>() / timeperiod as f64;
let mad = window
.iter()
.map(|&value| (value - mean).abs())
.sum::<f64>()
/ timeperiod as f64;
result[end] = if mad != 0.0 {
(typical_price[end] - mean) / (0.015 * mad)
} else {
0.0
};
}
result
}
fn compute_willr(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
) -> PyResult<Vec<f64>> {
let n = high.len();
let mut result = vec![f64::NAN; n];
let mut max_ind =
Maximum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?;
let mut min_ind =
Minimum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?;
for (idx, ((&high_value, &low_value), &close_value)) in
high.iter().zip(low.iter()).zip(close.iter()).enumerate()
{
let highest = max_ind.next(high_value);
let lowest = min_ind.next(low_value);
if idx + 1 >= timeperiod {
let range = highest - lowest;
result[idx] = if range != 0.0 {
-100.0 * (highest - close_value) / range
} else {
-50.0
};
}
}
Ok(result)
}
fn compute_close_indicator(name: &str, close: &[f64], timeperiod: usize) -> PyResult<Vec<f64>> {
match name {
"SMA" => Ok(ferro_ta_core::overlap::sma(close, timeperiod)),
"EMA" => Ok(ferro_ta_core::overlap::ema(close, timeperiod)),
"RSI" => Ok(ferro_ta_core::momentum::rsi(close, timeperiod)),
"STDDEV" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)),
"VAR" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)
.into_iter()
.map(|value| if value.is_nan() { value } else { value * value })
.collect()),
"LINEARREG" => {
use crate::statistic::common::rolling_linreg_apply;
let last_x = (timeperiod - 1) as f64;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, intercept: f64| intercept + slope * last_x,
))
}
"LINEARREG_SLOPE" => {
use crate::statistic::common::rolling_linreg_apply;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, _: f64| slope,
))
}
"LINEARREG_INTERCEPT" => {
use crate::statistic::common::rolling_linreg_apply;
Ok(rolling_linreg_apply(
close,
timeperiod,
|_: f64, intercept: f64| intercept,
))
}
"LINEARREG_ANGLE" => {
use crate::statistic::common::rolling_linreg_apply;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, _: f64| slope.atan() * 180.0 / std::f64::consts::PI,
))
}
"TSF" => {
use crate::statistic::common::rolling_linreg_apply;
let forecast_x = timeperiod as f64;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, intercept: f64| intercept + slope * forecast_x,
))
}
_ => Err(PyValueError::new_err(format!(
"unsupported close indicator for grouped execution: {name}"
))),
}
}
fn compute_hlc_indicator(
name: &str,
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
) -> PyResult<Vec<f64>> {
match name {
"ATR" => Ok(ferro_ta_core::volatility::atr(high, low, close, timeperiod)),
"NATR" => {
let atr = ferro_ta_core::volatility::atr(high, low, close, timeperiod);
Ok(atr
.into_iter()
.zip(close.iter())
.map(|(atr_value, &close_value)| {
if atr_value.is_nan() || close_value == 0.0 {
f64::NAN
} else {
(atr_value / close_value) * 100.0
}
})
.collect())
}
"ADX" => Ok(ferro_ta_core::momentum::adx(high, low, close, timeperiod)),
"ADXR" => Ok(ferro_ta_core::momentum::adxr(high, low, close, timeperiod)),
"CCI" => Ok(compute_cci(high, low, close, timeperiod)),
"WILLR" => compute_willr(high, low, close, timeperiod),
_ => Err(PyValueError::new_err(format!(
"unsupported HLC indicator for grouped execution: {name}"
))),
}
}
type IndicatorArrayList = Vec<Py<PyArray1<f64>>>;
// ---------------------------------------------------------------------------
// batch_sma
// ---------------------------------------------------------------------------
@@ -309,9 +143,9 @@ pub fn batch_sma<'py>(
log::debug!(
"batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
);
Ok(run_unary_batch(py, data, parallel, |col| {
run_unary_batch_par(py, data, parallel, |col| {
ferro_ta_core::overlap::sma(col, timeperiod)
}))
})
}
// ---------------------------------------------------------------------------
@@ -319,17 +153,6 @@ pub fn batch_sma<'py>(
// ---------------------------------------------------------------------------
/// Batch Exponential Moving Average — applies EMA to every column.
///
/// Parameters
/// ----------
/// data : numpy array, shape (n_samples, n_series), dtype float64
/// timeperiod : int
/// parallel : bool, default True
/// When True, columns are processed in parallel via Rayon (GIL released).
///
/// Returns
/// -------
/// numpy array, shape (n_samples, n_series), dtype float64
#[pyfunction]
#[pyo3(signature = (data, timeperiod = 30, parallel = true))]
pub fn batch_ema<'py>(
@@ -345,9 +168,9 @@ pub fn batch_ema<'py>(
log::debug!(
"batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
);
Ok(run_unary_batch(py, data, parallel, |col| {
run_unary_batch_par(py, data, parallel, |col| {
ferro_ta_core::overlap::ema(col, timeperiod)
}))
})
}
// ---------------------------------------------------------------------------
@@ -355,18 +178,6 @@ pub fn batch_ema<'py>(
// ---------------------------------------------------------------------------
/// Batch RSI — applies RSI (Wilder seeding) to every column.
///
/// Parameters
/// ----------
/// data : numpy array, shape (n_samples, n_series), dtype float64
/// timeperiod : int
/// parallel : bool, default True
/// When True, columns are processed in parallel via Rayon (GIL released).
///
/// Returns
/// -------
/// numpy array, shape (n_samples, n_series), dtype float64
/// Values in [0, 100]; NaN during warmup.
#[pyfunction]
#[pyo3(signature = (data, timeperiod = 14, parallel = true))]
pub fn batch_rsi<'py>(
@@ -382,49 +193,9 @@ pub fn batch_rsi<'py>(
log::debug!(
"batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
);
let period_f = timeperiod as f64;
Ok(run_unary_batch(py, data, parallel, |col| {
let mut col_result = vec![f64::NAN; n_samples];
if n_samples <= timeperiod {
return col_result;
}
let mut avg_gain = 0.0_f64;
let mut avg_loss = 0.0_f64;
for i in 1..=timeperiod {
let delta = col[i] - col[i - 1];
if delta > 0.0 {
avg_gain += delta;
} else {
avg_loss += -delta;
}
}
avg_gain /= period_f;
avg_loss /= period_f;
let rs = if avg_loss == 0.0 {
f64::MAX
} else {
avg_gain / avg_loss
};
col_result[timeperiod] = 100.0 - 100.0 / (1.0 + rs);
for i in (timeperiod + 1)..n_samples {
let delta = col[i] - col[i - 1];
let (gain, loss) = if delta > 0.0 {
(delta, 0.0)
} else {
(0.0, -delta)
};
avg_gain = (avg_gain * (period_f - 1.0) + gain) / period_f;
avg_loss = (avg_loss * (period_f - 1.0) + loss) / period_f;
let rs = if avg_loss == 0.0 {
f64::MAX
} else {
avg_gain / avg_loss
};
col_result[i] = 100.0 - 100.0 / (1.0 + rs);
}
col_result
}))
run_unary_batch_par(py, data, parallel, |col| {
ferro_ta_core::momentum::rsi(col, timeperiod)
})
}
// ---------------------------------------------------------------------------
@@ -451,40 +222,27 @@ pub fn batch_atr<'py>(
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
let high_by_series = transpose_to_series_major(arr_h);
let low_by_series = transpose_to_series_major(arr_l);
let close_by_series = transpose_to_series_major(arr_c);
let h_cols = numpy2d_to_columns(&arr_h);
let l_cols = numpy2d_to_columns(&arr_l);
let c_cols = numpy2d_to_columns(&arr_c);
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let process_col = |series_idx: usize| -> Vec<f64> {
let high_row = high_by_series.row(series_idx);
let low_row = low_by_series.row(series_idx);
let close_row = close_by_series.row(series_idx);
let high_col = high_row
.as_slice()
.expect("series-major rows are contiguous");
let low_col = low_row
.as_slice()
.expect("series-major rows are contiguous");
let close_col = close_row
.as_slice()
.expect("series-major rows are contiguous");
ferro_ta_core::volatility::atr(high_col, low_col, close_col, timeperiod)
let process = |i: usize| {
ferro_ta_core::volatility::atr(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod)
};
if parallel {
(0..n_series).into_par_iter().map(process_col).collect()
(0..n_series).into_par_iter().map(process).collect()
} else {
(0..n_series).map(process_col).collect()
(0..n_series).map(process).collect()
}
});
Ok(finish_single_output(py, n_samples, n_series, col_results))
Ok(columns_to_numpy2d(py, n_samples, col_results))
}
// ---------------------------------------------------------------------------
// batch_stoch
// ---------------------------------------------------------------------------
/// Stoch batch result type (slowk, slowd arrays).
type StochBatchResult<'py> = (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>);
#[pyfunction]
@@ -507,40 +265,30 @@ pub fn batch_stoch<'py>(
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
let high_by_series = transpose_to_series_major(arr_h);
let low_by_series = transpose_to_series_major(arr_l);
let close_by_series = transpose_to_series_major(arr_c);
let h_cols = numpy2d_to_columns(&arr_h);
let l_cols = numpy2d_to_columns(&arr_l);
let c_cols = numpy2d_to_columns(&arr_c);
let col_results: Vec<(Vec<f64>, Vec<f64>)> = py.allow_threads(|| {
let process_col = |series_idx: usize| -> (Vec<f64>, Vec<f64>) {
let high_row = high_by_series.row(series_idx);
let low_row = low_by_series.row(series_idx);
let close_row = close_by_series.row(series_idx);
let high_col = high_row
.as_slice()
.expect("series-major rows are contiguous");
let low_col = low_row
.as_slice()
.expect("series-major rows are contiguous");
let close_col = close_row
.as_slice()
.expect("series-major rows are contiguous");
let process = |i: usize| {
ferro_ta_core::momentum::stoch(
high_col,
low_col,
close_col,
&h_cols[i],
&l_cols[i],
&c_cols[i],
fastk_period,
slowk_period,
slowd_period,
)
};
if parallel {
(0..n_series).into_par_iter().map(process_col).collect()
(0..n_series).into_par_iter().map(process).collect()
} else {
(0..n_series).map(process_col).collect()
(0..n_series).map(process).collect()
}
});
Ok(finish_pair_output(py, n_samples, n_series, col_results))
let (all_k, all_d): (Vec<Vec<f64>>, Vec<Vec<f64>>) = col_results.into_iter().unzip();
Ok(column_pair_to_numpy2d(py, n_samples, all_k, all_d))
}
// ---------------------------------------------------------------------------
@@ -567,39 +315,29 @@ pub fn batch_adx<'py>(
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
let high_by_series = transpose_to_series_major(arr_h);
let low_by_series = transpose_to_series_major(arr_l);
let close_by_series = transpose_to_series_major(arr_c);
let h_cols = numpy2d_to_columns(&arr_h);
let l_cols = numpy2d_to_columns(&arr_l);
let c_cols = numpy2d_to_columns(&arr_c);
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let process_col = |series_idx: usize| -> Vec<f64> {
let high_row = high_by_series.row(series_idx);
let low_row = low_by_series.row(series_idx);
let close_row = close_by_series.row(series_idx);
let high_col = high_row
.as_slice()
.expect("series-major rows are contiguous");
let low_col = low_row
.as_slice()
.expect("series-major rows are contiguous");
let close_col = close_row
.as_slice()
.expect("series-major rows are contiguous");
ferro_ta_core::momentum::adx(high_col, low_col, close_col, timeperiod)
let process = |i: usize| {
ferro_ta_core::momentum::adx(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod)
};
if parallel {
(0..n_series).into_par_iter().map(process_col).collect()
(0..n_series).into_par_iter().map(process).collect()
} else {
(0..n_series).map(process_col).collect()
(0..n_series).map(process).collect()
}
});
Ok(finish_single_output(py, n_samples, n_series, col_results))
Ok(columns_to_numpy2d(py, n_samples, col_results))
}
// ---------------------------------------------------------------------------
// grouped 1-D execution
// ---------------------------------------------------------------------------
type IndicatorArrayList = Vec<Py<PyArray1<f64>>>;
#[pyfunction]
#[pyo3(signature = (close, names, timeperiods, parallel = true))]
pub fn run_close_indicators<'py>(
@@ -609,22 +347,35 @@ pub fn run_close_indicators<'py>(
timeperiods: Vec<usize>,
parallel: bool,
) -> PyResult<IndicatorArrayList> {
validate_indicator_requests(&names, &timeperiods)?;
let close_values = close.as_slice()?;
let results: Vec<PyResult<Vec<f64>>> = py.allow_threads(|| {
let run = |idx: usize| compute_close_indicator(&names[idx], close_values, timeperiods[idx]);
if parallel {
(0..names.len()).into_par_iter().map(run).collect()
} else {
(0..names.len()).map(run).collect()
}
});
results
.into_iter()
.map(|result| result.map(|values| values.into_pyarray(py).unbind()))
.collect()
if parallel {
// Parallel path: call core per-indicator in parallel via Rayon
let results: Vec<Result<Vec<f64>, String>> = py.allow_threads(|| {
(0..names.len())
.into_par_iter()
.map(|idx| {
ferro_ta_core::batch::run_close_indicators(
close_values,
&[names[idx].clone()],
&[timeperiods[idx]],
)
.map(|mut v| v.remove(0))
})
.collect()
});
results
.into_iter()
.map(|r| r.map(|v| v.into_pyarray(py).unbind()).map_err(map_core_err))
.collect()
} else {
let results = ferro_ta_core::batch::run_close_indicators(close_values, &names, &timeperiods)
.map_err(map_core_err)?;
Ok(results
.into_iter()
.map(|v| v.into_pyarray(py).unbind())
.collect())
}
}
#[pyfunction]
@@ -638,7 +389,6 @@ pub fn run_hlc_indicators<'py>(
timeperiods: Vec<usize>,
parallel: bool,
) -> PyResult<IndicatorArrayList> {
validate_indicator_requests(&names, &timeperiods)?;
let high_values = high.as_slice()?;
let low_values = low.as_slice()?;
let close_values = close.as_slice()?;
@@ -649,27 +399,40 @@ pub fn run_hlc_indicators<'py>(
));
}
let results: Vec<PyResult<Vec<f64>>> = py.allow_threads(|| {
let run = |idx: usize| {
compute_hlc_indicator(
&names[idx],
high_values,
low_values,
close_values,
timeperiods[idx],
)
};
if parallel {
(0..names.len()).into_par_iter().map(run).collect()
} else {
(0..names.len()).map(run).collect()
}
});
results
.into_iter()
.map(|result| result.map(|values| values.into_pyarray(py).unbind()))
.collect()
if parallel {
let results: Vec<Result<Vec<f64>, String>> = py.allow_threads(|| {
(0..names.len())
.into_par_iter()
.map(|idx| {
ferro_ta_core::batch::run_hlc_indicators(
high_values,
low_values,
close_values,
&[names[idx].clone()],
&[timeperiods[idx]],
)
.map(|mut v| v.remove(0))
})
.collect()
});
results
.into_iter()
.map(|r| r.map(|v| v.into_pyarray(py).unbind()).map_err(map_core_err))
.collect()
} else {
let results = ferro_ta_core::batch::run_hlc_indicators(
high_values,
low_values,
close_values,
&names,
&timeperiods,
)
.map_err(map_core_err)?;
Ok(results
.into_iter()
.map(|v| v.into_pyarray(py).unbind())
.collect())
}
}
// ---------------------------------------------------------------------------
+19 -136
View File
@@ -1,45 +1,10 @@
//! Chunked / out-of-core execution helpers.
//!
//! These functions support running indicators on data that is too large for
//! memory by processing it in chunks. The caller splits a large series into
//! overlapping chunks (overlap = indicator warm-up period), runs an indicator
//! on each chunk, and then stitches the results by trimming the overlap from
//! the front of each chunk's output.
//!
//! Functions
//! ---------
//! - `trim_overlap` — remove the first *overlap* elements from
//! an array (to strip the warm-up from a chunk's indicator output).
//! - `stitch_chunks` — concatenate trimmed chunk results into one
//! array.
//! - `make_chunk_ranges` — compute start/end indices for a series
//! given chunk size and overlap, for use by the Python caller.
//! - `chunk_apply_close_indicator`— run chunked close-only indicators fully in
//! Rust (SMA/EMA/RSI).
//! - `forward_fill_nan` — forward-fill NaN values in a 1-D array.
//! Chunked / out-of-core execution helpers (thin PyO3 wrapper over ferro_ta_core::chunked).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// trim_overlap
// ---------------------------------------------------------------------------
/// Remove the first *overlap* elements from an array.
///
/// After running an indicator on a chunk that includes a warm-up prefix, the
/// first *overlap* output values are unreliable (NaN or influenced by
/// artificial padding). This function discards them.
///
/// Parameters
/// ----------
/// chunk_out : 1-D float64 array — indicator output for a chunk
/// overlap : int — number of leading elements to discard
///
/// Returns
/// -------
/// 1-D float64 array — the trailing ``len(chunk_out) - overlap`` elements
#[pyfunction]
pub fn trim_overlap<'py>(
py: Python<'py>,
@@ -47,67 +12,32 @@ pub fn trim_overlap<'py>(
overlap: usize,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = chunk_out.as_slice()?;
let n = s.len();
if overlap > n {
if overlap > s.len() {
return Err(PyValueError::new_err(format!(
"overlap ({overlap}) must be <= chunk length ({n})"
"overlap ({overlap}) must be <= chunk length ({})",
s.len()
)));
}
let out = s[overlap..].to_vec();
Ok(out.into_pyarray(py))
let result = ferro_ta_core::chunked::trim_overlap(s, overlap);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// stitch_chunks
// ---------------------------------------------------------------------------
/// Concatenate a list of trimmed chunk results into a single output array.
///
/// Parameters
/// ----------
/// chunks : list of 1-D float64 arrays — trimmed outputs from each chunk
///
/// Returns
/// -------
/// 1-D float64 array — concatenated result
#[pyfunction]
pub fn stitch_chunks<'py>(
py: Python<'py>,
chunks: Vec<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let mut out: Vec<f64> = Vec::new();
for chunk in &chunks {
out.extend_from_slice(chunk.as_slice()?);
}
Ok(out.into_pyarray(py))
let vecs: Vec<Vec<f64>> = chunks
.iter()
.map(|c| c.as_slice().map(|s| s.to_vec()))
.collect::<Result<_, _>>()?;
let refs: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect();
let result = ferro_ta_core::chunked::stitch_chunks(&refs);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// make_chunk_ranges
// ---------------------------------------------------------------------------
/// Compute the (start, end) index pairs for chunked processing.
///
/// Each range ``[start, end)`` specifies a slice of the input series that the
/// caller should pass to the indicator function. The first *overlap* elements
/// of each range (except the very first range) are the warm-up prefix from the
/// previous chunk.
///
/// Parameters
/// ----------
/// n : int — total length of the series
/// chunk_size : int — desired number of *output* bars per chunk (>= 1)
/// overlap : int — number of warm-up bars prepended to each chunk (>= 0)
///
/// Returns
/// -------
/// list of (start: int, end: int) pairs as a flattened 1-D int64 array of
/// length 2 × n_chunks. Caller unpacks with ``ranges.reshape(-1, 2)``.
///
/// Example
/// -------
/// For n=10, chunk_size=4, overlap=2 the ranges would cover:
/// [0, 4), [2, 8), [6, 10) (start of chunk 2 = end of prev chunk - overlap)
/// Compute (start, end) index pairs for chunked processing.
#[pyfunction]
pub fn make_chunk_ranges<'py>(
py: Python<'py>,
@@ -118,26 +48,12 @@ pub fn make_chunk_ranges<'py>(
if chunk_size == 0 {
return Err(PyValueError::new_err("chunk_size must be >= 1"));
}
let mut ranges: Vec<i64> = Vec::new();
if n == 0 {
return Ok(ranges.into_pyarray(py));
}
let mut start: usize = 0;
loop {
let end = (start + chunk_size + overlap).min(n);
ranges.push(start as i64);
ranges.push(end as i64);
if end >= n {
break;
}
// Next chunk starts at end - overlap (so the next chunk has its overlap prefix)
start = end.saturating_sub(overlap);
}
Ok(ranges.into_pyarray(py))
let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// chunk_apply_close_indicator
// chunk_apply_close_indicator — stays in PyO3 (dispatches to ferro_ta_core indicators)
// ---------------------------------------------------------------------------
fn compute_close_indicator(
@@ -156,18 +72,6 @@ fn compute_close_indicator(
}
/// Run chunked execution for close-only indicators in Rust.
///
/// Parameters
/// ----------
/// series : 1-D float64 array
/// indicator : one of {"SMA", "EMA", "RSI"}
/// timeperiod : indicator period (>= 1)
/// chunk_size : output bars per chunk (>= 1)
/// overlap : warm-up bars prepended to each chunk
///
/// Returns
/// -------
/// 1-D float64 array with the same length as `series`.
#[pyfunction]
#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))]
pub fn chunk_apply_close_indicator<'py>(
@@ -227,38 +131,17 @@ pub fn chunk_apply_close_indicator<'py>(
Ok(stitched.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// forward_fill_nan
// ---------------------------------------------------------------------------
/// Forward-fill NaN values in a 1-D array.
///
/// Leading NaN values are preserved until the first non-NaN value appears.
#[pyfunction]
pub fn forward_fill_nan<'py>(
py: Python<'py>,
values: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let input = values.as_slice()?;
let mut out = Vec::with_capacity(input.len());
let mut last = f64::NAN;
for &value in input {
if value.is_nan() {
out.push(last);
} else {
last = value;
out.push(value);
}
}
Ok(out.into_pyarray(py))
let result = ferro_ta_core::chunked::forward_fill_nan(input);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(trim_overlap, m)?)?;
m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?;
+9 -99
View File
@@ -1,40 +1,10 @@
//! Crypto and 24/7 market helpers.
//!
//! Functions designed for continuous (24/7) markets such as crypto:
//!
//! - `funding_cumulative_pnl` — cumulative PnL from periodic funding rate
//! payments, given a constant position size.
//! - `continuous_bar_labels` — assign a sequential integer label to each bar
//! based on a fixed period size (e.g. daily UTC buckets).
//! - `mark_session_boundaries` — return indices where the session rolls over
//! (useful for calendar-free resampling on continuous data).
//! Crypto and 24/7 market helpers (thin PyO3 wrapper over ferro_ta_core::crypto).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// funding_cumulative_pnl
// ---------------------------------------------------------------------------
/// Compute the cumulative PnL from funding rate payments.
///
/// Crypto perpetual contracts charge a periodic funding rate to the holder.
/// If you hold ``position_size`` contracts and the funding rate is ``rate[i]``,
/// the PnL at period *i* is ``-position_size * rate[i]`` (longs pay when rate
/// is positive). This function returns the **cumulative** funding PnL.
///
/// Parameters
/// ----------
/// position_size : 1-D float64 array — signed position size per funding period
/// (positive = long, negative = short). Must have the same length as
/// ``funding_rate``.
/// funding_rate : 1-D float64 array — periodic funding rate (decimal, e.g.
/// 0.0001 = 0.01%).
///
/// Returns
/// -------
/// 1-D float64 array — cumulative funding PnL (same length as inputs).
#[pyfunction]
pub fn funding_cumulative_pnl<'py>(
py: Python<'py>,
@@ -43,41 +13,16 @@ pub fn funding_cumulative_pnl<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let pos = position_size.as_slice()?;
let rate = funding_rate.as_slice()?;
let n = pos.len();
if n != rate.len() {
if pos.len() != rate.len() {
return Err(PyValueError::new_err(
"position_size and funding_rate must have the same length",
));
}
let mut out = vec![0.0_f64; n];
let mut cumulative = 0.0_f64;
for i in 0..n {
// Longs pay when rate > 0; shorts receive
cumulative += -pos[i] * rate[i];
out[i] = cumulative;
}
Ok(out.into_pyarray(py))
let result = ferro_ta_core::crypto::funding_cumulative_pnl(pos, rate);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// continuous_bar_labels
// ---------------------------------------------------------------------------
/// Assign a sequential integer label per bar based on a fixed-size period.
///
/// For 24/7 data (no session gaps), this groups bars into equal-sized buckets:
/// bars 0…(period_bars-1) get label 0, bars period_bars…(2*period_bars-1) get
/// label 1, etc. Useful for resampling continuous data (e.g. group every 24
/// one-hour bars into a "day").
///
/// Parameters
/// ----------
/// n_bars : int — total number of bars
/// period_bars: int — number of bars per period (must be >= 1)
///
/// Returns
/// -------
/// 1-D int64 array of length *n_bars* — period labels (0-based).
#[pyfunction]
pub fn continuous_bar_labels<'py>(
py: Python<'py>,
@@ -87,56 +32,21 @@ pub fn continuous_bar_labels<'py>(
if period_bars == 0 {
return Err(PyValueError::new_err("period_bars must be >= 1"));
}
let out: Vec<i64> = (0..n_bars).map(|i| (i / period_bars) as i64).collect();
Ok(out.into_pyarray(py))
let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// mark_session_boundaries
// ---------------------------------------------------------------------------
/// Return bar indices where a new session begins.
///
/// Given UTC timestamps in nanoseconds (int64), marks boundaries at the start
/// of each new UTC day (midnight). Returns the bar indices where the UTC day
/// changes. Bar 0 is always included as the first boundary.
///
/// Parameters
/// ----------
/// timestamps_ns : 1-D int64 array — UTC timestamps in nanoseconds
/// (e.g. from ``pandas.DatetimeIndex.astype('int64')``).
///
/// Returns
/// -------
/// 1-D int64 array — indices of bars at the start of each new UTC day.
/// Return bar indices where a new UTC day begins.
#[pyfunction]
pub fn mark_session_boundaries<'py>(
py: Python<'py>,
timestamps_ns: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let ts = timestamps_ns.as_slice()?;
let n = ts.len();
if n == 0 {
return Ok(Vec::<i64>::new().into_pyarray(py));
}
// Nanoseconds per day
const NS_PER_DAY: i64 = 86_400_000_000_000;
let mut out = vec![0i64]; // bar 0 is always a boundary
let mut prev_day = ts[0].div_euclid(NS_PER_DAY);
for (i, &t) in ts.iter().enumerate().skip(1) {
let day = t.div_euclid(NS_PER_DAY);
if day != prev_day {
out.push(i as i64);
prev_day = day;
}
}
Ok(out.into_pyarray(py))
let result = ferro_ta_core::crypto::mark_session_boundaries(ts);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(funding_cumulative_pnl, m)?)?;
m.add_function(wrap_pyfunction!(continuous_bar_labels, m)?)?;
-187
View File
@@ -1,187 +0,0 @@
// Shared Hilbert Transform Core
// Based on John Ehlers' Discrete Hilbert Transform as implemented in TA-Lib.
// Reference: "Cybernetic Analysis for Stocks and Futures" by J.F. Ehlers
//
// All HT functions share a 63-bar lookback period.
use std::f64::consts::PI;
pub(super) const HT_LOOKBACK: usize = 63;
/// Shared output from the core Hilbert Transform computation.
pub(super) struct HtCore {
pub(super) trendline: Vec<f64>,
pub(super) dc_period: Vec<f64>,
pub(super) dc_phase: Vec<f64>,
pub(super) inphase: Vec<f64>,
pub(super) quadrature: Vec<f64>,
pub(super) trend_mode: Vec<i32>,
}
/// Run the full Hilbert Transform pipeline on a slice of close prices.
pub(super) fn compute_ht_core(prices: &[f64]) -> HtCore {
let n = prices.len();
let mut trendline = vec![f64::NAN; n];
let mut dc_period = vec![f64::NAN; n];
let mut dc_phase = vec![f64::NAN; n];
let mut inphase = vec![f64::NAN; n];
let mut quadrature = vec![f64::NAN; n];
let mut trend_mode = vec![0i32; n];
if n <= HT_LOOKBACK {
return HtCore {
trendline,
dc_period,
dc_phase,
inphase,
quadrature,
trend_mode,
};
}
// Step 1: Smooth the price series (4-bar weighted average)
let mut smooth = vec![0.0f64; n];
for i in 0..n {
smooth[i] = if i >= 3 {
(4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0
} else {
prices[i]
};
}
// Step 2: Full Hilbert Transform pipeline
let mut detrender = vec![0.0f64; n];
let mut q1 = vec![0.0f64; n];
let mut i1 = vec![0.0f64; n];
let mut ji = vec![0.0f64; n];
let mut jq = vec![0.0f64; n];
let mut i2 = vec![0.0f64; n];
let mut q2 = vec![0.0f64; n];
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
let mut period = vec![0.0f64; n];
let mut smooth_period = vec![0.0f64; n];
let mut phase = vec![0.0f64; n];
for i in 6..n {
let prev_period = period[i - 1];
// Alpha coefficient for HT filters depends on the current period estimate
let alpha = 0.075 * prev_period + 0.54;
// Discrete Hilbert Transform of smooth price (detrender)
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
- 0.5769 * smooth[i - 4]
- 0.0962 * smooth[i - 6])
* alpha;
// Q1: HT of detrender
if i >= 12 {
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
- 0.5769 * detrender[i - 4]
- 0.0962 * detrender[i - 6])
* alpha;
}
// I1: delayed detrender
if i >= 9 {
i1[i] = detrender[i - 3];
}
// jI: HT of I1
if i >= 15 {
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
* alpha;
}
// jQ: HT of Q1
if i >= 18 {
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
* alpha;
}
// Phase components
let i2_raw = i1[i] - jq[i];
let q2_raw = q1[i] + ji[i];
// EMA smoothing of I2 and Q2
let i2_prev = i2[i - 1];
let q2_prev = q2[i - 1];
i2[i] = 0.2 * i2_raw + 0.8 * i2_prev;
q2[i] = 0.2 * q2_raw + 0.8 * q2_prev;
// Cross-product for period estimation
let re_raw = i2[i] * i2_prev + q2[i] * q2_prev;
let im_raw = i2[i] * q2_prev - q2[i] * i2_prev;
// EMA smoothing of Re and Im
re[i] = 0.2 * re_raw + 0.8 * re[i - 1];
im[i] = 0.2 * im_raw + 0.8 * im[i - 1];
// Compute period from cross-product of consecutive phasors.
// Uses atan(Im/Re) per Ehlers' convention; guard against negative Re
// which would flip the sign of the period estimate.
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
2.0 * PI / (im[i] / re[i]).atan()
} else {
prev_period
};
// Clamp period relative to previous
if prev_period > 0.0 {
if p > 1.5 * prev_period {
p = 1.5 * prev_period;
}
if p < 0.67 * prev_period {
p = 0.67 * prev_period;
}
}
// Hard clamp to [6, 50] bars
p = p.clamp(6.0, 50.0);
// EMA smooth the period
period[i] = 0.2 * p + 0.8 * prev_period;
// Smooth the smoothed period once more
smooth_period[i] = 0.33 * period[i] + 0.67 * smooth_period[i - 1];
// Phase from I1 and Q1
phase[i] = if i1[i] != 0.0 {
q1[i].atan2(i1[i]) * 180.0 / PI
} else if q1[i] > 0.0 {
90.0
} else if q1[i] < 0.0 {
-90.0
} else {
0.0
};
// Write outputs once past lookback
if i >= HT_LOOKBACK {
dc_period[i] = smooth_period[i];
dc_phase[i] = phase[i];
inphase[i] = i1[i];
quadrature[i] = q1[i];
// Trend mode: cycle when SmoothPeriod >= 20, trend when < 20
trend_mode[i] = if smooth_period[i] < 20.0 { 1 } else { 0 };
}
}
// Trendline: average over the current dominant cycle period
for i in HT_LOOKBACK..n {
let sp = smooth_period[i];
let dc = (sp.round() as usize).max(1).min(i + 1);
let sum: f64 = (0..dc).map(|j| smooth[i - j]).sum();
trendline[i] = sum / dc as f64;
}
HtCore {
trendline,
dc_period,
dc_phase,
inphase,
quadrature,
trend_mode,
}
}
+2 -4
View File
@@ -1,4 +1,3 @@
use super::common::compute_ht_core;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
@@ -8,7 +7,6 @@ pub fn ht_dcperiod<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let prices = close.as_slice()?;
let core = compute_ht_core(prices);
Ok(core.dc_period.into_pyarray(py))
let result = ferro_ta_core::cycle::ht_dcperiod(close.as_slice()?);
Ok(result.into_pyarray(py))
}
+2 -4
View File
@@ -1,4 +1,3 @@
use super::common::compute_ht_core;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
@@ -8,7 +7,6 @@ pub fn ht_dcphase<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let prices = close.as_slice()?;
let core = compute_ht_core(prices);
Ok(core.dc_phase.into_pyarray(py))
let result = ferro_ta_core::cycle::ht_dcphase(close.as_slice()?);
Ok(result.into_pyarray(py))
}
+2 -7
View File
@@ -1,4 +1,3 @@
use super::common::compute_ht_core;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
@@ -9,10 +8,6 @@ pub fn ht_phasor<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
let prices = close.as_slice()?;
let core = compute_ht_core(prices);
Ok((
core.inphase.into_pyarray(py),
core.quadrature.into_pyarray(py),
))
let (inphase, quadrature) = ferro_ta_core::cycle::ht_phasor(close.as_slice()?);
Ok((inphase.into_pyarray(py), quadrature.into_pyarray(py)))
}
+1 -17
View File
@@ -1,7 +1,5 @@
use super::common::{compute_ht_core, HT_LOOKBACK};
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use std::f64::consts::PI;
/// Hilbert Transform SineWave. Returns (sine, leadsine) where leadsine leads sine by 45°.
#[pyfunction]
@@ -10,20 +8,6 @@ pub fn ht_sine<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
let prices = close.as_slice()?;
let n = prices.len();
let core = compute_ht_core(prices);
let mut sine = vec![f64::NAN; n];
let mut lead_sine = vec![f64::NAN; n];
for i in HT_LOOKBACK..n {
if !core.dc_phase[i].is_nan() {
let phase_rad = core.dc_phase[i] * PI / 180.0;
sine[i] = phase_rad.sin();
lead_sine[i] = (phase_rad + PI / 4.0).sin(); // 45-degree lead
}
}
let (sine, lead_sine) = ferro_ta_core::cycle::ht_sine(close.as_slice()?);
Ok((sine.into_pyarray(py), lead_sine.into_pyarray(py)))
}
+2 -4
View File
@@ -1,4 +1,3 @@
use super::common::compute_ht_core;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
@@ -8,7 +7,6 @@ pub fn ht_trendline<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let prices = close.as_slice()?;
let core = compute_ht_core(prices);
Ok(core.trendline.into_pyarray(py))
let result = ferro_ta_core::cycle::ht_trendline(close.as_slice()?);
Ok(result.into_pyarray(py))
}
+2 -4
View File
@@ -1,4 +1,3 @@
use super::common::compute_ht_core;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
@@ -8,7 +7,6 @@ pub fn ht_trendmode<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let prices = close.as_slice()?;
let core = compute_ht_core(prices);
Ok(core.trend_mode.into_pyarray(py))
let result = ferro_ta_core::cycle::ht_trendmode(close.as_slice()?);
Ok(result.into_pyarray(py))
}
-2
View File
@@ -3,8 +3,6 @@
//!
//! All functions use a 63-bar lookback period (first 63 values are NaN).
mod common;
mod ht_dcperiod;
mod ht_dcphase;
mod ht_phasor;
+39 -546
View File
@@ -1,76 +1,20 @@
//! Extended Indicators — Rust implementations of indicators not in TA-Lib.
//! Extended Indicators — thin PyO3 wrappers delegating to `ferro_ta_core::extended`.
//!
//! All compute-heavy work (sequential loops, rolling windows) is done in Rust.
//! Python wrappers in `python/ferro_ta/extended.py` are thin call-throughs that
//! handle input conversion and pandas/polars wrapping.
//! All compute-heavy work lives in the core crate. These functions convert
//! numpy arrays to slices, call the core, and convert the results back.
#![allow(clippy::type_complexity)]
#![allow(clippy::too_many_arguments)]
use std::collections::VecDeque;
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Compute ATR array using Wilder smoothing (same as src/volatility/atr.rs).
fn compute_atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if n <= timeperiod {
return result;
}
// Seed: SMA of first `timeperiod` true range values
let mut seed_sum = high[0] - low[0]; // first TR has no prev_close
for i in 1..timeperiod {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
seed_sum += hl.max(hc).max(lc);
}
let mut atr = seed_sum / timeperiod as f64;
result[timeperiod - 1] = atr;
let pf = (timeperiod - 1) as f64;
for i in timeperiod..n {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
let tr = hl.max(hc).max(lc);
atr = (atr * pf + tr) / timeperiod as f64;
result[i] = atr;
}
result
}
/// Compute EMA using ferro_ta_core (SMA-seeded, matches Python EMA).
fn compute_ema_ta(prices: &[f64], timeperiod: usize) -> Vec<f64> {
ferro_ta_core::overlap::ema(prices, timeperiod)
}
/// Compute WMA array using ferro_ta_core O(n) implementation.
fn compute_wma(prices: &[f64], timeperiod: usize) -> Vec<f64> {
ferro_ta_core::overlap::wma(prices, timeperiod)
}
// ---------------------------------------------------------------------------
// VWAP
// ---------------------------------------------------------------------------
/// Volume Weighted Average Price (cumulative or rolling).
///
/// Parameters
/// ----------
/// high, low, close, volume : 1-D float64 arrays (equal length)
/// timeperiod : 0 = cumulative from bar 0; >= 1 = rolling window
///
/// Returns
/// -------
/// 1-D float64 array of VWAP values.
#[pyfunction]
#[pyo3(signature = (high, low, close, volume, timeperiod = 0))]
pub fn vwap<'py>(
@@ -85,59 +29,13 @@ pub fn vwap<'py>(
let lo = low.as_slice()?;
let c = close.as_slice()?;
let v = volume.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[
(n, "high"),
(h.len(), "high"),
(lo.len(), "low"),
(c.len(), "close"),
(v.len(), "volume"),
])?;
let mut result = vec![f64::NAN; n];
let mut cum_tpv = 0.0f64;
let mut cum_vol = 0.0f64;
if timeperiod == 0 {
for i in 0..n {
let tp = (h[i] + lo[i] + c[i]) / 3.0;
cum_tpv += tp * v[i];
cum_vol += v[i];
result[i] = if cum_vol != 0.0 {
cum_tpv / cum_vol
} else {
f64::NAN
};
}
} else {
// Pre-compute cumulative sums for O(n) rolling window
let mut cum_tpv_arr = vec![0.0f64; n];
let mut cum_vol_arr = vec![0.0f64; n];
for i in 0..n {
let tp = (h[i] + lo[i] + c[i]) / 3.0;
let tpv = tp * v[i];
cum_tpv_arr[i] = tpv + if i > 0 { cum_tpv_arr[i - 1] } else { 0.0 };
cum_vol_arr[i] = v[i] + if i > 0 { cum_vol_arr[i - 1] } else { 0.0 };
}
for i in (timeperiod - 1)..n {
let prev_tpv = if i >= timeperiod {
cum_tpv_arr[i - timeperiod]
} else {
0.0
};
let prev_vol = if i >= timeperiod {
cum_vol_arr[i - timeperiod]
} else {
0.0
};
let w_tpv = cum_tpv_arr[i] - prev_tpv;
let w_vol = cum_vol_arr[i] - prev_vol;
result[i] = if w_vol != 0.0 {
w_tpv / w_vol
} else {
f64::NAN
};
}
}
let result = ferro_ta_core::extended::vwap(h, lo, c, v, timeperiod);
Ok(result.into_pyarray(py))
}
@@ -145,9 +43,6 @@ pub fn vwap<'py>(
// VWMA
// ---------------------------------------------------------------------------
/// Volume Weighted Moving Average.
///
/// VWMA = sum(close * volume, n) / sum(volume, n)
#[pyfunction]
#[pyo3(signature = (close, volume, timeperiod = 20))]
pub fn vwma<'py>(
@@ -159,32 +54,8 @@ pub fn vwma<'py>(
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let c = close.as_slice()?;
let v = volume.as_slice()?;
let n = c.len();
validation::validate_equal_length(&[(n, "close"), (v.len(), "volume")])?;
let mut cum_cv = vec![0.0f64; n];
let mut cum_v = vec![0.0f64; n];
for i in 0..n {
cum_cv[i] = c[i] * v[i] + if i > 0 { cum_cv[i - 1] } else { 0.0 };
cum_v[i] = v[i] + if i > 0 { cum_v[i - 1] } else { 0.0 };
}
let mut result = vec![f64::NAN; n];
for i in (timeperiod - 1)..n {
let prev_cv = if i >= timeperiod {
cum_cv[i - timeperiod]
} else {
0.0
};
let prev_v = if i >= timeperiod {
cum_v[i - timeperiod]
} else {
0.0
};
let w_cv = cum_cv[i] - prev_cv;
let w_v = cum_v[i] - prev_v;
result[i] = if w_v != 0.0 { w_cv / w_v } else { f64::NAN };
}
validation::validate_equal_length(&[(c.len(), "close"), (v.len(), "volume")])?;
let result = ferro_ta_core::extended::vwma(c, v, timeperiod);
Ok(result.into_pyarray(py))
}
@@ -192,10 +63,6 @@ pub fn vwma<'py>(
// SUPERTREND
// ---------------------------------------------------------------------------
/// ATR-based Supertrend indicator.
///
/// Returns (supertrend_line, direction) where direction is an int8 array:
/// 1 = uptrend, -1 = downtrend, 0 = warmup.
#[pyfunction]
#[pyo3(signature = (high, low, close, timeperiod = 7, multiplier = 3.0))]
pub fn supertrend<'py>(
@@ -210,95 +77,15 @@ pub fn supertrend<'py>(
let h = high.as_slice()?;
let lo = low.as_slice()?;
let c = close.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?;
let atr = compute_atr(h, lo, c, timeperiod);
let mut supertrend_out = vec![f64::NAN; n];
let mut direction = vec![0i8; n];
let mut upper_band = vec![f64::NAN; n];
let mut lower_band = vec![f64::NAN; n];
let first_valid = timeperiod - 1;
if first_valid >= n || atr[first_valid].is_nan() {
return Ok((supertrend_out.into_pyarray(py), direction.into_pyarray(py)));
}
// Compute basic bands
let mut upper_basic = vec![f64::NAN; n];
let mut lower_basic = vec![f64::NAN; n];
for i in 0..n {
if !atr[i].is_nan() {
let hl2 = (h[i] + lo[i]) / 2.0;
upper_basic[i] = hl2 + multiplier * atr[i];
lower_basic[i] = hl2 - multiplier * atr[i];
}
}
// Initialize band state at first valid ATR bar
upper_band[first_valid] = upper_basic[first_valid];
lower_band[first_valid] = lower_basic[first_valid];
// Keep supertrend_out NaN and direction 0 for indices 0..timeperiod (warmup)
for i in (first_valid + 1)..n {
if atr[i].is_nan() {
continue;
}
// Adjust lower band
lower_band[i] = if lower_basic[i] > lower_band[i - 1] || c[i - 1] < lower_band[i - 1] {
lower_basic[i]
} else {
lower_band[i - 1]
};
// Adjust upper band
upper_band[i] = if upper_basic[i] < upper_band[i - 1] || c[i - 1] > upper_band[i - 1] {
upper_basic[i]
} else {
upper_band[i - 1]
};
// Direction and output only from index timeperiod (warmup = 0, NaN)
if i >= timeperiod {
let prev_dir = direction[i - 1];
direction[i] = if prev_dir == 0 {
// First output bar: bootstrap with -1 (downtrend) or 1 from price vs band
if c[i] > upper_band[i] {
1
} else {
-1
}
} else if prev_dir == -1 {
if c[i] > upper_band[i] {
1
} else {
-1
}
} else if c[i] < lower_band[i] {
-1
} else {
1
};
supertrend_out[i] = if direction[i] == 1 {
lower_band[i]
} else {
upper_band[i]
};
}
}
Ok((supertrend_out.into_pyarray(py), direction.into_pyarray(py)))
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?;
let (st, dir) = ferro_ta_core::extended::supertrend(h, lo, c, timeperiod, multiplier);
Ok((st.into_pyarray(py), dir.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// DONCHIAN
// ---------------------------------------------------------------------------
/// Donchian Channels — rolling highest high / lowest low.
///
/// Returns (upper, middle, lower) arrays.
#[pyfunction]
#[pyo3(signature = (high, low, timeperiod = 20))]
pub fn donchian<'py>(
@@ -314,51 +101,8 @@ pub fn donchian<'py>(
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let h = high.as_slice()?;
let lo = low.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low")])?;
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
let mut middle = vec![f64::NAN; n];
// Use monotonic deque for O(n) sliding max / min
let mut max_dq: VecDeque<usize> = VecDeque::new();
let mut min_dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
// Remove out-of-window indices
while max_dq
.front()
.map(|&j| j + timeperiod <= i)
.unwrap_or(false)
{
max_dq.pop_front();
}
while min_dq
.front()
.map(|&j| j + timeperiod <= i)
.unwrap_or(false)
{
min_dq.pop_front();
}
// Maintain decreasing deque for max
while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) {
max_dq.pop_back();
}
max_dq.push_back(i);
// Maintain increasing deque for min
while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) {
min_dq.pop_back();
}
min_dq.push_back(i);
if i + 1 >= timeperiod {
upper[i] = h[*max_dq.front().unwrap()];
lower[i] = lo[*min_dq.front().unwrap()];
middle[i] = (upper[i] + lower[i]) / 2.0;
}
}
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low")])?;
let (upper, middle, lower) = ferro_ta_core::extended::donchian(h, lo, timeperiod);
Ok((
upper.into_pyarray(py),
middle.into_pyarray(py),
@@ -370,10 +114,6 @@ pub fn donchian<'py>(
// CHOPPINESS_INDEX
// ---------------------------------------------------------------------------
/// Choppiness Index — measures market choppiness vs trending.
///
/// Values near 100 → choppy; near 0 → trending.
/// Leading `timeperiod` values are NaN.
#[pyfunction]
#[pyo3(signature = (high, low, close, timeperiod = 14))]
pub fn choppiness_index<'py>(
@@ -387,73 +127,8 @@ pub fn choppiness_index<'py>(
let h = high.as_slice()?;
let lo = low.as_slice()?;
let c = close.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?;
// ATR(1) = True Range per bar
let mut tr = vec![0.0f64; n];
tr[0] = h[0] - lo[0];
for i in 1..n {
let hl = h[i] - lo[i];
let hc = (h[i] - c[i - 1]).abs();
let lc = (lo[i] - c[i - 1]).abs();
tr[i] = hl.max(hc).max(lc);
}
// Cumulative TR for rolling sum
let mut cum_tr = vec![0.0f64; n];
cum_tr[0] = tr[0];
for i in 1..n {
cum_tr[i] = cum_tr[i - 1] + tr[i];
}
let log_n = (timeperiod as f64).log10();
let mut result = vec![f64::NAN; n];
// Rolling max and min using monotonic deques
let mut max_dq: VecDeque<usize> = VecDeque::new();
let mut min_dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while max_dq
.front()
.map(|&j| j + timeperiod <= i)
.unwrap_or(false)
{
max_dq.pop_front();
}
while min_dq
.front()
.map(|&j| j + timeperiod <= i)
.unwrap_or(false)
{
min_dq.pop_front();
}
while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) {
max_dq.pop_back();
}
max_dq.push_back(i);
while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) {
min_dq.pop_back();
}
min_dq.push_back(i);
if i + 1 > timeperiod {
let prev_cum = if i >= timeperiod {
cum_tr[i - timeperiod]
} else {
0.0
};
let sum_tr = cum_tr[i] - prev_cum;
let hh = h[*max_dq.front().unwrap()];
let ll = lo[*min_dq.front().unwrap()];
let hl_range = hh - ll;
if hl_range > 0.0 && log_n > 0.0 {
result[i] = 100.0 * (sum_tr / hl_range).log10() / log_n;
}
}
}
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?;
let result = ferro_ta_core::extended::choppiness_index(h, lo, c, timeperiod);
Ok(result.into_pyarray(py))
}
@@ -461,9 +136,6 @@ pub fn choppiness_index<'py>(
// KELTNER_CHANNELS
// ---------------------------------------------------------------------------
/// Keltner Channels — EMA ± (multiplier × ATR).
///
/// Returns (upper, middle, lower) arrays.
#[pyfunction]
#[pyo3(signature = (high, low, close, timeperiod = 20, atr_period = 10, multiplier = 2.0))]
pub fn keltner_channels<'py>(
@@ -484,22 +156,9 @@ pub fn keltner_channels<'py>(
let h = high.as_slice()?;
let lo = low.as_slice()?;
let c = close.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?;
let middle = compute_ema_ta(c, timeperiod);
let atr = compute_atr(h, lo, c, atr_period);
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && !atr[i].is_nan() {
let band = multiplier * atr[i];
upper[i] = middle[i] + band;
lower[i] = middle[i] - band;
}
}
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?;
let (upper, middle, lower) =
ferro_ta_core::extended::keltner_channels(h, lo, c, timeperiod, atr_period, multiplier);
Ok((
upper.into_pyarray(py),
middle.into_pyarray(py),
@@ -511,9 +170,6 @@ pub fn keltner_channels<'py>(
// HULL_MA
// ---------------------------------------------------------------------------
/// Hull Moving Average (HMA).
///
/// Formula: HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))
#[pyfunction]
#[pyo3(signature = (close, timeperiod = 16))]
pub fn hull_ma<'py>(
@@ -523,43 +179,14 @@ pub fn hull_ma<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let c = close.as_slice()?;
let n = c.len();
let half = (timeperiod / 2).max(1);
let sqrt_p = ((timeperiod as f64).sqrt().round() as usize).max(1);
let wma_full = compute_wma(c, timeperiod);
let wma_half = compute_wma(c, half);
// raw = 2 * wma_half - wma_full
let mut raw = vec![f64::NAN; n];
for i in 0..n {
if !wma_full[i].is_nan() && !wma_half[i].is_nan() {
raw[i] = 2.0 * wma_half[i] - wma_full[i];
}
}
// Find first valid index in raw
let first_valid = raw.iter().position(|x| !x.is_nan()).unwrap_or(n);
let mut hull = vec![f64::NAN; n];
if first_valid < n {
let raw_valid = &raw[first_valid..];
let hma_slice = compute_wma(raw_valid, sqrt_p);
for (k, &v) in hma_slice.iter().enumerate() {
hull[first_valid + k] = v;
}
}
Ok(hull.into_pyarray(py))
let result = ferro_ta_core::extended::hull_ma(c, timeperiod);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// CHANDELIER_EXIT
// ---------------------------------------------------------------------------
/// Chandelier Exit — ATR-based trailing stop levels.
///
/// Returns (long_exit, short_exit) arrays.
#[pyfunction]
#[pyo3(signature = (high, low, close, timeperiod = 22, multiplier = 3.0))]
pub fn chandelier_exit<'py>(
@@ -574,56 +201,9 @@ pub fn chandelier_exit<'py>(
let h = high.as_slice()?;
let lo = low.as_slice()?;
let c = close.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?;
let atr = compute_atr(h, lo, c, timeperiod);
// Rolling max/min using monotonic deques
let mut max_dq: VecDeque<usize> = VecDeque::new();
let mut min_dq: VecDeque<usize> = VecDeque::new();
let mut highest_high = vec![f64::NAN; n];
let mut lowest_low = vec![f64::NAN; n];
for i in 0..n {
while max_dq
.front()
.map(|&j| j + timeperiod <= i)
.unwrap_or(false)
{
max_dq.pop_front();
}
while min_dq
.front()
.map(|&j| j + timeperiod <= i)
.unwrap_or(false)
{
min_dq.pop_front();
}
while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) {
max_dq.pop_back();
}
max_dq.push_back(i);
while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) {
min_dq.pop_back();
}
min_dq.push_back(i);
if i + 1 >= timeperiod {
highest_high[i] = h[*max_dq.front().unwrap()];
lowest_low[i] = lo[*min_dq.front().unwrap()];
}
}
let mut long_exit = vec![f64::NAN; n];
let mut short_exit = vec![f64::NAN; n];
for i in 0..n {
if !highest_high[i].is_nan() && !atr[i].is_nan() {
long_exit[i] = highest_high[i] - multiplier * atr[i];
short_exit[i] = lowest_low[i] + multiplier * atr[i];
}
}
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?;
let (long_exit, short_exit) =
ferro_ta_core::extended::chandelier_exit(h, lo, c, timeperiod, multiplier);
Ok((long_exit.into_pyarray(py), short_exit.into_pyarray(py)))
}
@@ -631,9 +211,6 @@ pub fn chandelier_exit<'py>(
// ICHIMOKU
// ---------------------------------------------------------------------------
/// Ichimoku Cloud (Ichimoku Kinko Hyo).
///
/// Returns (tenkan, kijun, senkou_a, senkou_b, chikou) arrays.
#[pyfunction]
#[pyo3(signature = (high, low, close, tenkan_period = 9, kijun_period = 26, senkou_b_period = 52, displacement = 26))]
pub fn ichimoku<'py>(
@@ -658,62 +235,16 @@ pub fn ichimoku<'py>(
let h = high.as_slice()?;
let lo = low.as_slice()?;
let c = close.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?;
// Helper: rolling (H+L)/2 using monotonic deques
let midpoint_rolling = |period: usize| -> Vec<f64> {
let mut result = vec![f64::NAN; n];
let mut max_dq: VecDeque<usize> = VecDeque::new();
let mut min_dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while max_dq.front().map(|&j| j + period <= i).unwrap_or(false) {
max_dq.pop_front();
}
while min_dq.front().map(|&j| j + period <= i).unwrap_or(false) {
min_dq.pop_front();
}
while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) {
max_dq.pop_back();
}
max_dq.push_back(i);
while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) {
min_dq.pop_back();
}
min_dq.push_back(i);
if i + 1 >= period {
result[i] = (h[*max_dq.front().unwrap()] + lo[*min_dq.front().unwrap()]) / 2.0;
}
}
result
};
let tenkan = midpoint_rolling(tenkan_period);
let kijun = midpoint_rolling(kijun_period);
let raw_b = midpoint_rolling(senkou_b_period);
// Senkou A: (tenkan + kijun) / 2 shifted back `displacement` bars
let mut senkou_a = vec![f64::NAN; n];
if n > displacement {
for i in displacement..n {
if !tenkan[i].is_nan() && !kijun[i].is_nan() {
senkou_a[i - displacement] = (tenkan[i] + kijun[i]) / 2.0;
}
}
}
// Senkou B: raw_b shifted back `displacement` bars
let mut senkou_b = vec![f64::NAN; n];
if n > displacement {
senkou_b[..n - displacement].copy_from_slice(&raw_b[displacement..]);
}
// Chikou: close shifted forward `displacement` bars
let mut chikou = vec![f64::NAN; n];
if n > displacement {
chikou[displacement..].copy_from_slice(&c[..n - displacement]);
}
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?;
let (tenkan, kijun, senkou_a, senkou_b, chikou) = ferro_ta_core::extended::ichimoku(
h,
lo,
c,
tenkan_period,
kijun_period,
senkou_b_period,
displacement,
);
Ok((
tenkan.into_pyarray(py),
kijun.into_pyarray(py),
@@ -727,10 +258,6 @@ pub fn ichimoku<'py>(
// PIVOT_POINTS
// ---------------------------------------------------------------------------
/// Pivot Points — support / resistance levels computed from previous bar.
///
/// method: "classic" | "fibonacci" | "camarilla"
/// Returns (pivot, r1, s1, r2, s2) arrays.
#[pyfunction]
#[pyo3(signature = (high, low, close, method = "classic"))]
pub fn pivot_points<'py>(
@@ -749,51 +276,17 @@ pub fn pivot_points<'py>(
let h = high.as_slice()?;
let lo = low.as_slice()?;
let c = close.as_slice()?;
let n = h.len();
validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?;
let mut pivot = vec![f64::NAN; n];
let mut r1 = vec![f64::NAN; n];
let mut s1 = vec![f64::NAN; n];
let mut r2 = vec![f64::NAN; n];
let mut s2 = vec![f64::NAN; n];
validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?;
let method_lower = method.to_lowercase();
for i in 1..n {
let ph = h[i - 1];
let pl = lo[i - 1];
let pc = c[i - 1];
let hl = ph - pl;
let p = (ph + pl + pc) / 3.0;
pivot[i] = p;
match method_lower.as_str() {
"classic" => {
r1[i] = 2.0 * p - pl;
s1[i] = 2.0 * p - ph;
r2[i] = p + hl;
s2[i] = p - hl;
}
"fibonacci" => {
r1[i] = p + 0.382 * hl;
s1[i] = p - 0.382 * hl;
r2[i] = p + 0.618 * hl;
s2[i] = p - 0.618 * hl;
}
"camarilla" => {
r1[i] = pc + 1.1 * hl / 12.0;
s1[i] = pc - 1.1 * hl / 12.0;
r2[i] = pc + 1.1 * hl / 6.0;
s2[i] = pc - 1.1 * hl / 6.0;
}
_ => {
return Err(PyValueError::new_err(format!(
"Unknown pivot method '{}'. Use 'classic', 'fibonacci', or 'camarilla'.",
method
)));
}
}
if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") {
return Err(PyValueError::new_err(format!(
"Unknown pivot method '{}'. Use 'classic', 'fibonacci', or 'camarilla'.",
method
)));
}
let (pivot, r1, s1, r2, s2) = ferro_ta_core::extended::pivot_points(h, lo, c, method);
Ok((
pivot.into_pyarray(py),
r1.into_pyarray(py),
+8 -129
View File
@@ -1,26 +1,10 @@
//! Rust rolling math operators — O(n) sliding window using monotonic deques.
//!
//! Functions exposed to Python:
//! rolling_sum — Rolling sum over `timeperiod` bars
//! rolling_max — Rolling maximum (O(n) via monotonic deque)
//! rolling_min — Rolling minimum (O(n) via monotonic deque)
//! rolling_maxindex — Index of rolling maximum
//! rolling_minindex — Index of rolling minimum
use std::collections::VecDeque;
//! Rolling math operators (thin PyO3 wrapper over ferro_ta_core::math_ops).
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// rolling_sum
// ---------------------------------------------------------------------------
/// Rolling sum over `timeperiod` bars.
///
/// Uses a prefix-sum array for O(n) computation.
/// Leading `timeperiod - 1` values are NaN.
#[pyfunction]
#[pyo3(signature = (real, timeperiod = 30))]
pub fn rolling_sum<'py>(
@@ -30,29 +14,11 @@ pub fn rolling_sum<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = real.as_slice()?;
let n = prices.len();
let mut result = vec![f64::NAN; n];
if n < timeperiod {
return Ok(result.into_pyarray(py));
}
// Prefix sum
let mut cs = vec![0.0f64; n + 1];
for i in 0..n {
cs[i + 1] = cs[i] + prices[i];
}
for i in (timeperiod - 1)..n {
result[i] = cs[i + 1] - cs[i + 1 - timeperiod];
}
let result = ferro_ta_core::math_ops::rolling_sum(prices, timeperiod);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// rolling_max
// ---------------------------------------------------------------------------
/// Rolling maximum over `timeperiod` bars (O(n) monotonic deque).
///
/// Leading `timeperiod - 1` values are NaN.
#[pyfunction]
#[pyo3(signature = (real, timeperiod = 30))]
pub fn rolling_max<'py>(
@@ -62,34 +28,11 @@ pub fn rolling_max<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = real.as_slice()?;
let n = prices.len();
let mut result = vec![f64::NAN; n];
let mut dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
// Remove indices out of the window
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
dq.pop_front();
}
// Maintain decreasing deque
while dq.back().map(|&j| prices[j] <= prices[i]).unwrap_or(false) {
dq.pop_back();
}
dq.push_back(i);
if i + 1 >= timeperiod {
result[i] = prices[*dq.front().unwrap()];
}
}
let result = ferro_ta_core::math_ops::rolling_max(prices, timeperiod);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// rolling_min
// ---------------------------------------------------------------------------
/// Rolling minimum over `timeperiod` bars (O(n) monotonic deque).
///
/// Leading `timeperiod - 1` values are NaN.
#[pyfunction]
#[pyo3(signature = (real, timeperiod = 30))]
pub fn rolling_min<'py>(
@@ -99,34 +42,11 @@ pub fn rolling_min<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = real.as_slice()?;
let n = prices.len();
let mut result = vec![f64::NAN; n];
let mut dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
dq.pop_front();
}
// Maintain increasing deque
while dq.back().map(|&j| prices[j] >= prices[i]).unwrap_or(false) {
dq.pop_back();
}
dq.push_back(i);
if i + 1 >= timeperiod {
result[i] = prices[*dq.front().unwrap()];
}
}
let result = ferro_ta_core::math_ops::rolling_min(prices, timeperiod);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// rolling_maxindex
// ---------------------------------------------------------------------------
/// Index of rolling maximum over `timeperiod` bars (O(n) monotonic deque).
///
/// Returns the 0-based index into the input array. During the warmup window
/// the value is `-1` (not valid — mask with warmup period if needed).
/// Index of rolling maximum over `timeperiod` bars.
#[pyfunction]
#[pyo3(signature = (real, timeperiod = 30))]
pub fn rolling_maxindex<'py>(
@@ -136,33 +56,11 @@ pub fn rolling_maxindex<'py>(
) -> PyResult<Bound<'py, PyArray1<i64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = real.as_slice()?;
let n = prices.len();
let mut result = vec![-1i64; n];
let mut dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
dq.pop_front();
}
while dq.back().map(|&j| prices[j] <= prices[i]).unwrap_or(false) {
dq.pop_back();
}
dq.push_back(i);
if i + 1 >= timeperiod {
result[i] = *dq.front().unwrap() as i64;
}
}
let result = ferro_ta_core::math_ops::rolling_maxindex(prices, timeperiod);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// rolling_minindex
// ---------------------------------------------------------------------------
/// Index of rolling minimum over `timeperiod` bars (O(n) monotonic deque).
///
/// Returns the 0-based index into the input array. During the warmup window
/// the value is `-1` (not valid — mask with warmup period if needed).
/// Index of rolling minimum over `timeperiod` bars.
#[pyfunction]
#[pyo3(signature = (real, timeperiod = 30))]
pub fn rolling_minindex<'py>(
@@ -172,29 +70,10 @@ pub fn rolling_minindex<'py>(
) -> PyResult<Bound<'py, PyArray1<i64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = real.as_slice()?;
let n = prices.len();
let mut result = vec![-1i64; n];
let mut dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
dq.pop_front();
}
while dq.back().map(|&j| prices[j] >= prices[i]).unwrap_or(false) {
dq.pop_back();
}
dq.push_back(i);
if i + 1 >= timeperiod {
result[i] = *dq.front().unwrap() as i64;
}
}
let result = ferro_ta_core::math_ops::rolling_minindex(prices, timeperiod);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(rolling_sum, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(rolling_max, m)?)?;
+45
View File
@@ -5,6 +5,16 @@ use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
/// Six-tuple of bound PyArray1 vectors (PLUS_DM, MINUS_DM, +DI, -DI, DX, ADX).
type AdxAllResult<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
);
/// Plus Directional Movement (Wilder smoothing).
#[pyfunction]
#[pyo3(signature = (high, low, timeperiod = 14))]
@@ -158,3 +168,38 @@ pub fn adxr<'py>(
let result = ferro_ta_core::momentum::adxr(highs, lows, closes, timeperiod);
Ok(result.into_pyarray(py))
}
/// Compute all six ADX-family outputs in a single TR/PDM/MDM pass.
///
/// Returns (plus_dm, minus_dm, plus_di, minus_di, dx, adx) — six arrays of
/// the same length as the inputs. Use this when you need more than one ADX
/// family output to avoid redundant computation.
#[pyfunction]
#[pyo3(signature = (high, low, close, timeperiod = 14))]
pub fn adx_all<'py>(
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
timeperiod: usize,
) -> PyResult<AdxAllResult<'py>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
validation::validate_equal_length(&[
(highs.len(), "high"),
(lows.len(), "low"),
(closes.len(), "close"),
])?;
let (pdm, mdm, pdi, mdi, dx, adx) =
ferro_ta_core::momentum::adx_all(highs, lows, closes, timeperiod);
Ok((
pdm.into_pyarray(py),
mdm.into_pyarray(py),
pdi.into_pyarray(py),
mdi.into_pyarray(py),
dx.into_pyarray(py),
adx.into_pyarray(py),
))
}
+1
View File
@@ -47,6 +47,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(self::adx::dx, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::adx::adx, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::adx::adxr, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::adx::adx_all, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::trix::trix, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::ultosc::ultosc, m)?)?;
Ok(())
+5 -30
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl2crows<'py>(
py: Python<'py>,
@@ -11,33 +9,10 @@ pub fn cdl2crows<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, _h1, _l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, _h3, _l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
// Two Crows:
// 1. First candle is a long white (bullish) candle
// 2. Second candle gaps up (opens above first close) and closes lower but still above first close
// 3. Third candle opens within second body and closes within first body
if is_bullish(o1, c1)
&& is_bearish(o2, c2)
&& o2 > c1 // gap up
&& c2 > c1 // second still closes above first close
&& is_bearish(o3, c3)
&& o3 < o2 && o3 > c2 // opens within second body
&& c3 > o1 && c3 < c1
// closes within first body
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl2crows(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -54
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl3blackcrows<'py>(
py: Python<'py>,
@@ -11,57 +9,10 @@ pub fn cdl3blackcrows<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, h2, l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let body3 = body_size(o3, c3);
let range1 = candle_range(h1, l1);
let range2 = candle_range(h2, l2);
let range3 = candle_range(h3, l3);
// All three candles must be bearish with large bodies
let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5;
let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5;
// Each opens within the previous candle's body and closes lower
let open2_in_body1 = o2 < o1 && o2 > c1;
let open3_in_body2 = o3 < o2 && o3 > c2;
// Small upper shadows (closes near the low)
let small_upper1 = upper_shadow(o1, h1, c1) <= body1 * 0.3;
let small_upper2 = upper_shadow(o2, h2, c2) <= body2 * 0.3;
let small_upper3 = upper_shadow(o3, h3, c3) <= body3 * 0.3;
if is_bearish(o1, c1)
&& is_bearish(o2, c2)
&& is_bearish(o3, c3)
&& long_body1
&& long_body2
&& long_body3
&& open2_in_body1
&& open3_in_body2
&& small_upper1
&& small_upper2
&& small_upper3
&& c2 < c1
&& c3 < c2
&& l3 < l2
&& l2 < l1
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl3blackcrows(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -36
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl3inside<'py>(
py: Python<'py>,
@@ -11,39 +9,10 @@ pub fn cdl3inside<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (_o3, _h3, _l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let range1 = candle_range(h1, l1);
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5;
// Candle 2 body is inside candle 1 body (harami condition)
let body2_high = o2.max(c2);
let body2_low = o2.min(c2);
let body1_high = o1.max(c1);
let body1_low = o1.min(c1);
let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.5;
// Three Inside Up: C1 bearish, C2 bullish harami, C3 closes above C2 close
if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) && c3 > c2 {
result[i] = 100;
}
// Three Inside Down: C1 bullish, C2 bearish harami, C3 closes below C2 close
else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) && c3 < c2 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl3inside(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -36
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl3linestrike<'py>(
py: Python<'py>,
@@ -11,39 +9,10 @@ pub fn cdl3linestrike<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 3..n {
let (o0, c0) = (opens[i - 3], closes[i - 3]);
let (o1, c1) = (opens[i - 2], closes[i - 2]);
let (o2, c2) = (opens[i - 1], closes[i - 1]);
let (o3, c3) = (opens[i], closes[i]);
if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& is_bearish(o2, c2)
&& c1 < c0
&& c2 < c1
&& is_bullish(o3, c3)
&& o3 < c2
&& c3 > o0
{
result[i] = 100;
} else if is_bullish(o0, c0)
&& is_bullish(o1, c1)
&& is_bullish(o2, c2)
&& c1 > c0
&& c2 > c1
&& is_bearish(o3, c3)
&& o3 > c2
&& c3 < o0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl3linestrike(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -31
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl3outside<'py>(
py: Python<'py>,
@@ -11,34 +9,10 @@ pub fn cdl3outside<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, _h1, _l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (_o3, _h3, _l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1_high = o1.max(c1);
let body1_low = o1.min(c1);
let body2_high = o2.max(c2);
let body2_low = o2.min(c2);
// Engulfing: candle 2 body completely covers candle 1 body
let engulfs = body2_high > body1_high && body2_low < body1_low;
// Three Outside Up: C1 bearish, C2 bullish engulfing, C3 closes above C2
if is_bearish(o1, c1) && is_bullish(o2, c2) && engulfs && c3 > c2 {
result[i] = 100;
}
// Three Outside Down: C1 bullish, C2 bearish engulfing, C3 closes below C2
else if is_bullish(o1, c1) && is_bearish(o2, c2) && engulfs && c3 < c2 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl3outside(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -26
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl3starsinsouth<'py>(
py: Python<'py>,
@@ -11,29 +9,10 @@ pub fn cdl3starsinsouth<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& is_bearish(o2, c2)
&& h1 <= h0
&& l1 >= l0
&& h2 <= h1
&& l2 >= l1
&& body_size(o2, c2) <= body_size(o1, c1) * 0.6
&& upper_shadow(o2, h2, c2) <= body_size(o2, c2) * 0.2
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl3starsinsouth(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -51
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdl3whitesoldiers<'py>(
py: Python<'py>,
@@ -11,54 +9,10 @@ pub fn cdl3whitesoldiers<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, h2, l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let body3 = body_size(o3, c3);
let range1 = candle_range(h1, l1);
let range2 = candle_range(h2, l2);
let range3 = candle_range(h3, l3);
let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5;
let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5;
let open2_in_body1 = o2 > o1 && o2 < c1;
let open3_in_body2 = o3 > o2 && o3 < c2;
let small_lower1 = lower_shadow(o1, l1, c1) <= body1 * 0.3;
let small_lower2 = lower_shadow(o2, l2, c2) <= body2 * 0.3;
let small_lower3 = lower_shadow(o3, l3, c3) <= body3 * 0.3;
if is_bullish(o1, c1)
&& is_bullish(o2, c2)
&& is_bullish(o3, c3)
&& long_body1
&& long_body2
&& long_body3
&& open2_in_body1
&& open3_in_body2
&& small_lower1
&& small_lower2
&& small_lower3
&& c2 > c1
&& c3 > c2
&& h3 > h2
&& h2 > h1
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdl3whitesoldiers(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -44
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlabandonedbaby<'py>(
py: Python<'py>,
@@ -11,47 +9,10 @@ pub fn cdlabandonedbaby<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let range2 = candle_range(h2, l2);
let body0 = body_size(o0, c0);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let is_doji1 = range1 > 0.0 && body1 / range1 <= 0.1;
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.5
&& is_doji1
&& h1 < l0
&& is_bullish(o2, c2)
&& range2 > 0.0
&& body2 >= range2 * 0.5
&& l2 > h1
{
result[i] = 100;
} else if is_bullish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.5
&& is_doji1
&& l1 > h0
&& is_bearish(o2, c2)
&& range2 > 0.0
&& body2 >= range2 * 0.5
&& h2 < l1
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlabandonedbaby(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -33
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdladvanceblock<'py>(
py: Python<'py>,
@@ -11,36 +9,10 @@ pub fn cdladvanceblock<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let body0 = body_size(o0, c0);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let us0 = upper_shadow(o0, h0, c0);
let us1 = upper_shadow(o1, h1, c1);
let us2 = upper_shadow(o2, h2, c2);
if is_bullish(o0, c0)
&& is_bullish(o1, c1)
&& is_bullish(o2, c2)
&& c1 > c0
&& c2 > c1
&& o1 >= o0
&& o1 <= c0
&& o2 >= o1
&& o2 <= c1
&& (body1 < body0 || body2 < body1 || us2 > us1 || us1 > us0)
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdladvanceblock(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -23
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlbelthold<'py>(
py: Python<'py>,
@@ -11,26 +9,10 @@ pub fn cdlbelthold<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
let body = body_size(o, c);
if range == 0.0 {
continue;
}
let long_body = body >= range * 0.6;
if is_bullish(o, c) && long_body && (o - l).abs() <= range * 0.01 {
result[i] = 100;
} else if is_bearish(o, c) && long_body && (h - o).abs() <= range * 0.01 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlbelthold(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -43
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlbreakaway<'py>(
py: Python<'py>,
@@ -11,46 +9,10 @@ pub fn cdlbreakaway<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 4..n {
let (o0, h0, l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]);
let c1 = closes[i - 3];
let c2 = closes[i - 2];
let c3 = closes[i - 1];
let _l3 = lows[i - 1];
let _h3 = highs[i - 1];
let (o4, c4) = (opens[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& c1 < l0
&& c2 < c1
&& c3 < c2
&& is_bullish(o4, c4)
&& c4 > c1
&& c4 < c0
{
result[i] = 100;
} else if is_bullish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& c1 > h0
&& c2 > c1
&& c3 > c2
&& is_bearish(o4, c4)
&& c4 < c1
&& c4 > c0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlbreakaway(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -25
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlclosingmarubozu<'py>(
py: Python<'py>,
@@ -11,28 +9,10 @@ pub fn cdlclosingmarubozu<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
if body < range * 0.4 {
continue;
}
if is_bullish(o, c) && (h - c).abs() <= range * 0.01 {
result[i] = 100;
} else if is_bearish(o, c) && (c - l).abs() <= range * 0.01 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlclosingmarubozu(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -38
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlconcealbabyswall<'py>(
py: Python<'py>,
@@ -11,41 +9,10 @@ pub fn cdlconcealbabyswall<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 3..n {
let (o0, h0, l0, c0) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]);
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, h2, l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let maru0 = range0 > 0.0
&& upper_shadow(o0, h0, c0) <= range0 * 0.02
&& lower_shadow(o0, l0, c0) <= range0 * 0.02;
let maru1 = range1 > 0.0
&& upper_shadow(o1, h1, c1) <= range1 * 0.02
&& lower_shadow(o1, l1, c1) <= range1 * 0.02;
let gap_down = o2 < c1;
let shadow_into = h2 >= c1;
let engulfs = o3 >= o2 && c3 <= c2 && h3 >= h2 && l3 <= l2;
if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& maru0
&& maru1
&& is_bearish(o2, c2)
&& gap_down
&& shadow_into
&& is_bearish(o3, c3)
&& engulfs
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlconcealbabyswall(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -25
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlcounterattack<'py>(
py: Python<'py>,
@@ -11,28 +9,10 @@ pub fn cdlcounterattack<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let body0 = body_size(o0, c0);
let body1 = body_size(o1, c1);
let long0 = range0 > 0.0 && body0 >= range0 * 0.5;
let long1 = range1 > 0.0 && body1 >= range1 * 0.5;
let same_close = (c1 - c0).abs() <= range0 * 0.02;
if is_bearish(o0, c0) && long0 && is_bullish(o1, c1) && long1 && same_close {
result[i] = 100;
} else if is_bullish(o0, c0) && long0 && is_bearish(o1, c1) && long1 && same_close {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlcounterattack(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -26
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdldarkcloudcover<'py>(
py: Python<'py>,
@@ -11,29 +9,10 @@ pub fn cdldarkcloudcover<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let body0 = body_size(o0, c0);
let range0 = candle_range(h0, l0);
let midpoint0 = (o0 + c0) / 2.0;
if is_bullish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.5
&& is_bearish(o1, c1)
&& o1 > h0
&& c1 < midpoint0
&& c1 > o0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdldarkcloudcover(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -17
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdldoji<'py>(
py: Python<'py>,
@@ -11,20 +9,10 @@ pub fn cdldoji<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let body = body_size(opens[i], closes[i]);
let range = candle_range(highs[i], lows[i]);
// Doji: body is very small relative to range
if range > 0.0 && body / range <= 0.1 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdldoji(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -33
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdldojistar<'py>(
py: Python<'py>,
@@ -11,36 +9,10 @@ pub fn cdldojistar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let range1 = candle_range(h1, l1);
let range2 = candle_range(h2, l2);
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
// Doji: body <= 10% of range
let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1;
// Bullish Doji Star: prior bearish large candle, doji opens/closes below prior low
let gap_down = o2.max(c2) < l1;
if is_bearish(o1, c1) && large_body1 && is_doji2 && gap_down {
result[i] = 100;
}
// Bearish Doji Star: prior bullish large candle, doji opens/closes above prior high
let gap_up = o2.min(c2) > h1;
if is_bullish(o1, c1) && large_body1 && is_doji2 && gap_up {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdldojistar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -22
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdldragonflydoji<'py>(
py: Python<'py>,
@@ -11,25 +9,10 @@ pub fn cdldragonflydoji<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
if body / range <= 0.1 && us / range <= 0.1 && ls >= range * 0.6 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdldragonflydoji(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -37
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlengulfing<'py>(
py: Python<'py>,
@@ -11,40 +9,10 @@ pub fn cdlengulfing<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let prev_o = opens[i - 1];
let prev_c = closes[i - 1];
let curr_o = opens[i];
let curr_c = closes[i];
let prev_body_high = prev_o.max(prev_c);
let prev_body_low = prev_o.min(prev_c);
let curr_body_high = curr_o.max(curr_c);
let curr_body_low = curr_o.min(curr_c);
// Bullish engulfing: prev is bearish, current is bullish and engulfs
if is_bearish(prev_o, prev_c)
&& is_bullish(curr_o, curr_c)
&& curr_body_high > prev_body_high
&& curr_body_low < prev_body_low
{
result[i] = 100;
}
// Bearish engulfing: prev is bullish, current is bearish and engulfs
else if is_bullish(prev_o, prev_c)
&& is_bearish(curr_o, curr_c)
&& curr_body_high > prev_body_high
&& curr_body_low < prev_body_low
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlengulfing(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -35
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdleveningdojistar<'py>(
py: Python<'py>,
@@ -11,38 +9,10 @@ pub fn cdleveningdojistar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let body3 = body_size(o3, c3);
let range1 = candle_range(h1, l1);
let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2));
let range3 = candle_range(h3, l3);
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1;
let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6;
if is_bullish(o1, c1)
&& large_body1
&& is_doji2
&& is_bearish(o3, c3)
&& large_body3
&& c3 < (o1 + c1) / 2.0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdleveningdojistar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -38
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdleveningstar<'py>(
py: Python<'py>,
@@ -11,41 +9,10 @@ pub fn cdleveningstar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let body3 = body_size(o3, c3);
let range1 = candle_range(h1, l1);
let range3 = candle_range(h3, l3);
// Evening star conditions:
// 1. First candle is a large bullish candle
// 2. Second candle is a star (small body) gapping above first
// 3. Third candle is a large bearish candle
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
let small_body2 = range1 > 0.0 && body2 < body1 * 0.3;
let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6;
if is_bullish(o1, c1)
&& large_body1
&& small_body2
&& is_bearish(o3, c3)
&& large_body3
&& c3 < (o1 + c1) / 2.0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdleveningstar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlgapsidesidewhite<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlgapsidesidewhite<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, _h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let both_bullish = is_bullish(o1, c1) && is_bullish(o2, c2);
let similar_size = body1 > 0.0 && (body2 - body1).abs() / body1 <= 0.3;
let similar_open = body1 > 0.0 && (o2 - o1).abs() / body1 <= 0.3;
if is_bullish(o0, c0) && both_bullish && similar_size && similar_open && o1 > c0 {
result[i] = 100;
} else if is_bearish(o0, c0) && both_bullish && similar_size && similar_open && c1 < o0 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlgapsidesidewhite(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -22
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlgravestonedoji<'py>(
py: Python<'py>,
@@ -11,25 +9,10 @@ pub fn cdlgravestonedoji<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
if body / range <= 0.1 && ls / range <= 0.1 && us >= range * 0.6 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlgravestonedoji(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -21
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlhammer<'py>(
py: Python<'py>,
@@ -11,24 +9,10 @@ pub fn cdlhammer<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let body = body_size(opens[i], closes[i]);
let range = candle_range(highs[i], lows[i]);
let lower = lower_shadow(opens[i], lows[i], closes[i]);
let upper = upper_shadow(opens[i], highs[i], closes[i]);
// Hammer: small body (< 1/3 range), long lower shadow (>= 2x body), small upper shadow
if range > 0.0 && body > 0.0 && body <= range / 3.0 && lower >= 2.0 * body && upper <= body
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlhammer(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -22
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlhangingman<'py>(
py: Python<'py>,
@@ -11,25 +9,10 @@ pub fn cdlhangingman<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
if range > 0.0 && body > 0.0 && ls >= body * 2.0 && us <= body && body / range <= 0.4 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlhangingman(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -36
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlharami<'py>(
py: Python<'py>,
@@ -11,39 +9,10 @@ pub fn cdlharami<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let range1 = candle_range(h1, l1);
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5;
// Current candle body is inside prior candle body
let body1_high = o1.max(c1);
let body1_low = o1.min(c1);
let body2_high = o2.max(c2);
let body2_low = o2.min(c2);
let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.6;
// Bullish Harami: prior bearish, current bullish inside
if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) {
result[i] = 100;
}
// Bearish Harami: prior bullish, current bearish inside
else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlharami(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -38
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlharamicross<'py>(
py: Python<'py>,
@@ -11,41 +9,10 @@ pub fn cdlharamicross<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let range1 = candle_range(h1, l1);
let range2 = candle_range(h2, l2);
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5;
// Second candle must be a doji
let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1;
// Doji body must be inside prior body
let body1_high = o1.max(c1);
let body1_low = o1.min(c1);
let doji_mid = (o2 + c2) / 2.0;
let inside = doji_mid <= body1_high && doji_mid >= body1_low;
// Bullish Harami Cross: prior bearish large candle, doji inside
if is_bearish(o1, c1) && large_body1 && is_doji2 && inside {
result[i] = 100;
}
// Bearish Harami Cross: prior bullish large candle, doji inside
else if is_bullish(o1, c1) && large_body1 && is_doji2 && inside {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlharamicross(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -26
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlhighwave<'py>(
py: Python<'py>,
@@ -11,29 +9,10 @@ pub fn cdlhighwave<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
if body / range <= 0.3 && us >= range * 0.3 && ls >= range * 0.3 {
if is_bullish(o, c) {
result[i] = 100;
} else {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlhighwave(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -25
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlhikkake<'py>(
py: Python<'py>,
@@ -11,28 +9,10 @@ pub fn cdlhikkake<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let h1 = highs[i - 1];
let l1 = lows[i - 1];
let h2 = highs[i];
let l2 = lows[i];
let inside = h1 <= h0 && l1 >= l0;
if !inside {
continue;
}
if is_bearish(o0, c0) && h2 > h1 && l2 > l1 {
result[i] = 100;
} else if is_bullish(o0, c0) && l2 < l1 && h2 < h1 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlhikkake(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -27
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlhikkakemod<'py>(
py: Python<'py>,
@@ -11,30 +9,10 @@ pub fn cdlhikkakemod<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 3..n {
let (o0, h0, l0, c0) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]);
let h1 = highs[i - 2];
let l1 = lows[i - 2];
let h2 = highs[i - 1];
let l2 = lows[i - 1];
let h3 = highs[i];
let l3 = lows[i];
let inside = h1 <= h0 && l1 >= l0;
if !inside {
continue;
}
if is_bearish(o0, c0) && l2 < l1 && h3 > h1 && l3 > l1 {
result[i] = 100;
} else if is_bullish(o0, c0) && h2 > h1 && l3 < l1 && h3 < h1 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlhikkakemod(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlhomingpigeon<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlhomingpigeon<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, _h0, _l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let body0_high = o0.max(c0);
let body0_low = o0.min(c0);
let body1_high = o1.max(c1);
let body1_low = o1.min(c1);
if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& body1_high <= body0_high
&& body1_low >= body0_low
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlhomingpigeon(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -31
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlidentical3crows<'py>(
py: Python<'py>,
@@ -11,34 +9,10 @@ pub fn cdlidentical3crows<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let tol0 = range0 * 0.03;
let tol1 = range1 * 0.03;
if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& is_bearish(o2, c2)
&& c1 < c0
&& c2 < c1
&& (o1 - c0).abs() <= tol0
&& (o2 - c1).abs() <= tol1
&& range0 > 0.0
&& range1 > 0.0
&& candle_range(h2, l2) > 0.0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlidentical3crows(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlinneck<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlinneck<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& is_bullish(o1, c1)
&& o1 < l0
&& (c1 - c0).abs() <= range0 * 0.03
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlinneck(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -22
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlinvertedhammer<'py>(
py: Python<'py>,
@@ -11,25 +9,10 @@ pub fn cdlinvertedhammer<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
if body > 0.0 && us >= body * 2.0 && ls <= body && body / range <= 0.4 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlinvertedhammer(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -26
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlkicking<'py>(
py: Python<'py>,
@@ -11,29 +9,10 @@ pub fn cdlkicking<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let maru0 = range0 > 0.0
&& upper_shadow(o0, h0, c0) <= range0 * 0.02
&& lower_shadow(o0, l0, c0) <= range0 * 0.02;
let maru1 = range1 > 0.0
&& upper_shadow(o1, h1, c1) <= range1 * 0.02
&& lower_shadow(o1, l1, c1) <= range1 * 0.02;
if is_bearish(o0, c0) && maru0 && is_bullish(o1, c1) && maru1 && o1 > o0 {
result[i] = 100;
} else if is_bullish(o0, c0) && maru0 && is_bearish(o1, c1) && maru1 && o1 < o0 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlkicking(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -37
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlkickingbylength<'py>(
py: Python<'py>,
@@ -11,40 +9,10 @@ pub fn cdlkickingbylength<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let maru0 = range0 > 0.0
&& upper_shadow(o0, h0, c0) <= range0 * 0.02
&& lower_shadow(o0, l0, c0) <= range0 * 0.02;
let maru1 = range1 > 0.0
&& upper_shadow(o1, h1, c1) <= range1 * 0.02
&& lower_shadow(o1, l1, c1) <= range1 * 0.02;
let opposite = (is_bearish(o0, c0) && is_bullish(o1, c1))
|| (is_bullish(o0, c0) && is_bearish(o1, c1));
let has_gap = (o1 - c0).abs() > 0.0;
if maru0 && maru1 && opposite && has_gap {
if range1 >= range0 {
if is_bullish(o1, c1) {
result[i] = 100;
} else {
result[i] = -100;
}
} else if is_bullish(o0, c0) {
result[i] = 100;
} else {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlkickingbylength(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -27
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlladderbottom<'py>(
py: Python<'py>,
@@ -11,30 +9,10 @@ pub fn cdlladderbottom<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 4..n {
let (o0, _h0, _l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]);
let (o1, _h1, _l1, c1) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]);
let (o2, _h2, _l2, c2) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o3, h3, _l3, c3) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o4, h4, l4, c4) = (opens[i], highs[i], lows[i], closes[i]);
let three_bear = is_bearish(o0, c0) && is_bearish(o1, c1) && is_bearish(o2, c2);
let descend = c1 < c0 && c2 < c1;
let us3 = upper_shadow(o3, h3, c3);
let body3 = body_size(o3, c3);
let inv_hammer = us3 >= body3 * 1.5;
let range4 = candle_range(h4, l4);
let body4 = body_size(o4, c4);
let large_bull = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5;
if three_bear && descend && inv_hammer && large_bull && c4 > c2 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlladderbottom(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -22
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdllongleggeddoji<'py>(
py: Python<'py>,
@@ -11,25 +9,10 @@ pub fn cdllongleggeddoji<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
if body / range <= 0.1 && us >= range * 0.3 && ls >= range * 0.3 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdllongleggeddoji(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdllongline<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdllongline<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
if body >= range * 0.7 {
if is_bullish(o, c) {
result[i] = 100;
} else {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdllongline(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlmarubozu<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlmarubozu<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let body = body_size(opens[i], closes[i]);
let range = candle_range(highs[i], lows[i]);
let lower = lower_shadow(opens[i], lows[i], closes[i]);
let upper = upper_shadow(opens[i], highs[i], closes[i]);
// Marubozu: body is >= 95% of range, tiny or no shadows
if range > 0.0 && body >= range * 0.95 && upper <= range * 0.025 && lower <= range * 0.025 {
if is_bullish(opens[i], closes[i]) {
result[i] = 100;
} else {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlmarubozu(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -18
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlmatchinglow<'py>(
py: Python<'py>,
@@ -11,21 +9,10 @@ pub fn cdlmatchinglow<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let tol = range0 * 0.02;
if is_bearish(o0, c0) && is_bearish(o1, c1) && (c1 - c0).abs() <= tol {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlmatchinglow(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -27
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlmathold<'py>(
py: Python<'py>,
@@ -11,30 +9,10 @@ pub fn cdlmathold<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 4..n {
let (o0, h0, l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]);
let (o1, _h1, l1, c1) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]);
let (o2, _h2, l2, c2) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o3, _h3, l3, c3) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o4, h4, l4, c4) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
let range4 = candle_range(h4, l4);
let body4 = body_size(o4, c4);
let large_bull0 = is_bullish(o0, c0) && range0 > 0.0 && body0 >= range0 * 0.5;
let small_bears = is_bearish(o1, c1) && is_bearish(o2, c2) && is_bearish(o3, c3);
let stay_above = l1 >= o0 && l2 >= o0 && l3 >= o0;
let large_bull4 = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5 && c4 > c0;
if large_bull0 && small_bears && stay_above && large_bull4 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlmathold(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -36
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlmorningdojistar<'py>(
py: Python<'py>,
@@ -11,39 +9,10 @@ pub fn cdlmorningdojistar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let body3 = body_size(o3, c3);
let range1 = candle_range(h1, l1);
let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); // avoid div-by-zero
let range3 = candle_range(h3, l3);
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
// Middle candle must be a doji
let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1;
let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6;
if is_bearish(o1, c1)
&& large_body1
&& is_doji2
&& is_bullish(o3, c3)
&& large_body3
&& c3 > (o1 + c1) / 2.0
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlmorningdojistar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -38
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlmorningstar<'py>(
py: Python<'py>,
@@ -11,41 +9,10 @@ pub fn cdlmorningstar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let body3 = body_size(o3, c3);
let range1 = candle_range(h1, l1);
let range3 = candle_range(h3, l3);
// Morning star conditions:
// 1. First candle is a large bearish candle
// 2. Second candle is a star (small body) gapping below first
// 3. Third candle is a large bullish candle
let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6;
let small_body2 = range1 > 0.0 && body2 < body1 * 0.3;
let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6;
if is_bearish(o1, c1)
&& large_body1
&& small_body2
&& is_bullish(o3, c3)
&& large_body3
&& c3 > (o1 + c1) / 2.0
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlmorningstar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlonneck<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlonneck<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& is_bullish(o1, c1)
&& o1 < l0
&& (c1 - l0).abs() <= range0 * 0.03
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlonneck(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -26
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlpiercing<'py>(
py: Python<'py>,
@@ -11,29 +9,10 @@ pub fn cdlpiercing<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let body0 = body_size(o0, c0);
let range0 = candle_range(h0, l0);
let midpoint0 = (o0 + c0) / 2.0;
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& is_bullish(o1, c1)
&& o1 < l0
&& c1 > midpoint0
&& c1 < o0
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlpiercing(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -27
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlrickshawman<'py>(
py: Python<'py>,
@@ -11,30 +9,10 @@ pub fn cdlrickshawman<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
let us = upper_shadow(o, h, c);
let ls = lower_shadow(o, l, c);
let body_mid = (o + c) / 2.0;
let range_mid = (h + l) / 2.0;
let is_doji = body / range <= 0.1;
let long_shadows = us >= range * 0.3 && ls >= range * 0.3;
let near_center = (body_mid - range_mid).abs() <= range * 0.15;
if is_doji && long_shadows && near_center {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlrickshawman(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -59
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlrisefall3methods<'py>(
py: Python<'py>,
@@ -11,62 +9,10 @@ pub fn cdlrisefall3methods<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 4..n {
let (o0, h0, l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]);
let (o1, h1, l1, c1) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]);
let (o2, h2, l2, c2) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o3, h3, l3, c3) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o4, h4, l4, c4) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
let range4 = candle_range(h4, l4);
let body4 = body_size(o4, c4);
if is_bullish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.5
&& is_bearish(o1, c1)
&& is_bearish(o2, c2)
&& is_bearish(o3, c3)
&& h1 <= h0
&& l1 >= l0
&& h2 <= h0
&& l2 >= l0
&& h3 <= h0
&& l3 >= l0
&& is_bullish(o4, c4)
&& range4 > 0.0
&& body4 >= range4 * 0.5
&& c4 > c0
&& o4 > c3
{
result[i] = 100;
} else if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.5
&& is_bullish(o1, c1)
&& is_bullish(o2, c2)
&& is_bullish(o3, c3)
&& h1 <= h0
&& l1 >= l0
&& h2 <= h0
&& l2 >= l0
&& h3 <= h0
&& l3 >= l0
&& is_bearish(o4, c4)
&& range4 > 0.0
&& body4 >= range4 * 0.5
&& c4 < c0
&& o4 < c3
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlrisefall3methods(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -23
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlseparatinglines<'py>(
py: Python<'py>,
@@ -11,26 +9,10 @@ pub fn cdlseparatinglines<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body1 = body_size(o1, c1);
let range1 = candle_range(h1, l1);
let same_open = range0 > 0.0 && (o1 - o0).abs() <= range0 * 0.02;
let long1 = range1 > 0.0 && body1 >= range1 * 0.5;
if is_bearish(o0, c0) && is_bullish(o1, c1) && same_open && long1 {
result[i] = 100;
} else if is_bullish(o0, c0) && is_bearish(o1, c1) && same_open && long1 {
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlseparatinglines(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -21
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlshootingstar<'py>(
py: Python<'py>,
@@ -11,24 +9,10 @@ pub fn cdlshootingstar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let body = body_size(opens[i], closes[i]);
let range = candle_range(highs[i], lows[i]);
let lower = lower_shadow(opens[i], lows[i], closes[i]);
let upper = upper_shadow(opens[i], highs[i], closes[i]);
// Shooting star: small body, long upper shadow (>= 2x body), small lower shadow
if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper >= 2.0 * body && lower <= body
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlshootingstar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlshortline<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlshortline<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c);
if body > 0.0 && body <= range * 0.3 {
if is_bullish(o, c) {
result[i] = 100;
} else {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlshortline(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -24
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlspinningtop<'py>(
py: Python<'py>,
@@ -11,27 +9,10 @@ pub fn cdlspinningtop<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let body = body_size(opens[i], closes[i]);
let range = candle_range(highs[i], lows[i]);
let lower = lower_shadow(opens[i], lows[i], closes[i]);
let upper = upper_shadow(opens[i], highs[i], closes[i]);
// Spinning top: small body (< 1/3 range), both shadows longer than body
if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper > body && lower > body {
if is_bullish(opens[i], closes[i]) {
result[i] = 100;
} else {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlspinningtop(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -32
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlstalledpattern<'py>(
py: Python<'py>,
@@ -11,35 +9,10 @@ pub fn cdlstalledpattern<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
if is_bullish(o0, c0)
&& is_bullish(o1, c1)
&& is_bullish(o2, c2)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& c1 > c0
&& c2 > c1
&& o1 >= o0
&& o1 <= c0
&& o2 >= c1 * 0.99
&& body2 < body1 * 0.7
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlstalledpattern(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -25
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlsticksandwich<'py>(
py: Python<'py>,
@@ -11,28 +9,10 @@ pub fn cdlsticksandwich<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let tol = range0 * 0.02;
if is_bearish(o0, c0)
&& is_bullish(o1, c1)
&& is_bearish(o2, c2)
&& (c2 - c0).abs() <= tol
&& o1 >= c0
&& c1 <= o0
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlsticksandwich(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -22
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdltakuri<'py>(
py: Python<'py>,
@@ -11,25 +9,10 @@ pub fn cdltakuri<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 0..n {
let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]);
let range = candle_range(h, l);
if range == 0.0 {
continue;
}
let body = body_size(o, c) + DOJI_BODY_EPSILON;
let ls = lower_shadow(o, l, c);
let us = upper_shadow(o, h, c);
if ls >= body * 3.0 && us <= range * 0.1 {
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdltakuri(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -35
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdltasukigap<'py>(
py: Python<'py>,
@@ -11,38 +9,10 @@ pub fn cdltasukigap<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, _h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
if is_bullish(o0, c0)
&& is_bullish(o1, c1)
&& o1 > c0
&& is_bearish(o2, c2)
&& o2 >= l1
&& o2 <= c1
&& c2 > c0
&& c2 < o1
{
result[i] = 100;
} else if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& o1 < c0
&& is_bullish(o2, c2)
&& o2 >= c1
&& o2 <= h1
&& c2 < c0
&& c2 > o1
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdltasukigap(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -26
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlthrusting<'py>(
py: Python<'py>,
@@ -11,29 +9,10 @@ pub fn cdlthrusting<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 1..n {
let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]);
let body0 = body_size(o0, c0);
let range0 = candle_range(h0, l0);
let midpoint0 = (o0 + c0) / 2.0;
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& is_bullish(o1, c1)
&& o1 < l0
&& c1 > c0
&& c1 < midpoint0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlthrusting(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -30
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdltristar<'py>(
py: Python<'py>,
@@ -11,33 +9,10 @@ pub fn cdltristar<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let range1 = candle_range(h1, l1);
let range2 = candle_range(h2, l2);
let body0 = body_size(o0, c0);
let body1 = body_size(o1, c1);
let body2 = body_size(o2, c2);
let doji0 = range0 > 0.0 && body0 / range0 <= 0.1;
let doji1 = range1 > 0.0 && body1 / range1 <= 0.1;
let doji2 = range2 > 0.0 && body2 / range2 <= 0.1;
if doji0 && doji1 && doji2 {
if l1 < l0 && h1 < h0 && c2 > c1 {
result[i] = 100;
} else if l1 > l0 && h1 > h0 && c2 < c1 {
result[i] = -100;
}
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdltristar(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -32
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlunique3river<'py>(
py: Python<'py>,
@@ -11,35 +9,10 @@ pub fn cdlunique3river<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, _h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
let body2 = body_size(o2, c2);
let range2 = candle_range(h2, l2);
if is_bearish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& is_bearish(o1, c1)
&& l1 < l0
&& lower_shadow(o1, l1, c1) > 0.0
&& is_bullish(o2, c2)
&& range2 > 0.0
&& body2 <= range2 * 0.5
&& c2 < c1
&& c2 > l1
{
result[i] = 100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlunique3river(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -28
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlupsidegap2crows<'py>(
py: Python<'py>,
@@ -11,31 +9,10 @@ pub fn cdlupsidegap2crows<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
let range0 = candle_range(h0, l0);
let body0 = body_size(o0, c0);
if is_bullish(o0, c0)
&& range0 > 0.0
&& body0 >= range0 * 0.4
&& is_bearish(o1, c1)
&& o1 > c0
&& is_bearish(o2, c2)
&& o2 > o1
&& c2 < o1
&& c2 > c0
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlupsidegap2crows(o, h, l, c);
Ok(result.into_pyarray(py))
}
+5 -35
View File
@@ -1,8 +1,6 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use super::common::*;
#[pyfunction]
pub fn cdlxsidegap3methods<'py>(
py: Python<'py>,
@@ -11,38 +9,10 @@ pub fn cdlxsidegap3methods<'py>(
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?;
let mut result = vec![0i32; n];
for i in 2..n {
let (o0, _h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]);
let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]);
let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]);
if is_bullish(o0, c0)
&& is_bullish(o1, c1)
&& o1 > c0
&& is_bearish(o2, c2)
&& o2 <= c1
&& o2 >= o1
&& c2 >= c0
&& c2 <= o1
{
result[i] = 100;
} else if is_bearish(o0, c0)
&& is_bearish(o1, c1)
&& o1 < c0
&& is_bullish(o2, c2)
&& o2 >= c1
&& o2 <= o1
&& c2 <= c0
&& c2 >= o1
{
result[i] = -100;
}
}
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let result = ferro_ta_core::pattern::cdlxsidegap3methods(o, h, l, c);
Ok(result.into_pyarray(py))
}
+1 -51
View File
@@ -1,51 +1 @@
//! Shared helpers for candlestick pattern detection.
use pyo3::prelude::PyResult;
/// Validate that open, high, low, close arrays have the same length (for use in CDL* functions).
pub fn validate_ohlc_length(
open_len: usize,
high_len: usize,
low_len: usize,
close_len: usize,
) -> PyResult<()> {
crate::validation::validate_equal_length(&[
(open_len, "open"),
(high_len, "high"),
(low_len, "low"),
(close_len, "close"),
])
}
/// Epsilon for doji-like candles (body ≈ 0) to avoid division by zero.
pub const DOJI_BODY_EPSILON: f64 = 0.0001;
#[inline]
pub fn body_size(open: f64, close: f64) -> f64 {
(close - open).abs()
}
#[inline]
pub fn upper_shadow(open: f64, high: f64, close: f64) -> f64 {
high - open.max(close)
}
#[inline]
pub fn lower_shadow(open: f64, low: f64, close: f64) -> f64 {
open.min(close) - low
}
#[inline]
pub fn candle_range(high: f64, low: f64) -> f64 {
high - low
}
#[inline]
pub fn is_bullish(open: f64, close: f64) -> bool {
close >= open
}
#[inline]
pub fn is_bearish(open: f64, close: f64) -> bool {
close < open
}
// Common helpers are now in ferro_ta_core::pattern. This file is kept for mod declaration.
+27 -141
View File
@@ -1,4 +1,4 @@
//! Portfolio Analytics — Rust implementations.
//! Portfolio Analytics — thin PyO3 wrappers delegating to `ferro_ta_core::portfolio`.
//!
//! Compute-intensive portfolio metrics implemented in Rust:
//! - `portfolio_volatility` — sqrt(w' Σ w) given weights and a covariance matrix
@@ -43,16 +43,11 @@ pub fn portfolio_volatility<'py>(
"cov_matrix must be ({n}, {n}), got ({rows}, {cols})"
)));
}
// variance = w' Σ w
let mut variance = 0.0_f64;
for i in 0..n {
let mut row_sum = 0.0_f64;
for j in 0..n {
row_sum += w[j] * cov[[i, j]];
}
variance += w[i] * row_sum;
}
Ok(variance.max(0.0).sqrt())
// Convert ndarray rows to Vec<Vec<f64>> for core
let cov_rows: Vec<Vec<f64>> = (0..rows)
.map(|i| (0..cols).map(|j| cov[[i, j]]).collect())
.collect();
Ok(ferro_ta_core::portfolio::portfolio_volatility(&cov_rows, w))
}
// ---------------------------------------------------------------------------
@@ -83,22 +78,7 @@ pub fn beta_full<'py>(
"asset_returns and benchmark_returns must have equal length >= 2",
));
}
let mean_a: f64 = a.iter().sum::<f64>() / n as f64;
let mean_b: f64 = b.iter().sum::<f64>() / n as f64;
let mut cov = 0.0_f64;
let mut var_b = 0.0_f64;
for i in 0..n {
let da = a[i] - mean_a;
let db = b[i] - mean_b;
cov += da * db;
var_b += db * db;
}
if var_b == 0.0 {
return Err(PyValueError::new_err(
"benchmark_returns has zero variance; cannot compute beta",
));
}
Ok(cov / var_b)
Ok(ferro_ta_core::portfolio::beta_full(a, b))
}
// ---------------------------------------------------------------------------
@@ -134,23 +114,7 @@ pub fn rolling_beta<'py>(
"asset and benchmark must be non-empty and equal length",
));
}
let mut result = vec![f64::NAN; n];
for i in (window - 1)..n {
let start = i + 1 - window;
let a_win = &a[start..=i];
let b_win = &b[start..=i];
let mean_a: f64 = a_win.iter().sum::<f64>() / window as f64;
let mean_b: f64 = b_win.iter().sum::<f64>() / window as f64;
let mut cov = 0.0_f64;
let mut var_b = 0.0_f64;
for k in 0..window {
let da = a_win[k] - mean_a;
let db = b_win[k] - mean_b;
cov += da * db;
var_b += db * db;
}
result[i] = if var_b == 0.0 { f64::NAN } else { cov / var_b };
}
let result = ferro_ta_core::portfolio::rolling_beta(a, b, window);
Ok(result.into_pyarray(py))
}
@@ -177,27 +141,10 @@ pub fn drawdown_series<'py>(
equity: PyReadonlyArray1<'py, f64>,
) -> PyResult<(Bound<'py, PyArray1<f64>>, f64)> {
let eq = equity.as_slice()?;
let n = eq.len();
if n == 0 {
if eq.is_empty() {
return Err(PyValueError::new_err("equity must be non-empty"));
}
let mut dd = vec![0.0_f64; n];
let mut peak = eq[0];
let mut max_dd = 0.0_f64;
for i in 0..n {
if eq[i] > peak {
peak = eq[i];
}
let d = if peak == 0.0 {
0.0
} else {
(eq[i] - peak) / peak
};
dd[i] = d;
if d < max_dd {
max_dd = d;
}
}
let (dd, max_dd) = ferro_ta_core::portfolio::drawdown_series(eq);
Ok((dd.into_pyarray(py), max_dd))
}
@@ -224,45 +171,18 @@ pub fn correlation_matrix<'py>(
if n_bars < 2 {
return Err(PyValueError::new_err("data must have at least 2 rows"));
}
// Core expects column vectors: data[j][i] = asset j at bar i
let columns: Vec<Vec<f64>> = (0..n_assets)
.map(|j| (0..n_bars).map(|i| arr[[i, j]]).collect())
.collect();
let corr = ferro_ta_core::portfolio::correlation_matrix(&columns);
// Convert Vec<Vec<f64>> back to ndarray::Array2
let mut result = Array2::<f64>::zeros((n_assets, n_assets));
// Compute means
let mut means = vec![0.0_f64; n_assets];
for j in 0..n_assets {
let mut sum = 0.0;
for i in 0..n_bars {
sum += arr[[i, j]];
}
means[j] = sum / n_bars as f64;
}
// Compute std devs and covariances
let mut stds = vec![0.0_f64; n_assets];
for j in 0..n_assets {
let mut var = 0.0;
for i in 0..n_bars {
let d = arr[[i, j]] - means[j];
var += d * d;
}
stds[j] = (var / n_bars as f64).sqrt();
}
for j1 in 0..n_assets {
for j2 in 0..n_assets {
if j1 == j2 {
result[[j1, j2]] = 1.0;
} else {
let mut cov = 0.0;
for i in 0..n_bars {
cov += (arr[[i, j1]] - means[j1]) * (arr[[i, j2]] - means[j2]);
}
cov /= n_bars as f64;
let denom = stds[j1] * stds[j2];
result[[j1, j2]] = if denom == 0.0 { f64::NAN } else { cov / denom };
}
result[[j1, j2]] = corr[j1][j2];
}
}
Ok(result.into_pyarray(py))
}
@@ -297,18 +217,7 @@ pub fn relative_strength<'py>(
"asset_returns and benchmark_returns must be non-empty and equal length",
));
}
let mut result = vec![0.0_f64; n];
let mut cum_a = 1.0_f64;
let mut cum_b = 1.0_f64;
for i in 0..n {
cum_a *= 1.0 + a[i];
cum_b *= 1.0 + b[i];
result[i] = if cum_b == 0.0 {
f64::NAN
} else {
cum_a / cum_b
};
}
let result = ferro_ta_core::portfolio::relative_strength(a, b);
Ok(result.into_pyarray(py))
}
@@ -342,11 +251,7 @@ pub fn spread<'py>(
"a and b must be non-empty and equal length",
));
}
let result: Vec<f64> = av
.iter()
.zip(bv.iter())
.map(|(x, y)| x - hedge * y)
.collect();
let result = ferro_ta_core::portfolio::spread(av, bv, hedge);
Ok(result.into_pyarray(py))
}
@@ -371,11 +276,7 @@ pub fn ratio<'py>(
"a and b must be non-empty and equal length",
));
}
let result: Vec<f64> = av
.iter()
.zip(bv.iter())
.map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y })
.collect();
let result = ferro_ta_core::portfolio::ratio(av, bv);
Ok(result.into_pyarray(py))
}
@@ -406,22 +307,10 @@ pub fn zscore_series<'py>(
return Err(PyValueError::new_err("window must be >= 2"));
}
let xv = x.as_slice()?;
let n = xv.len();
if n == 0 {
if xv.is_empty() {
return Err(PyValueError::new_err("x must be non-empty"));
}
let mut result = vec![f64::NAN; n];
for i in (window - 1)..n {
let win = &xv[i + 1 - window..=i];
let mean: f64 = win.iter().sum::<f64>() / window as f64;
let var: f64 = win.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / window as f64;
let std = var.sqrt();
result[i] = if std == 0.0 {
f64::NAN
} else {
(xv[i] - mean) / std
};
}
let result = ferro_ta_core::portfolio::zscore_series(xv, window);
Ok(result.into_pyarray(py))
}
@@ -455,14 +344,11 @@ pub fn compose_weighted<'py>(
n_sigs
)));
}
let mut result = vec![0.0_f64; n_bars];
for i in 0..n_bars {
let mut s = 0.0;
for j in 0..n_sigs {
s += arr[[i, j]] * w[j];
}
result[i] = s;
}
// Core expects column vectors: data[j][i] = signal j at bar i
let columns: Vec<Vec<f64>> = (0..n_sigs)
.map(|j| (0..n_bars).map(|i| arr[[i, j]]).collect())
.collect();
let result = ferro_ta_core::portfolio::compose_weighted(&columns, w);
Ok(result.into_pyarray(py))
}
+1 -7
View File
@@ -22,12 +22,6 @@ pub fn avgprice<'py>(
(lows.len(), "low"),
(closes.len(), "close"),
])?;
let result: Vec<f64> = opens
.iter()
.zip(highs.iter())
.zip(lows.iter())
.zip(closes.iter())
.map(|(((&o, &h), &l), &c)| (o + h + l + c) / 4.0)
.collect();
let result = ferro_ta_core::price_transform::avgprice(opens, highs, lows, closes);
Ok(result.into_pyarray(py))
}
+1 -5
View File
@@ -13,10 +13,6 @@ pub fn medprice<'py>(
let lows = low.as_slice()?;
let n = highs.len();
validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?;
let result: Vec<f64> = highs
.iter()
.zip(lows.iter())
.map(|(&h, &l)| (h + l) / 2.0)
.collect();
let result = ferro_ta_core::price_transform::medprice(highs, lows);
Ok(result.into_pyarray(py))
}
+1 -6
View File
@@ -19,11 +19,6 @@ pub fn typprice<'py>(
(lows.len(), "low"),
(closes.len(), "close"),
])?;
let result: Vec<f64> = highs
.iter()
.zip(lows.iter())
.zip(closes.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect();
let result = ferro_ta_core::price_transform::typprice(highs, lows, closes);
Ok(result.into_pyarray(py))
}
+1 -6
View File
@@ -19,11 +19,6 @@ pub fn wclprice<'py>(
(lows.len(), "low"),
(closes.len(), "close"),
])?;
let result: Vec<f64> = highs
.iter()
.zip(lows.iter())
.zip(closes.iter())
.map(|((&h, &l), &c)| (h + l + c * 2.0) / 4.0)
.collect();
let result = ferro_ta_core::price_transform::wclprice(highs, lows, closes);
Ok(result.into_pyarray(py))
}
+18 -189
View File
@@ -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)?)?;
+13 -147
View File
@@ -1,18 +1,9 @@
//! Resampling — OHLCV resampling and multi-timeframe helpers.
//!
//! Provides volume-bar resampling and OHLCV aggregation primitives.
//! Time-based resampling (pandas rule strings) is handled in the Python layer;
//! this module provides the compute-heavy parts that benefit from Rust.
//!
//! # Functions
//! - `volume_bars` — Aggregate ticks/bars into bars of fixed volume size.
//! - `ohlcv_agg` — Aggregate an array of OHLCV bars given bar-index labels.
//! Resampling — OHLCV resampling (thin PyO3 wrapper over ferro_ta_core::resampling).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
/// Return type for functions that return five OHLCV 1-D arrays.
type Ohlcv5<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
@@ -21,30 +12,7 @@ type Ohlcv5<'py> = (
Bound<'py, PyArray1<f64>>,
);
// ---------------------------------------------------------------------------
// volume_bars
// ---------------------------------------------------------------------------
/// Aggregate OHLCV data into volume bars of a fixed volume threshold.
///
/// Each output bar accumulates input bars until `volume_threshold` units of
/// volume have been consumed. The resulting bar has:
/// - open = first open of the group
/// - high = max high of the group
/// - low = min low of the group
/// - close = last close of the group
/// - volume = sum of volumes (approximately `volume_threshold`)
///
/// Returns five 1-D arrays: (open, high, low, close, volume).
///
/// Parameters
/// ----------
/// open, high, low, close, volume : 1-D float64 arrays (equal length)
/// volume_threshold : float — target volume per bar (must be > 0)
///
/// Returns
/// -------
/// Tuple of five 1-D float64 arrays (open, high, low, close, volume).
#[pyfunction]
#[pyo3(signature = (open, high, low, close, volume, volume_threshold))]
pub fn volume_bars<'py>(
@@ -70,76 +38,17 @@ pub fn volume_bars<'py>(
"All input arrays must be non-empty and have equal length",
));
}
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut bar_open = o[0];
let mut bar_high = h[0];
let mut bar_low = l[0];
let mut bar_close = c[0];
let mut bar_vol = v[0];
for i in 1..n {
bar_high = bar_high.max(h[i]);
bar_low = bar_low.min(l[i]);
bar_close = c[i];
bar_vol += v[i];
if bar_vol >= volume_threshold {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
// Start new bar
if i + 1 < n {
bar_open = o[i + 1];
bar_high = h[i + 1];
bar_low = l[i + 1];
bar_close = c[i + 1];
bar_vol = v[i + 1];
}
}
}
// Push any remaining partial bar
if bar_vol > 0.0 && out_vol.last().is_none_or(|&last| last != bar_vol) {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
}
let (ro, rh, rl, rc, rv) = ferro_ta_core::resampling::volume_bars(o, h, l, c, v, volume_threshold);
Ok((
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// ohlcv_agg
// ---------------------------------------------------------------------------
/// Aggregate OHLCV bars by integer group labels.
///
/// Given OHLCV arrays and a `labels` array of non-negative integers (same
/// length), groups consecutive bars with the same label and computes:
/// - open = first open of the group
/// - high = max high of the group
/// - low = min low of the group
/// - close = last close of the group
/// - volume = sum of volumes
///
/// `labels` must be non-decreasing (groups are contiguous).
///
/// Returns five 1-D arrays: (open, high, low, close, volume).
#[pyfunction]
#[pyo3(signature = (open, high, low, close, volume, labels))]
pub fn ohlcv_agg<'py>(
@@ -163,59 +72,16 @@ pub fn ohlcv_agg<'py>(
"All input arrays must be non-empty and have equal length",
));
}
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut cur_label = lbl[0];
let mut bar_open = o[0];
let mut bar_high = h[0];
let mut bar_low = l[0];
let mut bar_close = c[0];
let mut bar_vol = v[0];
for i in 1..n {
if lbl[i] != cur_label {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
cur_label = lbl[i];
bar_open = o[i];
bar_high = h[i];
bar_low = l[i];
bar_close = c[i];
bar_vol = v[i];
} else {
bar_high = bar_high.max(h[i]);
bar_low = bar_low.min(l[i]);
bar_close = c[i];
bar_vol += v[i];
}
}
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
let (ro, rh, rl, rc, rv) = ferro_ta_core::resampling::ohlcv_agg(o, h, l, c, v, lbl);
Ok((
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(volume_bars, m)?)?;
m.add_function(wrap_pyfunction!(ohlcv_agg, m)?)?;
+15 -105
View File
@@ -1,75 +1,26 @@
//! Signal processing helpers — Rust implementations.
//!
//! - `rank_series` — cross-sectional rank of a 1-D array (fractional rank)
//! - `top_n_indices` — indices of the N largest values in a 1-D array
//! - `bottom_n_indices` — indices of the N smallest values in a 1-D array
//! Signal processing helpers (thin PyO3 wrapper over ferro_ta_core::signals).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
fn rank_values(xv: &[f64]) -> Vec<f64> {
let n = xv.len();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
xv[a]
.partial_cmp(&xv[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut ranks = vec![0.0_f64; n];
let mut i = 0;
while i < n {
let val = xv[order[i]];
let mut j = i + 1;
while j < n && xv[order[j]] == val {
j += 1;
}
let avg_rank = (i + 1 + j) as f64 / 2.0;
for k in i..j {
ranks[order[k]] = avg_rank;
}
i = j;
}
ranks
}
// ---------------------------------------------------------------------------
// rank_series
// ---------------------------------------------------------------------------
/// Compute the fractional rank of each element (1-based, ascending).
///
/// Ties receive the average of their rank positions (same as pandas default).
///
/// Parameters
/// ----------
/// x : 1-D float64 array
///
/// Returns
/// -------
/// 1-D float64 array — ranks in [1, n]
/// Ties receive the average of their rank positions.
#[pyfunction]
pub fn rank_series<'py>(
py: Python<'py>,
x: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xv = x.as_slice()?;
let n = xv.len();
if n == 0 {
if xv.is_empty() {
return Err(PyValueError::new_err("x must be non-empty"));
}
Ok(rank_values(xv).into_pyarray(py))
let result = ferro_ta_core::signals::rank_values(xv);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// compose_rank
// ---------------------------------------------------------------------------
/// Compute rank-based composite scores for a 2-D signal matrix.
///
/// Each column is ranked independently (ascending, fractional ranks for ties),
/// and the per-row ranks are summed across columns.
/// Each column is ranked independently, per-row ranks are summed.
#[pyfunction]
pub fn compose_rank<'py>(
py: Python<'py>,
@@ -84,34 +35,17 @@ pub fn compose_rank<'py>(
}
let scores = py.allow_threads(|| {
let mut scores = vec![0.0_f64; n_bars];
for sig_idx in 0..n_sigs {
let column: Vec<f64> = arr.column(sig_idx).iter().copied().collect();
let ranks = rank_values(&column);
for (bar_idx, rank) in ranks.into_iter().enumerate() {
scores[bar_idx] += rank;
}
}
scores
let columns: Vec<Vec<f64>> = (0..n_sigs)
.map(|sig_idx| arr.column(sig_idx).iter().copied().collect())
.collect();
let col_refs: Vec<&[f64]> = columns.iter().map(|c| c.as_slice()).collect();
ferro_ta_core::signals::compose_rank(&col_refs)
});
Ok(scores.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// top_n_indices
// ---------------------------------------------------------------------------
/// Return the indices of the N largest values in `x` (unsorted).
///
/// Parameters
/// ----------
/// x : 1-D float64 array
/// n : int — number of top elements to return
///
/// Returns
/// -------
/// 1-D int64 array of length min(n, len(x))
/// Return the indices of the N largest values in `x`.
#[pyfunction]
pub fn top_n_indices<'py>(
py: Python<'py>,
@@ -119,23 +53,11 @@ pub fn top_n_indices<'py>(
n: usize,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let xv = x.as_slice()?;
let len = xv.len();
let k = n.min(len);
let mut order: Vec<usize> = (0..len).collect();
order.sort_by(|&a, &b| {
xv[b]
.partial_cmp(&xv[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
let result: Vec<i64> = order[..k].iter().map(|&i| i as i64).collect();
let result = ferro_ta_core::signals::top_n_indices(xv, n);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// bottom_n_indices
// ---------------------------------------------------------------------------
/// Return the indices of the N smallest values in `x` (unsorted).
/// Return the indices of the N smallest values in `x`.
#[pyfunction]
pub fn bottom_n_indices<'py>(
py: Python<'py>,
@@ -143,22 +65,10 @@ pub fn bottom_n_indices<'py>(
n: usize,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let xv = x.as_slice()?;
let len = xv.len();
let k = n.min(len);
let mut order: Vec<usize> = (0..len).collect();
order.sort_by(|&a, &b| {
xv[a]
.partial_cmp(&xv[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let result: Vec<i64> = order[..k].iter().map(|&i| i as i64).collect();
let result = ferro_ta_core::signals::bottom_n_indices(xv, n);
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(rank_series, m)?)?;
m.add_function(wrap_pyfunction!(compose_rank, m)?)?;
+59 -481
View File
@@ -1,128 +1,18 @@
//! Streaming / Incremental Indicators — bar-by-bar stateful classes.
//!
//! All classes are exposed as PyO3 `#[pyclass]` types. Each class:
//! - Accepts one value per call to `update()`.
//! - Returns `NaN` (or a NaN tuple) during the warm-up window.
//! - Exposes a `reset()` method to restart from scratch.
//! - Has a `period` property (where applicable).
//!
//! Internal EMA state is shared via the non-pyclass `EmaState` helper so
//! composite classes (`StreamingMACD`, `StreamingSupertrend`) can hold
//! multiple EMA states without additional allocations.
use std::collections::VecDeque;
//! Thin PyO3 wrappers that delegate to `ferro_ta_core::streaming`.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// Internal helper: EMA state (not a pyclass — used inside composite classes)
// ---------------------------------------------------------------------------
struct EmaState {
period: usize,
alpha: f64,
ema: f64,
seed_buf: Vec<f64>,
seeded: bool,
}
impl EmaState {
fn new(period: usize) -> Self {
Self {
period,
alpha: 2.0 / (period as f64 + 1.0),
ema: 0.0,
seed_buf: Vec::with_capacity(period),
seeded: false,
}
}
fn update(&mut self, value: f64) -> f64 {
if !self.seeded {
self.seed_buf.push(value);
if self.seed_buf.len() < self.period {
return f64::NAN;
}
let seed = self.seed_buf.iter().sum::<f64>() / self.period as f64;
self.ema = seed;
self.seeded = true;
log::debug!(
"EmaState warm-up complete: period={}, seed={seed:.6}",
self.period
);
return seed;
}
self.ema += self.alpha * (value - self.ema);
self.ema
}
fn reset(&mut self) {
self.ema = 0.0;
self.seed_buf.clear();
self.seeded = false;
}
}
use ferro_ta_core::streaming as core;
// ---------------------------------------------------------------------------
// Internal helper: ATR state (Wilder smoothing)
// Helper: convert core StreamingError to PyValueError
// ---------------------------------------------------------------------------
struct AtrState {
period: usize,
prev_close: f64,
tr_buf: Vec<f64>,
atr: f64,
seeded: bool,
has_prev: bool,
}
impl AtrState {
fn new(period: usize) -> Self {
Self {
period,
prev_close: 0.0,
tr_buf: Vec::with_capacity(period),
atr: 0.0,
seeded: false,
has_prev: false,
}
}
fn update(&mut self, high: f64, low: f64, close: f64) -> f64 {
let tr = if self.has_prev {
let hl = high - low;
let hc = (high - self.prev_close).abs();
let lc = (low - self.prev_close).abs();
hl.max(hc).max(lc)
} else {
high - low
};
self.prev_close = close;
self.has_prev = true;
if !self.seeded {
self.tr_buf.push(tr);
if self.tr_buf.len() < self.period {
return f64::NAN;
}
let seed = self.tr_buf.iter().sum::<f64>() / self.period as f64;
self.atr = seed;
self.seeded = true;
return f64::NAN; // first `period` bars (including this one) return NaN
}
let pf = (self.period - 1) as f64;
self.atr = (self.atr * pf + tr) / self.period as f64;
self.atr
}
fn reset(&mut self) {
self.prev_close = 0.0;
self.has_prev = false;
self.tr_buf.clear();
self.atr = 0.0;
self.seeded = false;
}
fn to_py_err(e: core::StreamingError) -> PyErr {
PyValueError::new_err(e.0)
}
// ---------------------------------------------------------------------------
@@ -134,10 +24,7 @@ impl AtrState {
/// Returns NaN during the first `period - 1` bars.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingSMA {
period: usize,
buf: VecDeque<f64>,
running_sum: f64,
count: usize,
inner: core::StreamingSMA,
}
#[pymethods]
@@ -145,48 +32,28 @@ impl StreamingSMA {
#[new]
#[pyo3(signature = (period))]
pub fn new(period: usize) -> PyResult<Self> {
if period < 1 {
return Err(PyValueError::new_err("period must be >= 1"));
}
Ok(Self {
period,
buf: VecDeque::with_capacity(period + 1),
running_sum: 0.0,
count: 0,
inner: core::StreamingSMA::new(period).map_err(to_py_err)?,
})
}
/// Add a new bar and return the current SMA (NaN during warmup).
pub fn update(&mut self, value: f64) -> f64 {
if self.buf.len() == self.period {
if let Some(old) = self.buf.pop_front() {
self.running_sum -= old;
}
}
self.buf.push_back(value);
self.running_sum += value;
self.count += 1;
if self.count < self.period {
f64::NAN
} else {
self.running_sum / self.period as f64
}
self.inner.update(value)
}
/// Reset state to initial condition.
pub fn reset(&mut self) {
self.buf.clear();
self.running_sum = 0.0;
self.count = 0;
self.inner.reset();
}
#[getter]
pub fn period(&self) -> usize {
self.period
self.inner.period()
}
fn __repr__(&self) -> String {
format!("StreamingSMA(period={})", self.period)
format!("StreamingSMA(period={})", self.inner.period())
}
}
@@ -195,13 +62,9 @@ impl StreamingSMA {
// ---------------------------------------------------------------------------
/// Exponential Moving Average with SMA seeding.
///
/// Uses a simple SMA for the first `period` bars to seed the EMA, then
/// switches to the standard EMA formula (alpha = 2 / (period + 1)).
/// Returns NaN during the warmup window.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingEMA {
inner: EmaState,
inner: core::StreamingEMA,
}
#[pymethods]
@@ -209,11 +72,8 @@ impl StreamingEMA {
#[new]
#[pyo3(signature = (period))]
pub fn new(period: usize) -> PyResult<Self> {
if period < 1 {
return Err(PyValueError::new_err("period must be >= 1"));
}
Ok(Self {
inner: EmaState::new(period),
inner: core::StreamingEMA::new(period).map_err(to_py_err)?,
})
}
@@ -228,11 +88,11 @@ impl StreamingEMA {
#[getter]
pub fn period(&self) -> usize {
self.inner.period
self.inner.period()
}
fn __repr__(&self) -> String {
format!("StreamingEMA(period={})", self.inner.period)
format!("StreamingEMA(period={})", self.inner.period())
}
}
@@ -241,18 +101,9 @@ impl StreamingEMA {
// ---------------------------------------------------------------------------
/// Relative Strength Index with TA-Libcompatible Wilder seeding.
///
/// Returns NaN during the first `period` bars.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingRSI {
period: usize,
prev: f64,
has_prev: bool,
gains: Vec<f64>,
losses: Vec<f64>,
avg_gain: f64,
avg_loss: f64,
seeded: bool,
inner: core::StreamingRSI,
}
#[pymethods]
@@ -260,73 +111,27 @@ impl StreamingRSI {
#[new]
#[pyo3(signature = (period = 14))]
pub fn new(period: usize) -> PyResult<Self> {
if period < 1 {
return Err(PyValueError::new_err("period must be >= 1"));
}
Ok(Self {
period,
prev: 0.0,
has_prev: false,
gains: Vec::with_capacity(period),
losses: Vec::with_capacity(period),
avg_gain: 0.0,
avg_loss: 0.0,
seeded: false,
inner: core::StreamingRSI::new(period).map_err(to_py_err)?,
})
}
/// Add a new close and return RSI in [0, 100] (NaN during warmup).
pub fn update(&mut self, value: f64) -> f64 {
if !self.has_prev {
self.prev = value;
self.has_prev = true;
return f64::NAN;
}
let delta = value - self.prev;
self.prev = value;
let gain = if delta > 0.0 { delta } else { 0.0 };
let loss = if delta < 0.0 { -delta } else { 0.0 };
if !self.seeded {
self.gains.push(gain);
self.losses.push(loss);
if self.gains.len() < self.period {
return f64::NAN;
}
self.avg_gain = self.gains.iter().sum::<f64>() / self.period as f64;
self.avg_loss = self.losses.iter().sum::<f64>() / self.period as f64;
self.seeded = true;
log::debug!("StreamingRSI warm-up complete: period={}", self.period);
} else {
let pf = (self.period - 1) as f64;
self.avg_gain = (self.avg_gain * pf + gain) / self.period as f64;
self.avg_loss = (self.avg_loss * pf + loss) / self.period as f64;
}
if self.avg_loss == 0.0 {
return 100.0;
}
let rs = self.avg_gain / self.avg_loss;
100.0 - 100.0 / (1.0 + rs)
self.inner.update(value)
}
pub fn reset(&mut self) {
self.prev = 0.0;
self.has_prev = false;
self.gains.clear();
self.losses.clear();
self.avg_gain = 0.0;
self.avg_loss = 0.0;
self.seeded = false;
self.inner.reset();
}
#[getter]
pub fn period(&self) -> usize {
self.period
self.inner.period()
}
fn __repr__(&self) -> String {
format!("StreamingRSI(period={})", self.period)
format!("StreamingRSI(period={})", self.inner.period())
}
}
@@ -335,12 +140,9 @@ impl StreamingRSI {
// ---------------------------------------------------------------------------
/// Average True Range with TA-Libcompatible Wilder seeding.
///
/// Accepts (high, low, close) per bar.
/// Returns NaN during the first `period` bars.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingATR {
inner: AtrState,
inner: core::StreamingATR,
}
#[pymethods]
@@ -348,11 +150,8 @@ impl StreamingATR {
#[new]
#[pyo3(signature = (period = 14))]
pub fn new(period: usize) -> PyResult<Self> {
if period < 1 {
return Err(PyValueError::new_err("period must be >= 1"));
}
Ok(Self {
inner: AtrState::new(period),
inner: core::StreamingATR::new(period).map_err(to_py_err)?,
})
}
@@ -367,11 +166,11 @@ impl StreamingATR {
#[getter]
pub fn period(&self) -> usize {
self.inner.period
self.inner.period()
}
fn __repr__(&self) -> String {
format!("StreamingATR(period={})", self.inner.period)
format!("StreamingATR(period={})", self.inner.period())
}
}
@@ -379,16 +178,10 @@ impl StreamingATR {
// StreamingBBands
// ---------------------------------------------------------------------------
/// Bollinger Bands — streaming variant.
///
/// Returns (upper, middle, lower) as a Python tuple.
/// NaN tuple during the warmup window.
/// Bollinger Bands — streaming variant using Welford's online algorithm.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingBBands {
period: usize,
nbdevup: f64,
nbdevdn: f64,
buf: VecDeque<f64>,
inner: core::StreamingBBands,
}
#[pymethods]
@@ -396,54 +189,29 @@ impl StreamingBBands {
#[new]
#[pyo3(signature = (period = 20, nbdevup = 2.0, nbdevdn = 2.0))]
pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> PyResult<Self> {
if period < 2 {
return Err(PyValueError::new_err("period must be >= 2"));
}
Ok(Self {
period,
nbdevup,
nbdevdn,
buf: VecDeque::with_capacity(period + 1),
inner: core::StreamingBBands::new(period, nbdevup, nbdevdn).map_err(to_py_err)?,
})
}
/// Add a new bar; return (upper, middle, lower). NaN tuple during warmup.
pub fn update(&mut self, value: f64) -> (f64, f64, f64) {
if self.buf.len() == self.period {
self.buf.pop_front();
}
self.buf.push_back(value);
if self.buf.len() < self.period {
return (f64::NAN, f64::NAN, f64::NAN);
}
let n = self.period as f64;
// Single-pass: compute sum and sum-of-squares simultaneously
let mut sum = 0.0f64;
let mut sum_sq = 0.0f64;
for &x in &self.buf {
sum += x;
sum_sq += x * x;
}
let mean = sum / n;
// Sample variance: (Σx² - n·mean²) / (n-1)
let variance = (sum_sq - n * mean * mean).max(0.0) / (n - 1.0);
let std = variance.sqrt();
(mean + self.nbdevup * std, mean, mean - self.nbdevdn * std)
self.inner.update(value)
}
pub fn reset(&mut self) {
self.buf.clear();
self.inner.reset();
}
#[getter]
pub fn period(&self) -> usize {
self.period
self.inner.period()
}
fn __repr__(&self) -> String {
format!(
"StreamingBBands(period={}, nbdevup={}, nbdevdn={})",
self.period, self.nbdevup, self.nbdevdn
"StreamingBBands(period={})",
self.inner.period()
)
}
}
@@ -453,14 +221,9 @@ impl StreamingBBands {
// ---------------------------------------------------------------------------
/// MACD — fast EMA, slow EMA, signal EMA.
///
/// Returns (macd_line, signal_line, histogram) as a Python tuple.
/// NaN values during warmup.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingMACD {
fast: EmaState,
slow: EmaState,
signal: EmaState,
inner: core::StreamingMACD,
}
#[pymethods]
@@ -468,46 +231,27 @@ impl StreamingMACD {
#[new]
#[pyo3(signature = (fastperiod = 12, slowperiod = 26, signalperiod = 9))]
pub fn new(fastperiod: usize, slowperiod: usize, signalperiod: usize) -> PyResult<Self> {
if fastperiod >= slowperiod {
return Err(PyValueError::new_err("fastperiod must be < slowperiod"));
}
if fastperiod < 1 || signalperiod < 1 {
return Err(PyValueError::new_err("periods must be >= 1"));
}
Ok(Self {
fast: EmaState::new(fastperiod),
slow: EmaState::new(slowperiod),
signal: EmaState::new(signalperiod),
inner: core::StreamingMACD::new(fastperiod, slowperiod, signalperiod)
.map_err(to_py_err)?,
})
}
/// Add a new close; return (macd_line, signal_line, histogram).
pub fn update(&mut self, value: f64) -> (f64, f64, f64) {
let fast_val = self.fast.update(value);
let slow_val = self.slow.update(value);
if slow_val.is_nan() {
return (f64::NAN, f64::NAN, f64::NAN);
}
let macd = fast_val - slow_val;
let signal = self.signal.update(macd);
if signal.is_nan() {
return (macd, f64::NAN, f64::NAN);
}
(macd, signal, macd - signal)
self.inner.update(value)
}
pub fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal.reset();
self.inner.reset();
}
fn __repr__(&self) -> String {
format!(
"StreamingMACD(fastperiod={}, slowperiod={}, signalperiod={})",
self.fast.period, self.slow.period, self.signal.period
self.inner.fast_period(),
self.inner.slow_period(),
self.inner.signal_period()
)
}
}
@@ -517,19 +261,9 @@ impl StreamingMACD {
// ---------------------------------------------------------------------------
/// Slow Stochastic (SMA-smoothed).
///
/// Returns (slowk, slowd) as a Python tuple.
/// NaN tuple during warmup.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingStoch {
fastk_period: usize,
slowk_period: usize,
slowd_period: usize,
high_buf: VecDeque<f64>,
low_buf: VecDeque<f64>,
close_buf: VecDeque<f64>,
fastk_buf: VecDeque<f64>,
slowk_buf: VecDeque<f64>,
inner: core::StreamingStoch,
}
#[pymethods]
@@ -537,83 +271,25 @@ impl StreamingStoch {
#[new]
#[pyo3(signature = (fastk_period = 5, slowk_period = 3, slowd_period = 3))]
pub fn new(fastk_period: usize, slowk_period: usize, slowd_period: usize) -> PyResult<Self> {
if fastk_period < 1 || slowk_period < 1 || slowd_period < 1 {
return Err(PyValueError::new_err("all periods must be >= 1"));
}
Ok(Self {
fastk_period,
slowk_period,
slowd_period,
high_buf: VecDeque::with_capacity(fastk_period + 1),
low_buf: VecDeque::with_capacity(fastk_period + 1),
close_buf: VecDeque::with_capacity(fastk_period + 1),
fastk_buf: VecDeque::with_capacity(slowk_period + 1),
slowk_buf: VecDeque::with_capacity(slowd_period + 1),
inner: core::StreamingStoch::new(fastk_period, slowk_period, slowd_period)
.map_err(to_py_err)?,
})
}
/// Add a new bar (high, low, close); return (slowk, slowd).
pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, f64) {
if self.high_buf.len() == self.fastk_period {
self.high_buf.pop_front();
self.low_buf.pop_front();
self.close_buf.pop_front();
}
self.high_buf.push_back(high);
self.low_buf.push_back(low);
self.close_buf.push_back(close);
if self.high_buf.len() < self.fastk_period {
return (f64::NAN, f64::NAN);
}
let max_h = self
.high_buf
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let min_l = self.low_buf.iter().cloned().fold(f64::INFINITY, f64::min);
let fastk = if max_h != min_l {
100.0 * (close - min_l) / (max_h - min_l)
} else {
0.0
};
if self.fastk_buf.len() == self.slowk_period {
self.fastk_buf.pop_front();
}
self.fastk_buf.push_back(fastk);
if self.fastk_buf.len() < self.slowk_period {
return (f64::NAN, f64::NAN);
}
let slowk = self.fastk_buf.iter().sum::<f64>() / self.slowk_period as f64;
if self.slowk_buf.len() == self.slowd_period {
self.slowk_buf.pop_front();
}
self.slowk_buf.push_back(slowk);
if self.slowk_buf.len() < self.slowd_period {
return (slowk, f64::NAN);
}
let slowd = self.slowk_buf.iter().sum::<f64>() / self.slowd_period as f64;
(slowk, slowd)
self.inner.update(high, low, close)
}
pub fn reset(&mut self) {
self.high_buf.clear();
self.low_buf.clear();
self.close_buf.clear();
self.fastk_buf.clear();
self.slowk_buf.clear();
self.inner.reset();
}
fn __repr__(&self) -> String {
format!(
"StreamingStoch(fastk_period={}, slowk_period={}, slowd_period={})",
self.fastk_period, self.slowk_period, self.slowd_period
"StreamingStoch(fastk_period={})",
self.inner.period()
)
}
}
@@ -623,14 +299,9 @@ impl StreamingStoch {
// ---------------------------------------------------------------------------
/// Cumulative Volume Weighted Average Price.
///
/// Resets automatically when `reset()` is called (e.g. at session open).
/// Accepts (high, low, close, volume) per bar.
#[pyclass(module = "ferro_ta._ferro_ta")]
#[derive(Default)]
pub struct StreamingVWAP {
cum_tpv: f64,
cum_vol: f64,
inner: core::StreamingVWAP,
}
#[pymethods]
@@ -638,27 +309,18 @@ impl StreamingVWAP {
#[new]
pub fn new() -> Self {
Self {
cum_tpv: 0.0,
cum_vol: 0.0,
inner: core::StreamingVWAP::new(),
}
}
/// Add a new bar (high, low, close, volume) and return cumulative VWAP.
pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 {
let tp = (high + low + close) / 3.0;
self.cum_tpv += tp * volume;
self.cum_vol += volume;
if self.cum_vol == 0.0 {
f64::NAN
} else {
self.cum_tpv / self.cum_vol
}
self.inner.update(high, low, close, volume)
}
/// Reset for a new session.
pub fn reset(&mut self) {
self.cum_tpv = 0.0;
self.cum_vol = 0.0;
self.inner.reset();
}
fn __repr__(&self) -> String {
@@ -671,21 +333,9 @@ impl StreamingVWAP {
// ---------------------------------------------------------------------------
/// ATR-based Supertrend — streaming variant.
///
/// Accepts (high, low, close) per bar.
/// Returns (supertrend_line, direction) as a Python tuple.
/// direction: 1 = uptrend, -1 = downtrend, 0 = warmup.
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingSupertrend {
period: usize,
multiplier: f64,
atr: AtrState,
upper_band: f64,
lower_band: f64,
has_bands: bool,
direction: i8,
prev_close: f64,
has_prev: bool,
inner: core::StreamingSupertrend,
}
#[pymethods]
@@ -693,101 +343,29 @@ impl StreamingSupertrend {
#[new]
#[pyo3(signature = (period = 7, multiplier = 3.0))]
pub fn new(period: usize, multiplier: f64) -> PyResult<Self> {
if period < 1 {
return Err(PyValueError::new_err("period must be >= 1"));
}
Ok(Self {
period,
multiplier,
atr: AtrState::new(period),
upper_band: 0.0,
lower_band: 0.0,
has_bands: false,
direction: 0,
prev_close: 0.0,
has_prev: false,
inner: core::StreamingSupertrend::new(period, multiplier).map_err(to_py_err)?,
})
}
/// Add a new bar (high, low, close); return (supertrend_line, direction).
pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, i8) {
let atr = self.atr.update(high, low, close);
if atr.is_nan() {
self.prev_close = close;
self.has_prev = true;
return (f64::NAN, 0);
}
let hl2 = (high + low) / 2.0;
let upper_basic = hl2 + self.multiplier * atr;
let lower_basic = hl2 - self.multiplier * atr;
if !self.has_bands {
self.upper_band = upper_basic;
self.lower_band = lower_basic;
self.has_bands = true;
self.direction = -1;
self.prev_close = close;
self.has_prev = true;
return (self.upper_band, self.direction);
}
let prev_close = self.prev_close;
let new_lower = if lower_basic > self.lower_band || prev_close < self.lower_band {
lower_basic
} else {
self.lower_band
};
let new_upper = if upper_basic < self.upper_band || prev_close > self.upper_band {
upper_basic
} else {
self.upper_band
};
self.lower_band = new_lower;
self.upper_band = new_upper;
self.direction = if self.direction == -1 {
if close > new_upper {
1
} else {
-1
}
} else if close < new_lower {
-1
} else {
1
};
self.prev_close = close;
let line = if self.direction == 1 {
new_lower
} else {
new_upper
};
(line, self.direction)
self.inner.update(high, low, close)
}
pub fn reset(&mut self) {
self.atr.reset();
self.upper_band = 0.0;
self.lower_band = 0.0;
self.has_bands = false;
self.direction = 0;
self.prev_close = 0.0;
self.has_prev = false;
self.inner.reset();
}
#[getter]
pub fn period(&self) -> usize {
self.period
self.inner.period()
}
fn __repr__(&self) -> String {
format!(
"StreamingSupertrend(period={}, multiplier={})",
self.period, self.multiplier
"StreamingSupertrend(period={})",
self.inner.period()
)
}
}
+2 -13
View File
@@ -2,7 +2,7 @@ use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
/// Chaikin Accumulation/Distribution Line. Cumulates (close - low - (high - close)) / (high - low) * volume.
/// Chaikin Accumulation/Distribution Line.
#[pyfunction]
pub fn ad<'py>(
py: Python<'py>,
@@ -22,17 +22,6 @@ pub fn ad<'py>(
(closes.len(), "close"),
(vols.len(), "volume"),
])?;
let mut result = vec![0.0_f64; n];
let mut ad_val = 0.0_f64;
for i in 0..n {
let hl = highs[i] - lows[i];
let clv = if hl != 0.0 {
((closes[i] - lows[i]) - (highs[i] - closes[i])) / hl
} else {
0.0
};
ad_val += clv * vols[i];
result[i] = ad_val;
}
let result = ferro_ta_core::volume::ad(highs, lows, closes, vols);
Ok(result.into_pyarray(py))
}
+1 -31
View File
@@ -2,8 +2,6 @@ use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use ta::indicators::ExponentialMovingAverage;
use ta::Next;
/// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD.
#[pyfunction]
@@ -35,34 +33,6 @@ pub fn adosc<'py>(
(closes.len(), "close"),
(vols.len(), "volume"),
])?;
// Compute raw AD values
let mut ad_vals = vec![0.0_f64; n];
let mut ad_val = 0.0_f64;
for i in 0..n {
let hl = highs[i] - lows[i];
let clv = if hl != 0.0 {
((closes[i] - lows[i]) - (highs[i] - closes[i])) / hl
} else {
0.0
};
ad_val += clv * vols[i];
ad_vals[i] = ad_val;
}
// Apply fast and slow EMA to AD
let mut fast_ema = ExponentialMovingAverage::new(fastperiod)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let mut slow_ema = ExponentialMovingAverage::new(slowperiod)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let warmup = slowperiod - 1;
let mut result = vec![f64::NAN; n];
for (i, &v) in ad_vals.iter().enumerate() {
let fast = fast_ema.next(v);
let slow = slow_ema.next(v);
if i >= warmup {
result[i] = fast - slow;
}
}
let result = ferro_ta_core::volume::adosc(highs, lows, closes, vols, fastperiod, slowperiod);
Ok(result.into_pyarray(py))
}
+2 -11
View File
@@ -2,7 +2,7 @@ use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
/// On Balance Volume: cumulates volume * sign(close - prev_close); bar 0 uses volume.
/// On Balance Volume: cumulates volume * sign(close - prev_close).
#[pyfunction]
pub fn obv<'py>(
py: Python<'py>,
@@ -13,15 +13,6 @@ pub fn obv<'py>(
let vols = volume.as_slice()?;
let n = closes.len();
validation::validate_equal_length(&[(n, "close"), (vols.len(), "volume")])?;
let mut result = vec![0.0_f64; n];
let mut obv_val = 0.0_f64;
for i in 1..n {
if closes[i] > closes[i - 1] {
obv_val += vols[i];
} else if closes[i] < closes[i - 1] {
obv_val -= vols[i];
}
result[i] = obv_val;
}
let result = ferro_ta_core::volume::obv(closes, vols);
Ok(result.into_pyarray(py))
}