Dtw algo (#9)

* feat: implement Dynamic Time Warping (DTW) functionality

- Added DTW distance computation and optimal warping path functions in Rust.
- Introduced corresponding Python bindings for DTW, DTW_DISTANCE, and BATCH_DTW.
- Enhanced WASM support with a new dtw_distance function.
- Included comprehensive unit tests for DTW functionality, validating against the dtaidistance library and ensuring mathematical properties.

* chore: update ferro-ta version to 1.1.4

- Bumped version number of ferro-ta to 1.1.4 in uv.lock and Cargo.lock files.
- Ensured consistency across package dependencies for the updated version.
This commit is contained in:
Pratik Bhadane
2026-04-07 23:38:36 +05:30
committed by GitHub
parent 388dc05c89
commit fd1bb137d6
19 changed files with 703 additions and 46 deletions
Generated
+26 -26
View File
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.0" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -67,9 +67,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.57" version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "ferro_ta" name = "ferro_ta"
version = "1.1.3" version = "1.1.4"
dependencies = [ dependencies = [
"criterion", "criterion",
"ferro_ta_core", "ferro_ta_core",
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.3" version = "1.1.4"
dependencies = [ dependencies = [
"criterion", "criterion",
"serde", "serde",
@@ -279,9 +279,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -289,9 +289,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]] [[package]]
name = "log" name = "log"
@@ -595,9 +595,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]] [[package]]
name = "rustversion" name = "rustversion"
@@ -729,9 +729,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -742,9 +742,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -752,9 +752,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -765,18 +765,18 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.91" version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -840,18 +840,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.47" version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.47" version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+2 -2
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package] [package]
name = "ferro_ta" name = "ferro_ta"
version = "1.1.3" version = "1.1.4"
edition = "2021" edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT" license = "MIT"
@@ -30,7 +30,7 @@ ndarray = "0.16"
rayon = "1.10" rayon = "1.10"
log = "0.4" log = "0.4"
pyo3-log = "0.12" pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.3", features = ["serde"] } ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.4", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %} {% set name = "ferro-ta" %}
{% set version = "1.1.3" %} {% set version = "1.1.4" %}
package: package:
name: {{ name|lower }} name: {{ name|lower }}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.3" version = "1.1.4"
edition = "2021" edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency" description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
```toml ```toml
[dependencies] [dependencies]
ferro_ta_core = "1.1.3" ferro_ta_core = "1.1.4"
``` ```
## Design ## Design
+200
View File
@@ -247,6 +247,118 @@ pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
result 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -259,4 +371,92 @@ mod tests {
assert!(v.abs() < 1e-10); 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_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);
}
} }
+63 -7
View File
@@ -1,8 +1,8 @@
{ {
"surfaces": { "surfaces": {
"python": { "python": {
"indicator_count": 208, "indicator_count": 211,
"method_count": 464, "method_count": 467,
"categories": [ "categories": [
"aggregation", "aggregation",
"alerts", "alerts",
@@ -131,6 +131,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "BBANDS", "name": "BBANDS",
"category": "overlap", "category": "overlap",
@@ -656,6 +663,20 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "DTW_DISTANCE",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "DX", "name": "DX",
"category": "momentum", "category": "momentum",
@@ -3283,6 +3304,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "BETA", "name": "BETA",
"category": "statistic", "category": "statistic",
@@ -3297,6 +3325,20 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "DTW_DISTANCE",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "LINEARREG", "name": "LINEARREG",
"category": "statistic", "category": "statistic",
@@ -4735,7 +4777,7 @@
] ]
}, },
"rust_core": { "rust_core": {
"public_function_count": 349, "public_function_count": 351,
"functions": [ "functions": [
{ {
"module": "aggregation", "module": "aggregation",
@@ -6187,6 +6229,16 @@
"function": "correl", "function": "correl",
"file": "statistic.rs" "file": "statistic.rs"
}, },
{
"module": "statistic",
"function": "dtw_distance",
"file": "statistic.rs"
},
{
"module": "statistic",
"function": "dtw_path",
"file": "statistic.rs"
},
{ {
"module": "statistic", "module": "statistic",
"function": "linearreg", "function": "linearreg",
@@ -6485,7 +6537,7 @@
] ]
}, },
"wasm_node": { "wasm_node": {
"export_count": 221, "export_count": 222,
"exports": [ "exports": [
"ad", "ad",
"adosc", "adosc",
@@ -6546,6 +6598,7 @@
"digital_price", "digital_price",
"donchian", "donchian",
"drawdown_series", "drawdown_series",
"dtw_distance",
"dx", "dx",
"early_exercise_premium", "early_exercise_premium",
"ema", "ema",
@@ -6712,9 +6765,9 @@
} }
}, },
"parity_summary": { "parity_summary": {
"python_indicator_count": 207, "python_indicator_count": 210,
"wasm_export_count": 221, "wasm_export_count": 222,
"common_python_wasm_count": 91, "common_python_wasm_count": 92,
"common_python_wasm": [ "common_python_wasm": [
"ad", "ad",
"adosc", "adosc",
@@ -6743,6 +6796,7 @@
"dema", "dema",
"detect_breaks_cusum", "detect_breaks_cusum",
"donchian", "donchian",
"dtw_distance",
"dx", "dx",
"ema", "ema",
"ht_dcperiod", "ht_dcperiod",
@@ -6817,6 +6871,7 @@
"asin", "asin",
"atan", "atan",
"batch_apply", "batch_apply",
"batch_dtw",
"beta", "beta",
"cdl2crows", "cdl2crows",
"cdl3blackcrows", "cdl3blackcrows",
@@ -6886,6 +6941,7 @@
"cosh", "cosh",
"div", "div",
"drawdown", "drawdown",
"dtw",
"exp", "exp",
"feature_matrix", "feature_matrix",
"floor", "floor",
+1 -1
View File
@@ -1,7 +1,7 @@
Release Notes Release Notes
============= =============
These docs track package version ``1.1.3``. These docs track package version ``1.1.4``.
1.1.0-audit (2026-03-28) 1.1.0-audit (2026-03-28)
------------------------ ------------------------
+1 -1
View File
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
Release status Release status
-------------- --------------
These docs track package version ``1.1.3``. These docs track package version ``1.1.4``.
- Release notes by version: :doc:`changelog` - Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_ - Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "ferro-ta" name = "ferro-ta"
version = "1.1.3" version = "1.1.4"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
+109
View File
@@ -12,19 +12,33 @@ LINEARREG_ANGLE — Linear Regression Angle (degrees)
TSF Time Series Forecast TSF Time Series Forecast
BETA Beta BETA Beta
CORREL Pearson's Correlation Coefficient (r) CORREL Pearson's Correlation Coefficient (r)
DTW Dynamic Time Warping (distance + warping path)
DTW_DISTANCE Dynamic Time Warping distance only (faster)
BATCH_DTW Batch DTW: N series vs 1 reference, in parallel
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
batch_dtw as _batch_dtw,
)
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
beta as _beta, beta as _beta,
) )
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
correl as _correl, correl as _correl,
) )
from ferro_ta._ferro_ta import (
dtw as _dtw,
)
from ferro_ta._ferro_ta import (
dtw_distance as _dtw_distance,
)
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
linearreg as _linearreg, linearreg as _linearreg,
) )
@@ -247,6 +261,98 @@ def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarr
_normalize_rust_error(e) _normalize_rust_error(e)
def DTW(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> tuple[float, np.ndarray]:
"""Dynamic Time Warping — distance and optimal warping path.
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
distance : float
DTW distance (accumulated Euclidean cost along the optimal path).
path : numpy.ndarray, shape (N, 2)
Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to
``(len(series1)-1, len(series2)-1)``.
"""
try:
return _dtw(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def DTW_DISTANCE(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> float:
"""Dynamic Time Warping distance only (faster — no path reconstruction).
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
float
DTW distance (accumulated Euclidean cost along the optimal path).
"""
try:
return _dtw_distance(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def BATCH_DTW(
matrix: ArrayLike,
reference: ArrayLike,
window: Optional[int] = None,
) -> np.ndarray:
"""Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel.
Parameters
----------
matrix : array-like, shape (N, L)
N time series of length L. Each row is compared against ``reference``.
reference : array-like, shape (L,)
The reference series.
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
numpy.ndarray, shape (N,)
DTW distance from each row of ``matrix`` to ``reference``.
"""
try:
mat = np.ascontiguousarray(matrix, dtype=np.float64)
if mat.ndim != 2:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"matrix must be a 2-D array, got {mat.ndim}-D.",
suggestion="Pass a 2-D NumPy array of shape (N, L).",
)
return _batch_dtw(mat, _to_f64(reference), window)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [ __all__ = [
"STDDEV", "STDDEV",
"VAR", "VAR",
@@ -257,4 +363,7 @@ __all__ = [
"TSF", "TSF",
"BETA", "BETA",
"CORREL", "CORREL",
"DTW",
"DTW_DISTANCE",
"BATCH_DTW",
] ]
+98
View File
@@ -0,0 +1,98 @@
use ndarray::Array2;
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use rayon::prelude::*;
/// Dynamic Time Warping — distance and optimal warping path between two 1-D series.
///
/// Returns a tuple `(distance, path)` where `path` is a NumPy array of shape
/// `(N, 2)` containing `(i, j)` index pairs from `(0, 0)` to `(n-1, m-1)`.
///
/// Local cost: `|series1[i] - series2[j]|` (Euclidean, matches `dtaidistance`).
#[pyfunction]
#[pyo3(signature = (series1, series2, window = None))]
pub fn dtw<'py>(
py: Python<'py>,
series1: PyReadonlyArray1<'py, f64>,
series2: PyReadonlyArray1<'py, f64>,
window: Option<usize>,
) -> PyResult<(f64, Bound<'py, PyArray2<usize>>)> {
let s1 = series1.as_slice()?;
let s2 = series2.as_slice()?;
if s1.is_empty() || s2.is_empty() {
return Err(PyValueError::new_err(
"series1 and series2 must not be empty",
));
}
let (dist, path) = ferro_ta_core::statistic::dtw_path(s1, s2, window);
let n = path.len();
let flat: Vec<usize> = path.iter().flat_map(|&(i, j)| [i, j]).collect();
let arr =
Array2::from_shape_vec((n, 2), flat).map_err(|e| PyValueError::new_err(e.to_string()))?;
Ok((dist, arr.into_pyarray(py)))
}
/// Dynamic Time Warping — distance only (faster, no path reconstruction).
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Use this when you only need the distance, not the alignment path.
#[pyfunction]
#[pyo3(signature = (series1, series2, window = None))]
pub fn dtw_distance<'py>(
_py: Python<'py>,
series1: PyReadonlyArray1<'py, f64>,
series2: PyReadonlyArray1<'py, f64>,
window: Option<usize>,
) -> PyResult<f64> {
let s1 = series1.as_slice()?;
let s2 = series2.as_slice()?;
if s1.is_empty() || s2.is_empty() {
return Err(PyValueError::new_err(
"series1 and series2 must not be empty",
));
}
Ok(ferro_ta_core::statistic::dtw_distance(s1, s2, window))
}
/// Batch Dynamic Time Warping — compute DTW distance from each row of a 2-D matrix
/// to a single reference series, in parallel.
///
/// Parameters
/// ----------
/// matrix : np.ndarray, shape (N, L)
/// N time series of length L. Each row is compared against `reference`.
/// reference : np.ndarray, shape (L,)
/// The reference series.
/// window : int, optional
/// Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns
/// -------
/// np.ndarray, shape (N,)
/// DTW distances, one per row.
#[pyfunction]
#[pyo3(signature = (matrix, reference, window = None))]
pub fn batch_dtw<'py>(
py: Python<'py>,
matrix: PyReadonlyArray2<'py, f64>,
reference: PyReadonlyArray1<'py, f64>,
window: Option<usize>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let mat = matrix.as_array();
let ref_slice = reference.as_slice()?;
if ref_slice.is_empty() {
return Err(PyValueError::new_err("reference must not be empty"));
}
let (n_rows, _) = mat.dim();
let rows: Vec<Vec<f64>> = (0..n_rows).map(|i| mat.row(i).to_vec()).collect();
let result: Vec<f64> = rows
.par_iter()
.map(|series| ferro_ta_core::statistic::dtw_distance(series, ref_slice, window))
.collect();
Ok(result.into_pyarray(py))
}
+4
View File
@@ -4,6 +4,7 @@
mod beta; mod beta;
pub(crate) mod common; pub(crate) mod common;
mod correl; mod correl;
mod dtw;
mod linearreg; mod linearreg;
mod stddev; mod stddev;
mod var; mod var;
@@ -23,5 +24,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw_distance, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::batch_dtw, m)?)?;
Ok(()) Ok(())
} }
+178
View File
@@ -1,10 +1,14 @@
"""Unit tests for ferro_ta.indicators.statistic""" """Unit tests for ferro_ta.indicators.statistic"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.statistic import ( from ferro_ta.indicators.statistic import (
BATCH_DTW,
BETA, BETA,
CORREL, CORREL,
DTW,
DTW_DISTANCE,
LINEARREG, LINEARREG,
LINEARREG_ANGLE, LINEARREG_ANGLE,
LINEARREG_INTERCEPT, LINEARREG_INTERCEPT,
@@ -308,3 +312,177 @@ class TestTSF:
expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0) expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0)
result = TSF(_A, timeperiod=14) result = TSF(_A, timeperiod=14)
np.testing.assert_allclose(result, expected, equal_nan=True) np.testing.assert_allclose(result, expected, equal_nan=True)
# ---------------------------------------------------------------------------
# DTW — Dynamic Time Warping
# ---------------------------------------------------------------------------
dtai = pytest.importorskip("dtaidistance", reason="dtaidistance not installed")
_DTW_RNG = np.random.default_rng(42)
class TestDTW:
# --- Validation against dtaidistance (SOTA reference) ---
def test_distance_matches_dtaidistance_random(self):
"""Core correctness: our distance == dtaidistance on 20 random pairs."""
for _ in range(20):
n = int(_DTW_RNG.integers(5, 50))
a = _DTW_RNG.random(n)
b = _DTW_RNG.random(n)
expected = dtai.dtw.distance(a, b)
actual = DTW_DISTANCE(a, b)
np.testing.assert_allclose(
actual, expected, rtol=1e-9, err_msg=f"Mismatch on series length {n}"
)
def test_distance_matches_dtaidistance_unequal_length(self):
"""Handles unequal-length series correctly."""
for _ in range(10):
a = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
b = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
expected = dtai.dtw.distance(a, b)
actual = DTW_DISTANCE(a, b)
np.testing.assert_allclose(actual, expected, rtol=1e-9)
def test_path_distance_matches_dtaidistance(self):
"""DTW() path variant: returned distance matches dtaidistance."""
a = _DTW_RNG.random(20)
b = _DTW_RNG.random(25)
expected = dtai.dtw.distance(a, b)
dist, _ = DTW(a, b)
np.testing.assert_allclose(dist, expected, rtol=1e-9)
def test_path_matches_dtaidistance_warping_path(self):
"""Warping path matches dtaidistance.dtw.warping_path() on same-length series."""
for _ in range(10):
n = int(_DTW_RNG.integers(5, 20))
a = _DTW_RNG.random(n)
b = _DTW_RNG.random(n)
expected_path = dtai.dtw.warping_path(a, b)
_, actual_path = DTW(a, b)
actual_pairs = [tuple(int(x) for x in row) for row in actual_path]
assert actual_pairs == expected_path, (
f"Path mismatch for n={n}:\n ours={actual_pairs}\n dtai={expected_path}"
)
def test_window_constrained_matches_dtaidistance(self):
"""Sakoe-Chiba window matches dtaidistance window parameter."""
a = _DTW_RNG.random(30)
b = _DTW_RNG.random(30)
for w in [3, 8, 15]:
expected = dtai.dtw.distance(a, b, window=w)
actual = DTW_DISTANCE(a, b, window=w)
np.testing.assert_allclose(
actual, expected, rtol=1e-9, err_msg=f"Mismatch at window={w}"
)
def test_batch_matches_dtaidistance(self):
"""BATCH_DTW matches calling dtaidistance per-row."""
ref = _DTW_RNG.random(20)
matrix = _DTW_RNG.random((8, 20))
batch_result = BATCH_DTW(matrix, ref)
for i in range(8):
expected = dtai.dtw.distance(matrix[i], ref)
np.testing.assert_allclose(
batch_result[i],
expected,
rtol=1e-9,
err_msg=f"Batch mismatch at row {i}",
)
# --- Mathematical properties ---
def test_identical_distance_is_zero(self):
a = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dist, _ = DTW(a, a)
assert dist == pytest.approx(0.0, abs=1e-10)
def test_symmetry(self):
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
assert DTW_DISTANCE(a, b) == pytest.approx(DTW_DISTANCE(b, a), rel=1e-10)
def test_triangle_inequality(self):
a, b, c = _DTW_RNG.random(15), _DTW_RNG.random(15), _DTW_RNG.random(15)
assert DTW_DISTANCE(a, c) <= DTW_DISTANCE(a, b) + DTW_DISTANCE(b, c) + 1e-9
# --- Known hardcoded values ---
def test_known_shifted_series(self):
# [0,1,2] vs [1,2,3]: optimal path (0,0)→(1,0)→(2,1)→(2,2)
# Squared costs: 1+0+0+1=2, sqrt(2). Verified against dtaidistance.
a = np.array([0.0, 1.0, 2.0])
b = np.array([1.0, 2.0, 3.0])
np.testing.assert_allclose(DTW_DISTANCE(a, b), np.sqrt(2.0), rtol=1e-9)
def test_known_single_element(self):
# sqrt((3-7)^2) = sqrt(16) = 4.0
np.testing.assert_allclose(
DTW_DISTANCE(np.array([3.0]), np.array([7.0])), 4.0, rtol=1e-9
)
def test_known_constant_series(self):
assert DTW_DISTANCE(np.full(10, 5.0), np.full(10, 5.0)) == pytest.approx(
0.0, abs=1e-12
)
# --- Path structural guarantees ---
def test_path_starts_at_origin(self):
_, path = DTW(_DTW_RNG.random(10), _DTW_RNG.random(10))
assert tuple(int(x) for x in path[0]) == (0, 0)
def test_path_ends_at_corner(self):
_, path = DTW(_DTW_RNG.random(7), _DTW_RNG.random(9))
assert tuple(int(x) for x in path[-1]) == (6, 8)
def test_path_is_monotone(self):
_, path = DTW(_DTW_RNG.random(20), _DTW_RNG.random(20))
for k in range(1, len(path)):
assert path[k][0] >= path[k - 1][0]
assert path[k][1] >= path[k - 1][1]
def test_path_steps_unit_size(self):
_, path = DTW(_DTW_RNG.random(15), _DTW_RNG.random(12))
for k in range(1, len(path)):
di = int(path[k][0]) - int(path[k - 1][0])
dj = int(path[k][1]) - int(path[k - 1][1])
assert di in (0, 1) and dj in (0, 1)
assert not (di == 0 and dj == 0)
# --- DTW_DISTANCE == DTW distance ---
def test_distance_only_matches_full(self):
a, b = _DTW_RNG.random(25), _DTW_RNG.random(25)
d_full, _ = DTW(a, b)
np.testing.assert_allclose(DTW_DISTANCE(a, b), d_full, rtol=1e-10)
# --- Batch ---
def test_batch_single_row(self):
ref = np.array([1.0, 2.0, 3.0])
result = BATCH_DTW(np.array([[1.0, 2.0, 3.0]]), ref)
assert result[0] == pytest.approx(0.0, abs=1e-10)
def test_batch_matches_single_calls(self):
ref = _DTW_RNG.random(20)
matrix = _DTW_RNG.random((8, 20))
batch = BATCH_DTW(matrix, ref)
for i in range(8):
np.testing.assert_allclose(
batch[i], DTW_DISTANCE(matrix[i], ref), rtol=1e-10
)
# --- Edge cases ---
def test_empty_series_raises(self):
with pytest.raises((ValueError, Exception)):
DTW(np.array([]), np.array([1.0, 2.0]))
def test_window_constrained_ge_unconstrained(self):
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
d_full = DTW_DISTANCE(a, b)
d_narrow = DTW_DISTANCE(a, b, window=2)
assert d_narrow >= d_full - 1e-9
Generated
+1 -1
View File
@@ -950,7 +950,7 @@ wheels = [
[[package]] [[package]]
name = "ferro-ta" name = "ferro-ta"
version = "1.1.3" version = "1.1.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
+2 -2
View File
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.3" version = "1.1.4"
[[package]] [[package]]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.1.3" version = "1.1.4"
dependencies = [ dependencies = [
"ferro_ta_core", "ferro_ta_core",
"js-sys", "js-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.1.3" version = "1.1.4"
edition = "2021" edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators" description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ferro-ta-wasm", "name": "ferro-ta-wasm",
"version": "1.1.3", "version": "1.1.4",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators", "description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "node/ferro_ta_wasm.js", "main": "node/ferro_ta_wasm.js",
"module": "web/ferro_ta_wasm.js", "module": "web/ferro_ta_wasm.js",
+12
View File
@@ -1653,6 +1653,18 @@ pub fn correl(real0: &Float64Array, real1: &Float64Array, timeperiod: usize) ->
from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod)) from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod))
} }
/// Dynamic Time Warping distance between two series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Pass `window` as `0` for unconstrained (no Sakoe-Chiba band).
#[wasm_bindgen]
pub fn dtw_distance(series1: &Float64Array, series2: &Float64Array, window: usize) -> f64 {
let s1 = to_vec(series1);
let s2 = to_vec(series2);
let w = if window == 0 { None } else { Some(window) };
ferro_ta_core::statistic::dtw_distance(&s1, &s2, w)
}
// =========================================================================== // ===========================================================================
// Streaming / Stateful API // Streaming / Stateful API
// =========================================================================== // ===========================================================================