Files
ferro-ta/crates/ferro_ta_core/src/statistic.rs
T
Pratik BhadaneandGitHub 288b1546b2 feat: broaden CPU and platform coverage across PyPI, nodes, and crate… (#26)
* feat: broaden CPU and platform coverage across PyPI, nodes, and crates.io

Replace static SIMD with runtime CPU-feature dispatch and expand the release
wheel matrix so one set of artifacts runs on any target CPU and platform
without illegal-instruction crashes.

Rust core:
- Add multiversion runtime dispatch (crates/ferro_ta_core/src/simd.rs); drop
  compile-time `wide`. `simd` feature is now default-on and forwarded through
  the pyo3 crate, and stays compatible with #![forbid(unsafe_code)].

Packaging:
- abi3-py310: one cp310-abi3 wheel per platform (covers CPython 3.10+).
- CI matrix adds Linux aarch64 + musllinux (x86_64/aarch64) and Windows arm64.

Node/Docker + docs:
- api/Dockerfile: document baseline+dispatch (no target-cpu pin) and add a
  fail-fast import check; aarch64 containers now install cleanly.
- Rewrite docs/guides/simd.md; fix stale `wide` mention in ADR 0003.
- Add ADR 0006 (CPU coverage strategy).

Also bundles in-flight release prep already staged in the tree (DTW exception
types, SBOM/provenance security, supporting docs).

* fix(ci): clear cargo-deny and pip-audit failures; apply dependency bumps

cargo-deny (advisories):
- Ignore pyo3 RUSTSEC-2026-0176 / RUSTSEC-2026-0177 in deny.toml with a
  documented rationale: ferro-ta uses neither affected code path
  (PyList/PyTuple nth iterators; PyCFunction::new_closure). Upstream fix
  needs pyo3 >=0.29 (large API migration), tracked as a follow-up.

pip-audit:
- Bump dev lockfile idna 3.18, pytest 9.1.1, urllib3 2.7.0 to clear
  PYSEC-2026-215, CVE-2025-71176, PYSEC-2026-141/142.

Dependency bumps (supersede open dependabot PRs; they auto-close on merge):
- cargo: log 0.4.32, serde_json 1.0.150, rayon 1.12.0
- api/requirements.txt: uvicorn>=0.49.0, pydantic>=2.13.4, ferro-ta>=1.1.4
- CI actions: deploy-pages v5, upload-pages-artifact v5, action-gh-release v3

The open `wide` 1.5.0 bump (PR #24) is obsolete — the crate is removed in
this branch.

* chore: address CodeRabbit review; remove docs/adr section

CodeRabbit findings:
- CI sbom job: add `attestations: write` so attest-build-provenance can run
  (it had only contents:write + id-token:write).
- simd.rs: vectorize `wma_seed` with lane-local accumulators — it was scalar
  behind the multiversion wrapper, adding dispatch overhead for no SIMD gain.
- CHANGELOG: consolidate the duplicate `### Changed` heading.
- python/ferro_ta/__init__.py: also re-export the `FerroTaError` alias.
- docs/guides/dtw.md: soften "byte-for-byte" parity to within-tolerance.

Remove docs/adr/ at maintainer request and clean up the ADR links in the
SIMD and DTW guides. The ADR files remain in commit 9506a30 if ever needed.
2026-06-29 18:21:22 +05:30

493 lines
16 KiB
Rust

//! Statistic functions.
/// Compute the rolling population standard deviation, scaled by `nbdev`.
///
/// Uses population variance (`ddof = 0`). Returns `nbdev * stddev` for
/// each window. The first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// * `nbdev` - Multiplier applied to the standard deviation (use 1.0 for raw stddev).
pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
let n = real.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let window = &real[i + 1 - timeperiod..=i];
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
let var: f64 = window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
result[i] = var.sqrt() * nbdev;
}
result
}
/// Rolling population variance, scaled by `nbdev²`.
pub fn var(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
let n = real.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let window = &real[i + 1 - timeperiod..=i];
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
let variance: f64 =
window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
result[i] = variance * nbdev * nbdev;
}
result
}
// ---------------------------------------------------------------------------
// Linear regression helpers
// ---------------------------------------------------------------------------
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
where
F: FnMut(f64, f64) -> f64,
{
let n = prices.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let period = timeperiod as f64;
let last_x = (timeperiod - 1) as f64;
let sum_x = last_x * period / 2.0;
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
let denom = period * sum_x2 - sum_x * sum_x;
let mut sum_y: f64 = prices[..timeperiod].iter().sum();
let mut sum_xy: f64 = prices[..timeperiod]
.iter()
.enumerate()
.map(|(idx, &v)| idx as f64 * v)
.sum();
for end in (timeperiod - 1)..n {
let slope = if denom != 0.0 {
(period * sum_xy - sum_x * sum_y) / denom
} else {
0.0
};
let intercept = (sum_y - slope * sum_x) / period;
result[end] = map(slope, intercept);
if end + 1 < n {
let outgoing = prices[end + 1 - timeperiod];
let incoming = prices[end + 1];
let prev_sum_y = sum_y;
sum_y = prev_sum_y - outgoing + incoming;
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
}
}
result
}
/// Linear regression fitted value at the last point of the window.
pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec<f64> {
let last_x = if timeperiod > 0 {
(timeperiod - 1) as f64
} else {
0.0
};
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
intercept + slope * last_x
})
}
/// Slope of the rolling linear regression line.
pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |slope, _| slope)
}
/// Intercept of the rolling linear regression line.
pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |_, intercept| intercept)
}
/// Angle of the regression line in degrees.
pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |slope, _| {
slope.atan() * 180.0 / std::f64::consts::PI
})
}
/// Time Series Forecast: linear regression extrapolated one period ahead.
pub fn tsf(close: &[f64], timeperiod: usize) -> Vec<f64> {
let forecast_x = timeperiod as f64;
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
intercept + slope * forecast_x
})
}
// ---------------------------------------------------------------------------
// Beta (rolling, return-based)
// ---------------------------------------------------------------------------
/// Rolling beta: regression of real1 daily returns on real0 daily returns.
pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real0.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n <= timeperiod {
return result;
}
let price_return = |curr: f64, prev: f64| -> f64 {
if prev != 0.0 {
curr / prev - 1.0
} else {
f64::NAN
}
};
let rx: Vec<f64> = real0.windows(2).map(|w| price_return(w[1], w[0])).collect();
let ry: Vec<f64> = real1.windows(2).map(|w| price_return(w[1], w[0])).collect();
let period = timeperiod as f64;
let mut sum_rx = 0.0_f64;
let mut sum_ry = 0.0_f64;
let mut sum_rx2 = 0.0_f64;
let mut sum_rxry = 0.0_f64;
let mut invalid = 0usize;
for idx in 0..timeperiod {
let (ret_x, ret_y) = (rx[idx], ry[idx]);
if ret_x.is_finite() && ret_y.is_finite() {
sum_rx += ret_x;
sum_ry += ret_y;
sum_rx2 += ret_x * ret_x;
sum_rxry += ret_x * ret_y;
} else {
invalid += 1;
}
}
for end in timeperiod..n {
result[end] = if invalid == 0 {
let denom = period * sum_rx2 - sum_rx * sum_rx;
if denom != 0.0 {
(period * sum_rxry - sum_rx * sum_ry) / denom
} else {
f64::NAN
}
} else {
f64::NAN
};
if end + 1 < n {
let out = end - timeperiod;
let (ox, oy) = (rx[out], ry[out]);
if ox.is_finite() && oy.is_finite() {
sum_rx -= ox;
sum_ry -= oy;
sum_rx2 -= ox * ox;
sum_rxry -= ox * oy;
} else {
invalid -= 1;
}
let (ix, iy) = (rx[end], ry[end]);
if ix.is_finite() && iy.is_finite() {
sum_rx += ix;
sum_ry += iy;
sum_rx2 += ix * ix;
sum_rxry += ix * iy;
} else {
invalid += 1;
}
}
}
result
}
// ---------------------------------------------------------------------------
// Correlation (rolling Pearson)
// ---------------------------------------------------------------------------
/// Rolling Pearson correlation coefficient between two series.
pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real0.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let period = timeperiod as f64;
let mut sum_x: f64 = real0[..timeperiod].iter().sum();
let mut sum_y: f64 = real1[..timeperiod].iter().sum();
let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum();
let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum();
let mut sum_xy: f64 = real0[..timeperiod]
.iter()
.zip(real1[..timeperiod].iter())
.map(|(&a, &b)| a * b)
.sum();
#[allow(clippy::needless_range_loop)]
for end in (timeperiod - 1)..n {
let denom_x = period * sum_x2 - sum_x * sum_x;
let denom_y = period * sum_y2 - sum_y * sum_y;
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
} else {
f64::NAN
};
if end + 1 < n {
let out = end + 1 - timeperiod;
let inc = end + 1;
sum_x += real0[inc] - real0[out];
sum_y += real1[inc] - real1[out];
sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out];
sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out];
sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out];
}
}
result
}
// ---------------------------------------------------------------------------
// Dynamic Time Warping (DTW)
// ---------------------------------------------------------------------------
/// Internal helper: build the full DTW accumulated-cost matrix.
///
/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series).
/// This matches the convention used by `dtaidistance.dtw.distance()`.
///
/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`.
fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option<usize>) -> Vec<Vec<f64>> {
let n = s1.len();
let m = s2.len();
let mut dp = vec![vec![f64::INFINITY; m]; n];
for i in 0..n {
// Window convention matches dtaidistance: window=w means |i-j| < w.
// None = unconstrained (full matrix).
let (j_lo, j_hi) = match window {
None => (0, m),
Some(w) => {
let lo = i.saturating_sub(w.saturating_sub(1));
let hi = i.saturating_add(w).min(m);
(lo, hi)
}
};
for j in j_lo..j_hi {
// Squared Euclidean local cost — matches dtaidistance convention.
// The final sqrt is applied only once at the top level (not per-step).
let cost = (s1[i] - s2[j]).powi(2);
let prev = if i == 0 && j == 0 {
0.0
} else if i == 0 {
dp[0][j - 1]
} else if j == 0 {
dp[i - 1][0]
} else {
dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
};
dp[i][j] = cost + prev;
}
}
dp
}
/// Compute the Dynamic Time Warping distance between two 1-D series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `f64::NAN` if either input is empty.
pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option<usize>) -> f64 {
if s1.is_empty() || s2.is_empty() {
return f64::NAN;
}
let dp = dtw_matrix(s1, s2, window);
// sqrt applied once at the end — matches dtaidistance.dtw.distance() convention.
dp[s1.len() - 1][s2.len() - 1].sqrt()
}
/// Compute the DTW distance and the optimal warping path between two 1-D series.
///
/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs,
/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `(f64::NAN, vec![])` if either input is empty.
pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option<usize>) -> (f64, Vec<(usize, usize)>) {
if s1.is_empty() || s2.is_empty() {
return (f64::NAN, vec![]);
}
let dp = dtw_matrix(s1, s2, window);
let dist = dp[s1.len() - 1][s2.len() - 1].sqrt();
// Backtrace from (n-1, m-1) to (0, 0)
let mut path = Vec::new();
let (mut i, mut j) = (s1.len() - 1, s2.len() - 1);
path.push((i, j));
while i > 0 || j > 0 {
let (ni, nj) = match (i, j) {
(0, _) => (0, j - 1),
(_, 0) => (i - 1, 0),
_ => {
let diag = dp[i - 1][j - 1];
let up = dp[i - 1][j];
let left = dp[i][j - 1];
let best = diag.min(up).min(left);
if best == diag {
(i - 1, j - 1)
} else if best == up {
(i - 1, j)
} else {
(i, j - 1)
}
}
};
i = ni;
j = nj;
path.push((i, j));
}
path.reverse();
(dist, path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stddev_constant() {
let prices = vec![5.0; 5];
let result = stddev(&prices, 3, 1.0);
for v in result.iter().filter(|v| !v.is_nan()) {
assert!(v.abs() < 1e-10);
}
}
#[test]
fn dtw_identical_series_is_zero() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(dtw_distance(&a, &a, None), 0.0);
}
#[test]
fn dtw_known_shifted_series() {
// [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt.
// Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2).
// Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142...
let a = vec![0.0, 1.0, 2.0];
let b = vec![1.0, 2.0, 3.0];
let expected = 2.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_known_even_shift() {
// [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3).
// Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320...
let a = vec![0.0, 2.0, 4.0];
let b = vec![1.0, 3.0, 5.0];
let expected = 3.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_single_element() {
let a = vec![3.0];
let b = vec![7.0];
assert_eq!(dtw_distance(&a, &b, None), 4.0);
}
#[test]
fn dtw_empty_returns_nan() {
assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan());
assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan());
}
#[test]
fn dtw_path_endpoints() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![1.5, 2.5, 3.5, 4.5];
let (_, path) = dtw_path(&a, &b, None);
assert_eq!(path.first(), Some(&(0, 0)));
assert_eq!(path.last(), Some(&(3, 3)));
}
#[test]
fn dtw_path_is_monotone() {
let a = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let b = vec![2.0, 1.0, 4.0, 3.0, 6.0];
let (_, path) = dtw_path(&a, &b, None);
for k in 1..path.len() {
assert!(path[k].0 >= path[k - 1].0);
assert!(path[k].1 >= path[k - 1].1);
}
}
#[test]
fn dtw_path_distance_matches_distance_only() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0];
let d1 = dtw_distance(&a, &b, None);
let (d2, _) = dtw_path(&a, &b, None);
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn dtw_nan_in_input_propagates() {
// NaN in either input must propagate to the distance (IEEE 754 semantics).
let a = vec![1.0, 2.0, f64::NAN, 4.0];
let b = vec![1.0, 2.0, 3.0, 4.0];
assert!(dtw_distance(&a, &b, None).is_nan());
assert!(dtw_distance(&b, &a, None).is_nan());
}
#[test]
fn dtw_is_symmetric() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0];
let d_ab = dtw_distance(&a, &b, None);
let d_ba = dtw_distance(&b, &a, None);
assert!((d_ab - d_ba).abs() < 1e-12);
}
#[test]
fn dtw_path_length_bounded() {
// A valid warp path has length between max(n, m) and n + m - 1.
let a: Vec<f64> = (0..7).map(|x| x as f64).collect();
let b: Vec<f64> = (0..10).map(|x| (x as f64).sin()).collect();
let (_, path) = dtw_path(&a, &b, None);
let n = a.len();
let m = b.len();
assert!(path.len() >= n.max(m));
assert!(path.len() <= n + m - 1);
}
#[test]
fn dtw_window_constrained_ge_unconstrained() {
// window convention matches dtaidistance: Some(w) means |i-j| < w.
// A narrow window restricts warping, so constrained distance >= unconstrained.
let a: Vec<f64> = (0..20).map(|x| x as f64).collect();
let b: Vec<f64> = (0..20).map(|x| x as f64 + 3.0).collect();
let d_full = dtw_distance(&a, &b, None);
let d_narrow = dtw_distance(&a, &b, Some(3));
assert!(d_narrow >= d_full - 1e-12);
}
}