From db4d0b56c158afae95d6396176cc83140d7ecc28 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Tue, 21 Apr 2026 17:09:38 +0900 Subject: [PATCH 1/6] Support nullable core composite indicators --- core/src/indicators/aroon.rs | 21 ++++- core/src/indicators/aroonosc.rs | 16 +++- core/src/indicators/cci.rs | 69 +++++++++++++--- core/src/indicators/cv.rs | 35 ++++++-- core/src/indicators/ema.rs | 2 - core/src/indicators/erbear.rs | 22 +++-- core/src/indicators/erbull.rs | 22 +++-- core/src/indicators/ichimoku.rs | 69 +++++++++++++--- core/src/indicators/macd.rs | 47 +++++++++-- core/src/indicators/massi.rs | 89 +++++++++++++------- core/src/indicators/mom.rs | 11 ++- core/src/indicators/pchan.rs | 20 ++++- core/src/indicators/ppo.rs | 41 ++++++++-- core/src/indicators/psl.rs | 49 +++++------ core/src/indicators/pvo.rs | 19 +++-- core/src/indicators/roc.rs | 13 +-- core/src/indicators/rsi.rs | 5 -- core/src/indicators/sonar.rs | 35 ++++++-- core/src/indicators/stochf.rs | 47 ++++++++--- core/src/indicators/stochrsi.rs | 52 +++++++----- core/src/indicators/stochs.rs | 59 ++++++++++---- core/src/indicators/willr.rs | 16 +++- core/src/utils.rs | 139 ++++++++++++++++++++------------ 23 files changed, 650 insertions(+), 248 deletions(-) diff --git a/core/src/indicators/aroon.rs b/core/src/indicators/aroon.rs index 104db40..5a6920b 100644 --- a/core/src/indicators/aroon.rs +++ b/core/src/indicators/aroon.rs @@ -1,10 +1,14 @@ use crate::utils::rolling_argmax_argmin; -pub fn aroon(highs: &[f64], lows: &[f64], period: usize) -> (Vec>, Vec>) { +pub fn aroon( + highs: &[Option], + lows: &[Option], + period: usize, +) -> (Vec>, Vec>) { let mut aroon_up = vec![None; highs.len()]; let mut aroon_down = vec![None; lows.len()]; - if highs.len() < period { + if highs.len() != lows.len() || highs.len() < period { return (aroon_up, aroon_down); } @@ -41,6 +45,8 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let highs = highs.into_iter().map(Some).collect::>(); + let lows = lows.into_iter().map(Some).collect::>(); let (aroon_up, aroon_down) = aroon(&highs, &lows, 25); let expected_up = testutils::load_expected::>(&format!( @@ -66,4 +72,15 @@ mod tests { ); } } + + #[test] + fn test_aroon_with_gap_invalidates_window() { + let highs = vec![Some(1.0), Some(5.0), None, Some(2.0)]; + let lows = vec![Some(4.0), Some(1.0), None, Some(3.0)]; + + let (up, down) = aroon(&highs, &lows, 2); + + assert_eq!(up, vec![None, None, None, None]); + assert_eq!(down, vec![None, None, None, None]); + } } diff --git a/core/src/indicators/aroonosc.rs b/core/src/indicators/aroonosc.rs index abf0518..4e9357d 100644 --- a/core/src/indicators/aroonosc.rs +++ b/core/src/indicators/aroonosc.rs @@ -1,9 +1,9 @@ use crate::indicators::aroon; -pub fn aroonosc(highs: &[f64], lows: &[f64], period: usize) -> Vec> { +pub fn aroonosc(highs: &[Option], lows: &[Option], period: usize) -> Vec> { let mut aroonosc = vec![None; highs.len()]; - if highs.len() < period { + if highs.len() != lows.len() || highs.len() < period { return aroonosc; } @@ -30,6 +30,8 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let highs = highs.into_iter().map(Some).collect::>(); + let lows = lows.into_iter().map(Some).collect::>(); let result = aroonosc(&highs, &lows, 25); let expected = testutils::load_expected::>(&format!( @@ -45,4 +47,14 @@ mod tests { ); } } + + #[test] + fn test_aroonosc_mismatched_lengths_fail_closed() { + let highs = vec![Some(1.0), Some(2.0), Some(3.0)]; + let lows = vec![Some(1.0), Some(2.0)]; + + let result = aroonosc(&highs, &lows, 2); + + assert_eq!(result, vec![None, None, None]); + } } diff --git a/core/src/indicators/cci.rs b/core/src/indicators/cci.rs index 8829d34..b0b74dd 100644 --- a/core/src/indicators/cci.rs +++ b/core/src/indicators/cci.rs @@ -1,4 +1,11 @@ -pub fn cci(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec> { +use crate::utils::rolling_mean_strict; + +pub fn cci( + highs: &[Option], + lows: &[Option], + closes: &[Option], + period: usize, +) -> Vec> { let len = highs.len(); let mut result = vec![None; len]; @@ -6,22 +13,42 @@ pub fn cci(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec = highs + let typical_prices = highs .iter() .zip(lows.iter()) .zip(closes.iter()) - .map(|((h, l), c)| (h + l + c) / 3.0) - .collect(); + .map(|((high, low), close)| match (high, low, close) { + (Some(high), Some(low), Some(close)) => Some((high + low + close) / 3.0), + _ => None, + }) + .collect::>(); + let sma_tp = rolling_mean_strict(&typical_prices, period); for i in period - 1..len { - let slice = &typical_prices[i + 1 - period..=i]; - let sma_tp: f64 = slice.iter().sum::() / period as f64; - let mean_deviation = slice.iter().map(|&x| (x - sma_tp).abs()).sum::() / period as f64; + let Some(sma_tp) = sma_tp[i] else { + continue; + }; + let mut mean_deviation = 0.0; + let mut valid = true; + for typical_price in &typical_prices[i + 1 - period..=i] { + let Some(typical_price) = *typical_price else { + valid = false; + break; + }; + mean_deviation += (typical_price - sma_tp).abs(); + } + if !valid { + continue; + } + mean_deviation /= period as f64; result[i] = if mean_deviation == 0.0 { None } else { - Some((typical_prices[i] - sma_tp) / (0.015 * mean_deviation)) + Some( + (typical_prices[i].expect("sma implies current value exists") - sma_tp) + / (0.015 * mean_deviation), + ) }; } @@ -38,9 +65,18 @@ mod tests { fn test_cci() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = cci(&high, &low, &close, 20); let expected = testutils::load_expected::>(&format!( "../data/expected/cci_{}.json", @@ -55,4 +91,15 @@ mod tests { ); } } + + #[test] + fn test_cci_with_gap_invalidates_typical_price_window() { + let high = vec![Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; + let low = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)]; + let close = vec![Some(2.0), Some(3.0), None, Some(5.0), Some(6.0)]; + + let result = cci(&high, &low, &close, 3); + + assert_eq!(result, vec![None, None, None, None, None]); + } } diff --git a/core/src/indicators/cv.rs b/core/src/indicators/cv.rs index a06fd25..0ca2395 100644 --- a/core/src/indicators/cv.rs +++ b/core/src/indicators/cv.rs @@ -1,6 +1,6 @@ -use crate::indicators::ema::ema_dense; +use crate::indicators::ema::ema_aligned; -pub fn cv(highs: &[f64], lows: &[f64], period: usize) -> Vec> { +pub fn cv(highs: &[Option], lows: &[Option], period: usize) -> Vec> { let mut cv = vec![None; highs.len()]; let len = highs.len(); @@ -8,8 +8,15 @@ pub fn cv(highs: &[f64], lows: &[f64], period: usize) -> Vec> { return cv; } - let high_low_diffs: Vec = highs.iter().zip(lows.iter()).map(|(h, l)| h - l).collect(); - let ema_high_low_diffs = ema_dense(&high_low_diffs, period); + let high_low_diffs = highs + .iter() + .zip(lows.iter()) + .map(|(high, low)| match (high, low) { + (Some(high), Some(low)) => Some(high - low), + _ => None, + }) + .collect::>(); + let ema_high_low_diffs = ema_aligned(&high_low_diffs, period); for i in period * 2 - 1..len { if let (Some(current_ema), Some(previous_ema)) = @@ -33,8 +40,14 @@ mod tests { fn test_cv() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); let result = cv(&high, &low, 10); let expected = testutils::load_expected::>(&format!( "../data/expected/cv_{}.json", @@ -49,4 +62,14 @@ mod tests { ); } } + + #[test] + fn test_cv_with_gap_requires_both_ema_points() { + let high = vec![Some(5.0), Some(6.0), None, Some(8.0), Some(9.0), Some(10.0)]; + let low = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0), Some(6.0)]; + + let result = cv(&high, &low, 2); + + assert_eq!(result, vec![None, None, None, Some(0.0), None, Some(0.0)]); + } } diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 5fdb38d..01bc4d0 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -48,8 +48,6 @@ pub fn ema(data: &[Option], period: usize) -> Vec> { result } -pub(crate) use ema as ema_aligned; - #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/erbear.rs b/core/src/indicators/erbear.rs index f5ef92a..2311dcf 100644 --- a/core/src/indicators/erbear.rs +++ b/core/src/indicators/erbear.rs @@ -1,17 +1,17 @@ -use crate::indicators::ema::ema_dense; +use crate::indicators::ema::ema_aligned; -pub fn erbear(lows: &[f64], closes: &[f64], period: usize) -> Vec> { +pub fn erbear(lows: &[Option], closes: &[Option], period: usize) -> Vec> { let mut erbear = vec![None; lows.len()]; - if lows.len() < period { + if lows.len() != closes.len() || lows.len() < period || period == 0 { return erbear; } - let ema_values = ema_dense(closes, period); + let ema_values = ema_aligned(closes, period); for i in (period - 1)..lows.len() { - if let Some(ema_value) = ema_values[i] { - let bear_power = lows[i] - ema_value; + if let (Some(low), Some(ema_value)) = (lows[i], ema_values[i]) { + let bear_power = low - ema_value; erbear[i] = Some(bear_power); } } @@ -29,8 +29,14 @@ mod tests { fn test_erbear() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = erbear(&lows, &closes, 13); let expected = testutils::load_expected::>(&format!( "../data/expected/erbear_{}.json", diff --git a/core/src/indicators/erbull.rs b/core/src/indicators/erbull.rs index cda1125..4373d34 100644 --- a/core/src/indicators/erbull.rs +++ b/core/src/indicators/erbull.rs @@ -1,17 +1,17 @@ -use crate::indicators::ema::ema_dense; +use crate::indicators::ema::ema_aligned; -pub fn erbull(highs: &[f64], closes: &[f64], period: usize) -> Vec> { +pub fn erbull(highs: &[Option], closes: &[Option], period: usize) -> Vec> { let mut erbull = vec![None; highs.len()]; - if highs.len() < period { + if highs.len() != closes.len() || highs.len() < period || period == 0 { return erbull; } - let ema_values = ema_dense(closes, period); + let ema_values = ema_aligned(closes, period); for i in (period - 1)..highs.len() { - if let Some(ema_value) = ema_values[i] { - let bull_power = highs[i] - ema_value; + if let (Some(high), Some(ema_value)) = (highs[i], ema_values[i]) { + let bull_power = high - ema_value; erbull[i] = Some(bull_power); } } @@ -29,8 +29,14 @@ mod tests { fn test_erbull() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = erbull(&highs, &closes, 13); let expected = testutils::load_expected::>(&format!( "../data/expected/erbull_{}.json", diff --git a/core/src/indicators/ichimoku.rs b/core/src/indicators/ichimoku.rs index 56afa82..795324c 100644 --- a/core/src/indicators/ichimoku.rs +++ b/core/src/indicators/ichimoku.rs @@ -17,15 +17,23 @@ fn leading_span_a_from_lines( forward_shift(span, base_line_period) } -pub fn ichimoku_conversion_line(highs: &[f64], lows: &[f64], period: usize) -> Vec> { +pub fn ichimoku_conversion_line( + highs: &[Option], + lows: &[Option], + period: usize, +) -> Vec> { rolling_midpoint(highs, lows, period) } -pub fn ichimoku_base_line(highs: &[f64], lows: &[f64], period: usize) -> Vec> { +pub fn ichimoku_base_line( + highs: &[Option], + lows: &[Option], + period: usize, +) -> Vec> { rolling_midpoint(highs, lows, period) } -pub fn ichimoku_lagging_span(closes: &[f64], base_line_period: usize) -> Vec> { +pub fn ichimoku_lagging_span(closes: &[Option], base_line_period: usize) -> Vec> { let len = closes.len(); let mut lagging_span = vec![None; len]; @@ -34,15 +42,15 @@ pub fn ichimoku_lagging_span(closes: &[f64], base_line_period: usize) -> Vec], + lows: &[Option], conversion_line_period: usize, base_line_period: usize, ) -> Vec> { @@ -52,8 +60,8 @@ pub fn ichimoku_leading_span_a( } pub fn ichimoku_leading_span_b( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period: usize, base_line_period: usize, ) -> Vec> { @@ -61,9 +69,9 @@ pub fn ichimoku_leading_span_b( } pub fn ichimoku( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], conversion_line_period: usize, base_line_period: usize, leading_span_b_period: usize, @@ -74,6 +82,18 @@ pub fn ichimoku( Vec>, // Leading span A Vec>, // Leading span B ) { + if highs.len() != lows.len() || highs.len() != closes.len() { + let len = highs.len(); + let projected_len = len.saturating_add(base_line_period.saturating_sub(1)); + return ( + vec![None; len], + vec![None; len], + vec![None; len], + vec![None; projected_len], + vec![None; projected_len], + ); + } + let conversion_line = ichimoku_conversion_line(highs, lows, conversion_line_period); let base_line = ichimoku_base_line(highs, lows, base_line_period); let lagging_span = ichimoku_lagging_span(closes, base_line_period); @@ -102,6 +122,9 @@ mod tests { let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let high = high.into_iter().map(Some).collect::>(); + let low = low.into_iter().map(Some).collect::>(); + let close = close.into_iter().map(Some).collect::>(); let (conversion_line, base_line, lagging_span, leading_span_a, leading_span_b) = ichimoku(&high, &low, &close, 9, 26, 52); @@ -159,4 +182,28 @@ mod tests { ); } } + + #[test] + fn test_ichimoku_lagging_span_preserves_gap_when_shifted() { + let closes = vec![Some(1.0), Some(2.0), None, Some(4.0)]; + + let lagging = ichimoku_lagging_span(&closes, 2); + + assert_eq!(lagging, vec![Some(2.0), None, Some(4.0), None]); + } + + #[test] + fn test_ichimoku_mismatched_lengths_fail_closed_with_aligned_shapes() { + let highs = vec![Some(1.0), Some(2.0), Some(3.0)]; + let lows = vec![Some(1.0), Some(2.0)]; + let closes = vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]; + + let (conversion, base, lagging, span_a, span_b) = ichimoku(&highs, &lows, &closes, 2, 3, 4); + + assert_eq!(conversion, vec![None, None, None]); + assert_eq!(base, vec![None, None, None]); + assert_eq!(lagging, vec![None, None, None]); + assert_eq!(span_a, vec![None, None, None, None, None]); + assert_eq!(span_b, vec![None, None, None, None, None]); + } } diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index 1a605e2..008390b 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -1,7 +1,7 @@ -use crate::indicators::ema::{ema_aligned, ema_dense}; +use crate::indicators::ema::ema_aligned; pub fn macd( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -21,7 +21,7 @@ pub fn macd( } pub fn macd_histogram( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -40,7 +40,7 @@ pub fn macd_histogram( } pub fn macd_signal( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -49,15 +49,15 @@ pub fn macd_signal( ema_aligned(&macd_line, signal_period) } -pub fn macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { +pub fn macd_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { let mut macd_line = vec![None; data.len()]; if data.len() < slow_period || fast_period >= slow_period { return macd_line; } - let fast_ema = ema_dense(data, fast_period); - let slow_ema = ema_dense(data, slow_period); + let fast_ema = ema_aligned(data, fast_period); + let slow_ema = ema_aligned(data, slow_period); for i in (slow_period - 1)..data.len() { if let (Some(fast), Some(slow)) = (fast_ema[i], slow_ema[i]) { @@ -82,7 +82,10 @@ mod tests { // When for symbol in test_cases { - let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let input = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (macd_line, signal_line, histogram) = macd(&input, 12, 26, 9); let expected_macd = testutils::load_expected::>(&format!( @@ -119,4 +122,32 @@ mod tests { ); } } + + #[test] + fn test_macd_with_interior_gap_propagates_to_composite_outputs() { + let input = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + None, + Some(4.0), + Some(5.0), + Some(6.0), + ]; + + let (line, signal, histogram) = macd(&input, 2, 3, 2); + + assert_eq!( + line, + vec![None, None, Some(0.5), None, Some(0.5), Some(0.5), Some(0.5)] + ); + assert_eq!( + signal, + vec![None, None, None, None, Some(0.5), Some(0.5), Some(0.5)] + ); + assert_eq!( + histogram, + vec![None, None, None, None, Some(0.0), Some(0.0), Some(0.0)] + ); + } } diff --git a/core/src/indicators/massi.rs b/core/src/indicators/massi.rs index 6f5cfbf..ff54c0d 100644 --- a/core/src/indicators/massi.rs +++ b/core/src/indicators/massi.rs @@ -1,8 +1,9 @@ -use crate::indicators::ema::{ema_aligned, ema_dense}; +use crate::indicators::ema::ema_aligned; +use crate::utils::rolling_sum_strict; pub fn massi( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period_ema: usize, period_sum: usize, period_signal: usize, @@ -14,8 +15,8 @@ pub fn massi( } pub fn massi_signal( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period_ema: usize, period_sum: usize, period_signal: usize, @@ -25,39 +26,42 @@ pub fn massi_signal( } pub fn massi_line( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period_ema: usize, period_sum: usize, ) -> Vec> { let len = highs.len(); let mut mass = vec![None; len]; - if len != lows.len() || len < 2 * (period_ema - 1) + (period_sum - 1) + 1 { + if len != lows.len() + || period_ema == 0 + || period_sum == 0 + || len < 2 * (period_ema - 1) + (period_sum - 1) + 1 + { return mass; } - let high_low_diffs: Vec = highs.iter().zip(lows.iter()).map(|(h, l)| h - l).collect(); - let s_ema = ema_dense(&high_low_diffs, period_ema); - let offset: usize = period_ema - 1; + let high_low_diffs = highs + .iter() + .zip(lows.iter()) + .map(|(high, low)| match (high, low) { + (Some(high), Some(low)) => Some(high - low), + _ => None, + }) + .collect::>(); + let s_ema = ema_aligned(&high_low_diffs, period_ema); let d_ema = ema_aligned(&s_ema, period_ema); + let ema_ratio = s_ema + .iter() + .zip(d_ema.iter()) + .map(|(&single, &double)| match (single, double) { + (Some(single), Some(double)) if double != 0.0 => Some(single / double), + _ => None, + }) + .collect::>(); - let mut ema_ratio = Vec::with_capacity(len.saturating_sub(2 * offset)); - for i in 0..len { - if let (Some(s), Some(d)) = (s_ema[i], d_ema[i]) { - ema_ratio.push(s / d); - } - } - - let mut ratio_sum = 0.0; - for i in 0..ema_ratio.len() { - ratio_sum += ema_ratio[i]; - if i >= period_sum - 1 { - mass[i + 2 * offset] = Some(ratio_sum); - ratio_sum -= ema_ratio[i - (period_sum - 1)]; - } - } - + mass = rolling_sum_strict(&ema_ratio, period_sum); mass } @@ -77,6 +81,8 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let highs = highs.into_iter().map(Some).collect::>(); + let lows = lows.into_iter().map(Some).collect::>(); let (mass, signal) = massi(&highs, &lows, 9, 25, 9); @@ -104,4 +110,33 @@ mod tests { ); } } + + #[test] + fn test_massi_with_gap_invalidates_ratio_window() { + let highs = vec![ + Some(5.0), + Some(6.0), + Some(7.0), + None, + Some(8.0), + Some(9.0), + Some(10.0), + ]; + let lows = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + None, + Some(4.0), + Some(5.0), + Some(6.0), + ]; + + let line = massi_line(&highs, &lows, 2, 2); + + assert_eq!( + line, + vec![None, None, None, None, None, Some(2.0), Some(2.0)] + ); + } } diff --git a/core/src/indicators/mom.rs b/core/src/indicators/mom.rs index 2bf07e1..4fd3c8e 100644 --- a/core/src/indicators/mom.rs +++ b/core/src/indicators/mom.rs @@ -1,4 +1,4 @@ -pub fn mom(closes: &[f64], period: usize) -> Vec> { +pub fn mom(closes: &[Option], period: usize) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; @@ -7,7 +7,9 @@ pub fn mom(closes: &[f64], period: usize) -> Vec> { } for i in period..len { - result[i] = Some(closes[i] - closes[i - period]); + if let (Some(current), Some(prev)) = (closes[i], closes[i - period]) { + result[i] = Some(current - prev); + } } result @@ -23,7 +25,10 @@ mod tests { fn test_mom() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = mom(&close, 10); let expected = testutils::load_expected::>(&format!( "../data/expected/mom_{}.json", diff --git a/core/src/indicators/pchan.rs b/core/src/indicators/pchan.rs index 154c53b..db1fc37 100644 --- a/core/src/indicators/pchan.rs +++ b/core/src/indicators/pchan.rs @@ -1,8 +1,8 @@ use crate::utils::rolling_max_min; pub fn pchan( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period: usize, ) -> (Vec>, Vec>, Vec>) { let len = highs.len(); @@ -10,7 +10,7 @@ pub fn pchan( let mut lower = vec![None; len]; let mut middle = vec![None; len]; - if period == 0 || len < period { + if len != lows.len() || period == 0 || len < period { return (upper, middle, lower); } @@ -42,6 +42,8 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let highs = highs.into_iter().map(Some).collect::>(); + let lows = lows.into_iter().map(Some).collect::>(); let (upper, middle, lower) = pchan(&highs, &lows, 20); let expected_upper = testutils::load_expected::>(&format!( @@ -77,4 +79,16 @@ mod tests { ); } } + + #[test] + fn test_pchan_with_gap_invalidates_previous_window() { + let highs = vec![Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; + let lows = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)]; + + let (upper, middle, lower) = pchan(&highs, &lows, 2); + + assert_eq!(upper, vec![None, None, Some(4.0), None, None]); + assert_eq!(middle, vec![None, None, Some(2.5), None, None]); + assert_eq!(lower, vec![None, None, Some(1.0), None, None]); + } } diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index 63ea40d..3a14000 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -1,7 +1,7 @@ -use crate::indicators::ema::{ema_aligned, ema_dense}; +use crate::indicators::ema::ema_aligned; pub fn ppo( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -21,7 +21,7 @@ pub fn ppo( } pub fn ppo_histogram( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -39,7 +39,7 @@ pub fn ppo_histogram( } pub fn ppo_signal( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -48,15 +48,15 @@ pub fn ppo_signal( ema_aligned(&ppo_line, signal_period) } -pub fn ppo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { +pub fn ppo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { let mut ppo_line = vec![None; data.len()]; if data.len() < slow_period || fast_period >= slow_period { return ppo_line; } - let fast_ema = ema_dense(data, fast_period); - let slow_ema = ema_dense(data, slow_period); + let fast_ema = ema_aligned(data, fast_period); + let slow_ema = ema_aligned(data, slow_period); for i in (slow_period - 1)..data.len() { if let (Some(fast), Some(slow)) = (fast_ema[i], slow_ema[i]) { @@ -83,7 +83,10 @@ mod tests { // When for symbol in test_cases { - let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let input = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (ppo_line, signal_line, histogram) = ppo(&input, 12, 26, 9); let expected_ppo = testutils::load_expected::>(&format!( @@ -120,4 +123,26 @@ mod tests { ); } } + + #[test] + fn test_ppo_with_interior_gap_keeps_alignment() { + let input = vec![ + Some(10.0), + Some(11.0), + Some(12.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + ]; + + let (line, signal, histogram) = ppo(&input, 2, 3, 2); + + assert_eq!(line[3], None); + assert_eq!(signal[3], None); + assert_eq!(histogram[3], None); + assert!(line[4].is_some()); + assert!(signal[4].is_some()); + assert!(histogram[4].is_some()); + } } diff --git a/core/src/indicators/psl.rs b/core/src/indicators/psl.rs index 115ea32..33e6c7a 100644 --- a/core/src/indicators/psl.rs +++ b/core/src/indicators/psl.rs @@ -1,4 +1,6 @@ -pub fn psl(closes: &[f64], period: usize) -> Vec> { +use crate::utils::rolling_sum_strict; + +pub fn psl(closes: &[Option], period: usize) -> Vec> { let mut psl = vec![None; closes.len()]; let len = closes.len(); @@ -6,29 +8,18 @@ pub fn psl(closes: &[f64], period: usize) -> Vec> { return psl; } - let mut count = 0; - - // Count initial positive price changes - for i in 1..period { - if closes[i] > closes[i - 1] { - count += 1; - } - } + let changes = closes + .windows(2) + .map(|window| match (window[0], window[1]) { + (Some(prev), Some(current)) => Some(if current > prev { 1.0 } else { 0.0 }), + _ => None, + }) + .collect::>(); - // Calculate PSL for the rest of the series + let sums = rolling_sum_strict(&changes, period); for i in period..len { - // Add current price change to the count - if closes[i] > closes[i - 1] { - count += 1; - } - - // Calculate PSL value - let psl_value = (count as f64 / period as f64) * 100.0; - psl[i] = Some(psl_value); - - // Remove oldest price change from the count - if closes[i - period + 1] > closes[i - period] { - count -= 1; + if let Some(sum) = sums[i - 1] { + psl[i] = Some((sum / period as f64) * 100.0); } } @@ -45,7 +36,10 @@ mod tests { fn test_psl() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = psl(&close, 12); let expected = testutils::load_expected::>(&format!( "../data/expected/psl_{}.json", @@ -60,4 +54,13 @@ mod tests { ); } } + + #[test] + fn test_psl_with_gap_invalidates_change_window() { + let close = vec![Some(1.0), Some(2.0), Some(3.0), None, Some(4.0), Some(5.0)]; + + let result = psl(&close, 2); + + assert_eq!(result, vec![None, None, Some(100.0), None, None, None]); + } } diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index c9ae630..6108e19 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -1,7 +1,7 @@ -use crate::indicators::ema::{ema_aligned, ema_dense}; +use crate::indicators::ema::ema_aligned; pub fn pvo( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -21,7 +21,7 @@ pub fn pvo( } pub fn pvo_histogram( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -39,7 +39,7 @@ pub fn pvo_histogram( } pub fn pvo_signal( - data: &[f64], + data: &[Option], fast_period: usize, slow_period: usize, signal_period: usize, @@ -48,15 +48,15 @@ pub fn pvo_signal( ema_aligned(&pvo_line, signal_period) } -pub fn pvo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { +pub fn pvo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { let mut pvo_line = vec![None; data.len()]; if data.len() < slow_period || fast_period >= slow_period { return pvo_line; } - let fast_ema = ema_dense(data, fast_period); - let slow_ema = ema_dense(data, slow_period); + let fast_ema = ema_aligned(data, fast_period); + let slow_ema = ema_aligned(data, slow_period); for i in (slow_period - 1)..data.len() { if let (Some(fast), Some(slow)) = (fast_ema[i], slow_ema[i]) { @@ -83,7 +83,10 @@ mod tests { // When for symbol in test_cases { - let input = testutils::load_data(&format!("../data/{}.json", symbol), "v"); + let input = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let (pvo_line, signal_line, histogram) = pvo(&input, 12, 26, 9); let expected_pvo = testutils::load_expected::>(&format!( diff --git a/core/src/indicators/roc.rs b/core/src/indicators/roc.rs index f460bd8..d968232 100644 --- a/core/src/indicators/roc.rs +++ b/core/src/indicators/roc.rs @@ -1,4 +1,4 @@ -pub fn roc(closes: &[f64], period: usize) -> Vec> { +pub fn roc(closes: &[Option], period: usize) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; @@ -7,9 +7,9 @@ pub fn roc(closes: &[f64], period: usize) -> Vec> { } for i in period..len { - let curr_close = closes[i]; - let prev_close = closes[i - period]; - result[i] = Some(((curr_close - prev_close) / prev_close) * 100.0); + if let (Some(curr_close), Some(prev_close)) = (closes[i], closes[i - period]) { + result[i] = Some(((curr_close - prev_close) / prev_close) * 100.0); + } } result @@ -25,7 +25,10 @@ mod tests { fn test_roc() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = roc(&close, 20); let expected = testutils::load_expected::>(&format!( "../data/expected/roc_{}.json", diff --git a/core/src/indicators/rsi.rs b/core/src/indicators/rsi.rs index 84a5ab6..d1ae07b 100644 --- a/core/src/indicators/rsi.rs +++ b/core/src/indicators/rsi.rs @@ -1,8 +1,3 @@ -pub(crate) fn rsi_dense(data: &[f64], period: usize) -> Vec> { - let nullable = data.iter().copied().map(Some).collect::>(); - rsi(&nullable, period) -} - pub fn rsi(data: &[Option], period: usize) -> Vec> { let mut rsi = vec![None; data.len()]; diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index e462d44..8ee7d0b 100644 --- a/core/src/indicators/sonar.rs +++ b/core/src/indicators/sonar.rs @@ -1,7 +1,7 @@ -use crate::indicators::ema::{ema_aligned, ema_dense}; +use crate::indicators::ema::ema_aligned; pub fn sonar( - data: &[f64], + data: &[Option], period: usize, step: usize, signal_period: usize, @@ -13,7 +13,7 @@ pub fn sonar( } pub fn sonar_signal( - data: &[f64], + data: &[Option], period: usize, step: usize, signal_period: usize, @@ -22,14 +22,14 @@ pub fn sonar_signal( ema_aligned(&sonar_line, signal_period) } -pub fn sonar_line(data: &[f64], period: usize, step: usize) -> Vec> { +pub fn sonar_line(data: &[Option], period: usize, step: usize) -> Vec> { let mut sonar_line = vec![None; data.len()]; if data.len() < period + step { return sonar_line; } - let ema_values = ema_dense(data, period); + let ema_values = ema_aligned(data, period); for i in (period + step - 1)..data.len() { if let (Some(current_ema), Some(previous_ema)) = (ema_values[i], ema_values[i - step]) { @@ -54,7 +54,10 @@ mod tests { // When for symbol in test_cases { - let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let input = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (sonar_line, signal_line) = sonar(&input, 9, 6, 5); let expected_sonar = testutils::load_expected::>(&format!( @@ -81,4 +84,24 @@ mod tests { ); } } + + #[test] + fn test_sonar_with_gap_requires_valid_current_and_stepped_prior_state() { + let input = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + None, + Some(4.0), + Some(5.0), + Some(6.0), + ]; + + let line = sonar_line(&input, 2, 2); + + assert_eq!( + line, + vec![None, None, None, None, Some(1.0), None, Some(2.0)] + ); + } } diff --git a/core/src/indicators/stochf.rs b/core/src/indicators/stochf.rs index 474567e..d349eb3 100644 --- a/core/src/indicators/stochf.rs +++ b/core/src/indicators/stochf.rs @@ -1,15 +1,15 @@ use crate::utils::{rolling_max_min, rolling_mean_strict}; pub fn stochf_percent_k( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], fastk_period: usize, ) -> Vec> { let len = closes.len(); let mut percent_k = vec![None; len]; - if len < fastk_period { + if len != highs.len() || len != lows.len() || len < fastk_period || fastk_period == 0 { return percent_k; } @@ -20,10 +20,14 @@ pub fn stochf_percent_k( continue; }; + let Some(close) = closes[i] else { + continue; + }; + percent_k[i] = if max_high == min_low { None } else { - Some(((closes[i] - min_low) / (max_high - min_low)) * 100.0) + Some(((close - min_low) / (max_high - min_low)) * 100.0) }; } @@ -49,9 +53,9 @@ pub fn stochf_percent_d( } pub fn stochf( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], fastk_period: usize, fastd_period: usize, ) -> (Vec>, Vec>) { @@ -70,9 +74,18 @@ mod tests { fn test_stochf() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (percent_k, percent_d) = stochf(&highs, &lows, &closes, 14, 3); @@ -99,4 +112,16 @@ mod tests { ); } } + + #[test] + fn test_stochf_with_gap_invalidates_window() { + let highs = vec![Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; + let lows = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)]; + let closes = vec![Some(2.0), Some(3.0), None, Some(5.0), Some(6.0)]; + + let (percent_k, percent_d) = stochf(&highs, &lows, &closes, 3, 2); + + assert_eq!(percent_k, vec![None, None, None, None, None]); + assert_eq!(percent_d, vec![None, None, None, None, None]); + } } diff --git a/core/src/indicators/stochrsi.rs b/core/src/indicators/stochrsi.rs index 4fda02f..5bae5c5 100644 --- a/core/src/indicators/stochrsi.rs +++ b/core/src/indicators/stochrsi.rs @@ -1,8 +1,8 @@ -use crate::indicators::rsi::rsi_dense; +use crate::indicators::rsi::rsi; use crate::utils::{rolling_max_min, rolling_mean_strict}; pub fn stochrsi( - closes: &[f64], + closes: &[Option], period_rsi: usize, period_k: usize, period_d: usize, @@ -14,24 +14,10 @@ pub fn stochrsi( return (percent_k, vec![None; len]); } - let rsi_values = rsi_dense(closes, period_rsi); - let rsi_values_with_nan: Vec = rsi_values - .iter() - .map(|value| value.unwrap_or(f64::NAN)) - .collect(); - let (rolling_max, rolling_min) = - rolling_max_min(&rsi_values_with_nan, &rsi_values_with_nan, period_k); + let rsi_values = rsi(closes, period_rsi); + let (rolling_max, rolling_min) = rolling_max_min(&rsi_values, &rsi_values, period_k); for i in (period_rsi + period_k - 1)..len { - let valid_values: Vec = rsi_values[i + 1 - period_k..=i] - .iter() - .filter_map(|&x| x) - .collect(); - - if valid_values.len() != period_k { - continue; - } - let (Some(rsi), Some(rsi_max), Some(rsi_min)) = (rsi_values[i], rolling_max[i], rolling_min[i]) else { @@ -64,7 +50,10 @@ mod tests { fn test_stochrsi() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (percent_k, percent_d) = stochrsi(&closes, 14, 14, 3); @@ -91,4 +80,29 @@ mod tests { ); } } + + #[test] + fn test_stochrsi_with_gap_invalidates_rsi_window() { + let closes = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + Some(2.0), + None, + Some(4.0), + Some(5.0), + Some(6.0), + ]; + + let (percent_k, percent_d) = stochrsi(&closes, 3, 2, 2); + + assert_eq!( + percent_k, + vec![None, None, None, None, None, None, None, Some(100.0)] + ); + assert_eq!( + percent_d, + vec![None, None, None, None, None, None, None, None] + ); + } } diff --git a/core/src/indicators/stochs.rs b/core/src/indicators/stochs.rs index 3bfcb3d..99a1975 100644 --- a/core/src/indicators/stochs.rs +++ b/core/src/indicators/stochs.rs @@ -1,15 +1,15 @@ use crate::utils::{rolling_max_min, rolling_mean_strict}; fn stochs_raw_k( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], fastk_period: usize, ) -> Vec> { let len = closes.len(); let mut raw_k = vec![None; len]; - if len < fastk_period { + if len != highs.len() || len != lows.len() || len < fastk_period || fastk_period == 0 { return raw_k; } @@ -20,10 +20,14 @@ fn stochs_raw_k( continue; }; + let Some(close) = closes[i] else { + continue; + }; + raw_k[i] = if max_high == min_low { None } else { - Some(((closes[i] - min_low) / (max_high - min_low)) * 100.0) + Some(((close - min_low) / (max_high - min_low)) * 100.0) }; } @@ -31,9 +35,9 @@ fn stochs_raw_k( } pub fn stoch_percent_k( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], fastk_period: usize, slowk_period: usize, ) -> Vec> { @@ -48,9 +52,9 @@ pub fn stoch_percent_k( } pub fn stoch_percent_d( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], fastk_period: usize, slowk_period: usize, slowd_period: usize, @@ -79,9 +83,9 @@ fn stoch_percent_d_from_k( } pub fn stochs( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], fastk_period: usize, slowk_period: usize, slowd_period: usize, @@ -101,9 +105,18 @@ mod tests { fn test_stochs() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (percent_k, percent_d) = stochs(&high, &low, &close, 14, 3, 3); @@ -130,4 +143,16 @@ mod tests { ); } } + + #[test] + fn test_stochs_with_gap_invalidates_slow_windows() { + let high = vec![Some(3.0), Some(4.0), Some(5.0), None, Some(7.0), Some(8.0)]; + let low = vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0), Some(6.0)]; + let close = vec![Some(2.0), Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; + + let (percent_k, percent_d) = stochs(&high, &low, &close, 3, 2, 2); + + assert_eq!(percent_k, vec![None, None, None, None, None, None]); + assert_eq!(percent_d, vec![None, None, None, None, None, None]); + } } diff --git a/core/src/indicators/willr.rs b/core/src/indicators/willr.rs index 2c78214..5c84e48 100644 --- a/core/src/indicators/willr.rs +++ b/core/src/indicators/willr.rs @@ -1,10 +1,15 @@ use crate::utils::rolling_max_min; -pub fn willr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec> { +pub fn willr( + highs: &[Option], + lows: &[Option], + closes: &[Option], + period: usize, +) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; - if len < period { + if len != highs.len() || len != lows.len() || len < period || period == 0 { return result; } @@ -15,7 +20,9 @@ pub fn willr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec< continue; }; - let cc = closes[i]; + let Some(cc) = closes[i] else { + continue; + }; if max_high == min_low { result[i] = None; } else { @@ -39,6 +46,9 @@ mod tests { let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let high = high.into_iter().map(Some).collect::>(); + let low = low.into_iter().map(Some).collect::>(); + let close = close.into_iter().map(Some).collect::>(); let result = willr(&high, &low, &close, 14); let expected = testutils::load_expected::>(&format!( "../data/expected/willr_{}.json", diff --git a/core/src/utils.rs b/core/src/utils.rs index 8f657bd..7653635 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -30,86 +30,127 @@ pub fn find_min(data: &[f64]) -> f64 { data.iter().cloned().fold(f64::INFINITY, f64::min) } -/// Computes rolling max/min values over paired slices using a monotonic queue. +/// Computes rolling max/min values over paired aligned slices using a monotonic queue. /// -/// NaN inputs are skipped. If a full window contains no finite values for one side, -/// that side returns `None` for the window. +/// A window only emits values when both input slices contain a full valid window. pub fn rolling_max_min( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period: usize, ) -> (Vec>, Vec>) { let len = highs.len(); let mut rolling_max = vec![None; len]; let mut rolling_min = vec![None; len]; - if len < period || period == 0 { + if len != lows.len() || len < period || period == 0 { return (rolling_max, rolling_min); } let mut max_deque = VecDeque::with_capacity(period); let mut min_deque = VecDeque::with_capacity(period); + let mut valid_highs = 0usize; + let mut valid_lows = 0usize; for i in 0..len { - push_max_index(&mut max_deque, highs, i); - push_min_index(&mut min_deque, lows, i); + if highs[i].is_some() { + push_max_index(&mut max_deque, highs, i); + valid_highs += 1; + } + if lows[i].is_some() { + push_min_index(&mut min_deque, lows, i); + valid_lows += 1; + } if i + 1 < period { continue; } + if i >= period { + if highs[i - period].is_some() { + valid_highs -= 1; + } + if lows[i - period].is_some() { + valid_lows -= 1; + } + } + let earliest_idx = i + 1 - period; evict_expired(&mut max_deque, earliest_idx); evict_expired(&mut min_deque, earliest_idx); - rolling_max[i] = max_deque.front().map(|&idx| highs[idx]); - rolling_min[i] = min_deque.front().map(|&idx| lows[idx]); + if valid_highs == period && valid_lows == period { + rolling_max[i] = max_deque.front().and_then(|&idx| highs[idx]); + rolling_min[i] = min_deque.front().and_then(|&idx| lows[idx]); + } } (rolling_max, rolling_min) } -/// Computes rolling argmax/argmin source indices over paired slices using a monotonic queue. +/// Computes rolling argmax/argmin source indices over paired aligned slices. /// -/// Returned indices refer to the original input slices. NaN inputs are skipped, so a -/// window with no finite values on one side returns `None` for that side. +/// Returned indices refer to the original input slices. A window only emits indices +/// when both inputs are fully valid across the full window. pub fn rolling_argmax_argmin( - highs: &[f64], - lows: &[f64], + highs: &[Option], + lows: &[Option], period: usize, ) -> (Vec>, Vec>) { let len = highs.len(); let mut max_indices = vec![None; len]; let mut min_indices = vec![None; len]; - if len < period || period == 0 { + if len != lows.len() || len < period || period == 0 { return (max_indices, min_indices); } let mut max_deque = VecDeque::with_capacity(period); let mut min_deque = VecDeque::with_capacity(period); + let mut valid_highs = 0usize; + let mut valid_lows = 0usize; for i in 0..len { - push_max_index(&mut max_deque, highs, i); - push_min_index(&mut min_deque, lows, i); + if highs[i].is_some() { + push_max_index(&mut max_deque, highs, i); + valid_highs += 1; + } + if lows[i].is_some() { + push_min_index(&mut min_deque, lows, i); + valid_lows += 1; + } if i + 1 < period { continue; } + if i >= period { + if highs[i - period].is_some() { + valid_highs -= 1; + } + if lows[i - period].is_some() { + valid_lows -= 1; + } + } + let earliest_idx = i + 1 - period; evict_expired(&mut max_deque, earliest_idx); evict_expired(&mut min_deque, earliest_idx); - max_indices[i] = max_deque.front().copied(); - min_indices[i] = min_deque.front().copied(); + if valid_highs == period && valid_lows == period { + max_indices[i] = max_deque.front().copied(); + min_indices[i] = min_deque.front().copied(); + } } (max_indices, min_indices) } /// Computes the midpoint of the rolling high/low channel for each full window. -pub fn rolling_midpoint(highs: &[f64], lows: &[f64], period: usize) -> Vec> { +pub fn rolling_midpoint( + highs: &[Option], + lows: &[Option], + period: usize, +) -> Vec> { let (rolling_max, rolling_min) = rolling_max_min(highs, lows, period); rolling_max .into_iter() @@ -276,13 +317,10 @@ pub fn rolling_mean_stddev_strict( (means, stddevs) } -fn push_max_index(deque: &mut VecDeque, data: &[f64], idx: usize) { - if data[idx].is_nan() { - return; - } - +fn push_max_index(deque: &mut VecDeque, data: &[Option], idx: usize) { + let current = data[idx].expect("push_max_index requires a present value"); while let Some(&back) = deque.back() { - if data[back] <= data[idx] { + if data[back].expect("deque indices always refer to present values") <= current { deque.pop_back(); } else { break; @@ -291,13 +329,10 @@ fn push_max_index(deque: &mut VecDeque, data: &[f64], idx: usize) { deque.push_back(idx); } -fn push_min_index(deque: &mut VecDeque, data: &[f64], idx: usize) { - if data[idx].is_nan() { - return; - } - +fn push_min_index(deque: &mut VecDeque, data: &[Option], idx: usize) { + let current = data[idx].expect("push_min_index requires a present value"); while let Some(&back) = deque.back() { - if data[back] >= data[idx] { + if data[back].expect("deque indices always refer to present values") >= current { deque.pop_back(); } else { break; @@ -504,8 +539,8 @@ mod tests { #[test] fn test_rolling_midpoint() { - let highs = vec![10.0, 12.0, 14.0, 16.0, 18.0]; - let lows = vec![4.0, 6.0, 8.0, 10.0, 12.0]; + let highs = vec![Some(10.0), Some(12.0), Some(14.0), Some(16.0), Some(18.0)]; + let lows = vec![Some(4.0), Some(6.0), Some(8.0), Some(10.0), Some(12.0)]; let result = rolling_midpoint(&highs, &lows, 3); @@ -514,8 +549,8 @@ mod tests { #[test] fn test_rolling_max_min() { - let highs = vec![1.0, 3.0, 2.0, 5.0, 4.0]; - let lows = vec![5.0, 2.0, 3.0, 1.0, 4.0]; + let highs = vec![Some(1.0), Some(3.0), Some(2.0), Some(5.0), Some(4.0)]; + let lows = vec![Some(5.0), Some(2.0), Some(3.0), Some(1.0), Some(4.0)]; let (rolling_max, rolling_min) = rolling_max_min(&highs, &lows, 3); @@ -531,8 +566,8 @@ mod tests { #[test] fn test_rolling_argmax_argmin_prefers_latest_duplicate() { - let highs = vec![1.0, 5.0, 5.0, 2.0]; - let lows = vec![4.0, 1.0, 1.0, 3.0]; + let highs = vec![Some(1.0), Some(5.0), Some(5.0), Some(2.0)]; + let lows = vec![Some(4.0), Some(1.0), Some(1.0), Some(3.0)]; let (max_indices, min_indices) = rolling_argmax_argmin(&highs, &lows, 3); @@ -541,31 +576,31 @@ mod tests { } #[test] - fn test_rolling_max_min_ignores_nan_when_finite_values_exist() { - let highs = vec![1.0, f64::NAN, 3.0]; - let lows = vec![5.0, f64::NAN, 2.0]; + fn test_rolling_max_min_invalidates_window_with_gap() { + let highs = vec![Some(1.0), None, Some(3.0)]; + let lows = vec![Some(5.0), None, Some(2.0)]; let (rolling_max, rolling_min) = rolling_max_min(&highs, &lows, 2); - assert_eq!(rolling_max, vec![None, Some(1.0), Some(3.0)]); - assert_eq!(rolling_min, vec![None, Some(5.0), Some(2.0)]); + assert_eq!(rolling_max, vec![None, None, None]); + assert_eq!(rolling_min, vec![None, None, None]); } #[test] - fn test_rolling_argmax_argmin_ignore_nan_when_finite_values_exist() { - let highs = vec![1.0, f64::NAN, 5.0]; - let lows = vec![4.0, f64::NAN, 1.0]; + fn test_rolling_argmax_argmin_invalidates_window_with_gap() { + let highs = vec![Some(1.0), None, Some(5.0)]; + let lows = vec![Some(4.0), None, Some(1.0)]; let (max_indices, min_indices) = rolling_argmax_argmin(&highs, &lows, 2); - assert_eq!(max_indices, vec![None, Some(0), Some(2)]); - assert_eq!(min_indices, vec![None, Some(0), Some(2)]); + assert_eq!(max_indices, vec![None, None, None]); + assert_eq!(min_indices, vec![None, None, None]); } #[test] - fn test_rolling_max_min_all_nan_window_returns_none() { - let highs = vec![f64::NAN, f64::NAN, f64::NAN]; - let lows = vec![f64::NAN, f64::NAN, f64::NAN]; + fn test_rolling_extrema_all_gap_window_returns_none() { + let highs = vec![None, None, None]; + let lows = vec![None, None, None]; let (rolling_max, rolling_min) = rolling_max_min(&highs, &lows, 2); let (max_indices, min_indices) = rolling_argmax_argmin(&highs, &lows, 2); From 656439ef745c24a4db08adf94fabe35a2522432e Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 12:05:39 +0900 Subject: [PATCH 2/6] Reseed sparse composite signals after gaps --- core/src/indicators/macd.rs | 46 ++++++++++++++++++++----- core/src/indicators/massi.rs | 67 ++++++++++++++++++++++++++++++++++-- core/src/indicators/ppo.rs | 34 +++++++++++------- core/src/indicators/pvo.rs | 40 ++++++++++++++++++--- core/src/indicators/sonar.rs | 40 +++++++++++++++++++-- 5 files changed, 196 insertions(+), 31 deletions(-) diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index 008390b..29d356b 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; pub fn macd( data: &[Option], @@ -7,7 +7,7 @@ pub fn macd( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let macd_line = macd_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&macd_line, signal_period); + let signal_line = ema_reseed_on_gap(&macd_line, signal_period); let histogram = macd_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn macd_histogram( signal_period: usize, ) -> Vec> { let macd_line = macd_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&macd_line, signal_period); + let signal_line = ema_reseed_on_gap(&macd_line, signal_period); macd_line .iter() @@ -46,7 +46,7 @@ pub fn macd_signal( signal_period: usize, ) -> Vec> { let macd_line = macd_line(data, fast_period, slow_period); - ema_aligned(&macd_line, signal_period) + ema_reseed_on_gap(&macd_line, signal_period) } pub fn macd_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -124,30 +124,58 @@ mod tests { } #[test] - fn test_macd_with_interior_gap_propagates_to_composite_outputs() { + fn test_macd_signal_reseeds_after_sparse_gap() { let input = vec![ Some(1.0), Some(2.0), Some(3.0), - None, Some(4.0), + None, Some(5.0), Some(6.0), + Some(7.0), ]; let (line, signal, histogram) = macd(&input, 2, 3, 2); assert_eq!( line, - vec![None, None, Some(0.5), None, Some(0.5), Some(0.5), Some(0.5)] + vec![ + None, + None, + Some(0.5), + Some(0.5), + None, + Some(0.5), + Some(0.5), + Some(0.5), + ] ); assert_eq!( signal, - vec![None, None, None, None, Some(0.5), Some(0.5), Some(0.5)] + vec![ + None, + None, + None, + Some(0.5), + None, + None, + Some(0.5), + Some(0.5), + ] ); assert_eq!( histogram, - vec![None, None, None, None, Some(0.0), Some(0.0), Some(0.0)] + vec![ + None, + None, + None, + Some(0.0), + None, + None, + Some(0.0), + Some(0.0), + ] ); } } diff --git a/core/src/indicators/massi.rs b/core/src/indicators/massi.rs index ff54c0d..bed0531 100644 --- a/core/src/indicators/massi.rs +++ b/core/src/indicators/massi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; use crate::utils::rolling_sum_strict; pub fn massi( @@ -9,7 +9,7 @@ pub fn massi( period_signal: usize, ) -> (Vec>, Vec>) { let mass = massi_line(highs, lows, period_ema, period_sum); - let signal = ema_aligned(&mass, period_signal); + let signal = ema_reseed_on_gap(&mass, period_signal); (mass, signal) } @@ -22,7 +22,7 @@ pub fn massi_signal( period_signal: usize, ) -> Vec> { let mass = massi_line(highs, lows, period_ema, period_sum); - ema_aligned(&mass, period_signal) + ema_reseed_on_gap(&mass, period_signal) } pub fn massi_line( @@ -139,4 +139,65 @@ mod tests { vec![None, None, None, None, None, Some(2.0), Some(2.0)] ); } + + #[test] + fn test_massi_signal_reseeds_after_sparse_gap() { + let highs = vec![ + Some(5.0), + Some(6.0), + Some(7.0), + Some(8.0), + Some(9.0), + None, + Some(10.0), + Some(11.0), + Some(12.0), + Some(13.0), + ]; + let lows = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + Some(4.0), + Some(5.0), + None, + Some(6.0), + Some(7.0), + Some(8.0), + Some(9.0), + ]; + + let (line, signal) = massi(&highs, &lows, 2, 2, 2); + + assert_eq!( + line, + vec![ + None, + None, + None, + Some(2.0), + Some(2.0), + None, + None, + Some(2.0), + Some(2.0), + Some(2.0), + ] + ); + assert_eq!( + signal, + vec![ + None, + None, + None, + None, + Some(2.0), + None, + None, + None, + Some(2.0), + Some(2.0), + ] + ); + } } diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index 3a14000..1dbd5ea 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; pub fn ppo( data: &[Option], @@ -7,7 +7,7 @@ pub fn ppo( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let ppo_line = ppo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&ppo_line, signal_period); + let signal_line = ema_reseed_on_gap(&ppo_line, signal_period); let histogram = ppo_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn ppo_histogram( signal_period: usize, ) -> Vec> { let ppo_line = ppo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&ppo_line, signal_period); + let signal_line = ema_reseed_on_gap(&ppo_line, signal_period); ppo_line .iter() .zip(signal_line.iter()) @@ -45,7 +45,7 @@ pub fn ppo_signal( signal_period: usize, ) -> Vec> { let ppo_line = ppo_line(data, fast_period, slow_period); - ema_aligned(&ppo_line, signal_period) + ema_reseed_on_gap(&ppo_line, signal_period) } pub fn ppo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -125,24 +125,34 @@ mod tests { } #[test] - fn test_ppo_with_interior_gap_keeps_alignment() { + fn test_ppo_signal_reseeds_after_sparse_gap() { let input = vec![ Some(10.0), Some(11.0), Some(12.0), - None, Some(13.0), + None, Some(14.0), Some(15.0), + Some(16.0), ]; let (line, signal, histogram) = ppo(&input, 2, 3, 2); - assert_eq!(line[3], None); - assert_eq!(signal[3], None); - assert_eq!(histogram[3], None); - assert!(line[4].is_some()); - assert!(signal[4].is_some()); - assert!(histogram[4].is_some()); + assert!(line[3].is_some()); + assert_eq!(line[4], None); + assert!(line[5].is_some()); + assert!(line[6].is_some()); + + assert!(signal[3].is_some()); + assert_eq!(signal[4], None); + assert_eq!(signal[5], None); + assert!((signal[6].unwrap() - ((line[5].unwrap() + line[6].unwrap()) / 2.0)).abs() < 1e-12); + assert!(signal[7].unwrap() < signal[6].unwrap()); + + assert!(histogram[3].is_some()); + assert_eq!(histogram[4], None); + assert_eq!(histogram[5], None); + assert!(histogram[6].is_some()); } } diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index 6108e19..31fe530 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; pub fn pvo( data: &[Option], @@ -7,7 +7,7 @@ pub fn pvo( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let pvo_line = pvo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&pvo_line, signal_period); + let signal_line = ema_reseed_on_gap(&pvo_line, signal_period); let histogram = pvo_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn pvo_histogram( signal_period: usize, ) -> Vec> { let pvo_line = pvo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&pvo_line, signal_period); + let signal_line = ema_reseed_on_gap(&pvo_line, signal_period); pvo_line .iter() .zip(signal_line.iter()) @@ -45,7 +45,7 @@ pub fn pvo_signal( signal_period: usize, ) -> Vec> { let pvo_line = pvo_line(data, fast_period, slow_period); - ema_aligned(&pvo_line, signal_period) + ema_reseed_on_gap(&pvo_line, signal_period) } pub fn pvo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -123,4 +123,36 @@ mod tests { ); } } + + #[test] + fn test_pvo_signal_reseeds_after_sparse_gap() { + let input = vec![ + Some(10.0), + Some(11.0), + Some(12.0), + Some(13.0), + None, + Some(14.0), + Some(15.0), + Some(16.0), + ]; + + let (line, signal, histogram) = pvo(&input, 2, 3, 2); + + assert!(line[3].is_some()); + assert_eq!(line[4], None); + assert!(line[5].is_some()); + assert!(line[6].is_some()); + + assert!(signal[3].is_some()); + assert_eq!(signal[4], None); + assert_eq!(signal[5], None); + assert!((signal[6].unwrap() - ((line[5].unwrap() + line[6].unwrap()) / 2.0)).abs() < 1e-12); + assert!(signal[7].unwrap() < signal[6].unwrap()); + + assert!(histogram[3].is_some()); + assert_eq!(histogram[4], None); + assert_eq!(histogram[5], None); + assert!(histogram[6].is_some()); + } } diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index 8ee7d0b..e42ecda 100644 --- a/core/src/indicators/sonar.rs +++ b/core/src/indicators/sonar.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; pub fn sonar( data: &[Option], @@ -7,7 +7,7 @@ pub fn sonar( signal_period: usize, ) -> (Vec>, Vec>) { let sonar_line = sonar_line(data, period, step); - let signal_line = ema_aligned(&sonar_line, signal_period); + let signal_line = ema_reseed_on_gap(&sonar_line, signal_period); (sonar_line, signal_line) } @@ -19,7 +19,7 @@ pub fn sonar_signal( signal_period: usize, ) -> Vec> { let sonar_line = sonar_line(data, period, step); - ema_aligned(&sonar_line, signal_period) + ema_reseed_on_gap(&sonar_line, signal_period) } pub fn sonar_line(data: &[Option], period: usize, step: usize) -> Vec> { @@ -104,4 +104,38 @@ mod tests { vec![None, None, None, None, Some(1.0), None, Some(2.0)] ); } + + #[test] + fn test_sonar_signal_reseeds_after_sparse_gap() { + let input = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + Some(4.0), + None, + Some(5.0), + Some(6.0), + Some(7.0), + ]; + + let (line, signal) = sonar(&input, 2, 1, 2); + + assert_eq!( + line, + vec![ + None, + None, + Some(1.0), + Some(1.0), + None, + None, + Some(1.0), + Some(1.0), + ] + ); + assert_eq!( + signal, + vec![None, None, None, Some(1.0), None, None, None, Some(1.0),] + ); + } } From f86f146c7831a375f1ac2b1b9f971b45e27abcdb Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 12:17:44 +0900 Subject: [PATCH 3/6] Trim composite lane to sparse reseeds --- core/src/indicators/aroon.rs | 21 +----- core/src/indicators/aroonosc.rs | 16 +---- core/src/indicators/cci.rs | 69 +++--------------- core/src/indicators/cv.rs | 35 ++------- core/src/indicators/ema.rs | 1 + core/src/indicators/erbear.rs | 22 +++--- core/src/indicators/erbull.rs | 22 +++--- core/src/indicators/ichimoku.rs | 69 +++--------------- core/src/indicators/mom.rs | 11 +-- core/src/indicators/pchan.rs | 20 +----- core/src/indicators/psl.rs | 49 ++++++------- core/src/indicators/roc.rs | 13 ++-- core/src/indicators/rsi.rs | 5 ++ core/src/indicators/sma.rs | 1 - core/src/indicators/stochf.rs | 47 +++---------- core/src/indicators/stochrsi.rs | 52 +++++--------- core/src/indicators/stochs.rs | 59 +++++----------- core/src/indicators/willr.rs | 16 +---- core/src/utils.rs | 121 +++++++++++--------------------- 19 files changed, 178 insertions(+), 471 deletions(-) diff --git a/core/src/indicators/aroon.rs b/core/src/indicators/aroon.rs index 5a6920b..104db40 100644 --- a/core/src/indicators/aroon.rs +++ b/core/src/indicators/aroon.rs @@ -1,14 +1,10 @@ use crate::utils::rolling_argmax_argmin; -pub fn aroon( - highs: &[Option], - lows: &[Option], - period: usize, -) -> (Vec>, Vec>) { +pub fn aroon(highs: &[f64], lows: &[f64], period: usize) -> (Vec>, Vec>) { let mut aroon_up = vec![None; highs.len()]; let mut aroon_down = vec![None; lows.len()]; - if highs.len() != lows.len() || highs.len() < period { + if highs.len() < period { return (aroon_up, aroon_down); } @@ -45,8 +41,6 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let highs = highs.into_iter().map(Some).collect::>(); - let lows = lows.into_iter().map(Some).collect::>(); let (aroon_up, aroon_down) = aroon(&highs, &lows, 25); let expected_up = testutils::load_expected::>(&format!( @@ -72,15 +66,4 @@ mod tests { ); } } - - #[test] - fn test_aroon_with_gap_invalidates_window() { - let highs = vec![Some(1.0), Some(5.0), None, Some(2.0)]; - let lows = vec![Some(4.0), Some(1.0), None, Some(3.0)]; - - let (up, down) = aroon(&highs, &lows, 2); - - assert_eq!(up, vec![None, None, None, None]); - assert_eq!(down, vec![None, None, None, None]); - } } diff --git a/core/src/indicators/aroonosc.rs b/core/src/indicators/aroonosc.rs index 4e9357d..abf0518 100644 --- a/core/src/indicators/aroonosc.rs +++ b/core/src/indicators/aroonosc.rs @@ -1,9 +1,9 @@ use crate::indicators::aroon; -pub fn aroonosc(highs: &[Option], lows: &[Option], period: usize) -> Vec> { +pub fn aroonosc(highs: &[f64], lows: &[f64], period: usize) -> Vec> { let mut aroonosc = vec![None; highs.len()]; - if highs.len() != lows.len() || highs.len() < period { + if highs.len() < period { return aroonosc; } @@ -30,8 +30,6 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let highs = highs.into_iter().map(Some).collect::>(); - let lows = lows.into_iter().map(Some).collect::>(); let result = aroonosc(&highs, &lows, 25); let expected = testutils::load_expected::>(&format!( @@ -47,14 +45,4 @@ mod tests { ); } } - - #[test] - fn test_aroonosc_mismatched_lengths_fail_closed() { - let highs = vec![Some(1.0), Some(2.0), Some(3.0)]; - let lows = vec![Some(1.0), Some(2.0)]; - - let result = aroonosc(&highs, &lows, 2); - - assert_eq!(result, vec![None, None, None]); - } } diff --git a/core/src/indicators/cci.rs b/core/src/indicators/cci.rs index b0b74dd..8829d34 100644 --- a/core/src/indicators/cci.rs +++ b/core/src/indicators/cci.rs @@ -1,11 +1,4 @@ -use crate::utils::rolling_mean_strict; - -pub fn cci( - highs: &[Option], - lows: &[Option], - closes: &[Option], - period: usize, -) -> Vec> { +pub fn cci(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec> { let len = highs.len(); let mut result = vec![None; len]; @@ -13,42 +6,22 @@ pub fn cci( return result; } - let typical_prices = highs + let typical_prices: Vec = highs .iter() .zip(lows.iter()) .zip(closes.iter()) - .map(|((high, low), close)| match (high, low, close) { - (Some(high), Some(low), Some(close)) => Some((high + low + close) / 3.0), - _ => None, - }) - .collect::>(); - let sma_tp = rolling_mean_strict(&typical_prices, period); + .map(|((h, l), c)| (h + l + c) / 3.0) + .collect(); for i in period - 1..len { - let Some(sma_tp) = sma_tp[i] else { - continue; - }; - let mut mean_deviation = 0.0; - let mut valid = true; - for typical_price in &typical_prices[i + 1 - period..=i] { - let Some(typical_price) = *typical_price else { - valid = false; - break; - }; - mean_deviation += (typical_price - sma_tp).abs(); - } - if !valid { - continue; - } - mean_deviation /= period as f64; + let slice = &typical_prices[i + 1 - period..=i]; + let sma_tp: f64 = slice.iter().sum::() / period as f64; + let mean_deviation = slice.iter().map(|&x| (x - sma_tp).abs()).sum::() / period as f64; result[i] = if mean_deviation == 0.0 { None } else { - Some( - (typical_prices[i].expect("sma implies current value exists") - sma_tp) - / (0.015 * mean_deviation), - ) + Some((typical_prices[i] - sma_tp) / (0.015 * mean_deviation)) }; } @@ -65,18 +38,9 @@ mod tests { fn test_cci() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") - .into_iter() - .map(Some) - .collect::>(); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") - .into_iter() - .map(Some) - .collect::>(); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = cci(&high, &low, &close, 20); let expected = testutils::load_expected::>(&format!( "../data/expected/cci_{}.json", @@ -91,15 +55,4 @@ mod tests { ); } } - - #[test] - fn test_cci_with_gap_invalidates_typical_price_window() { - let high = vec![Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; - let low = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)]; - let close = vec![Some(2.0), Some(3.0), None, Some(5.0), Some(6.0)]; - - let result = cci(&high, &low, &close, 3); - - assert_eq!(result, vec![None, None, None, None, None]); - } } diff --git a/core/src/indicators/cv.rs b/core/src/indicators/cv.rs index 0ca2395..a06fd25 100644 --- a/core/src/indicators/cv.rs +++ b/core/src/indicators/cv.rs @@ -1,6 +1,6 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema_dense; -pub fn cv(highs: &[Option], lows: &[Option], period: usize) -> Vec> { +pub fn cv(highs: &[f64], lows: &[f64], period: usize) -> Vec> { let mut cv = vec![None; highs.len()]; let len = highs.len(); @@ -8,15 +8,8 @@ pub fn cv(highs: &[Option], lows: &[Option], period: usize) -> Vec Some(high - low), - _ => None, - }) - .collect::>(); - let ema_high_low_diffs = ema_aligned(&high_low_diffs, period); + let high_low_diffs: Vec = highs.iter().zip(lows.iter()).map(|(h, l)| h - l).collect(); + let ema_high_low_diffs = ema_dense(&high_low_diffs, period); for i in period * 2 - 1..len { if let (Some(current_ema), Some(previous_ema)) = @@ -40,14 +33,8 @@ mod tests { fn test_cv() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") - .into_iter() - .map(Some) - .collect::>(); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") - .into_iter() - .map(Some) - .collect::>(); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); let result = cv(&high, &low, 10); let expected = testutils::load_expected::>(&format!( "../data/expected/cv_{}.json", @@ -62,14 +49,4 @@ mod tests { ); } } - - #[test] - fn test_cv_with_gap_requires_both_ema_points() { - let high = vec![Some(5.0), Some(6.0), None, Some(8.0), Some(9.0), Some(10.0)]; - let low = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0), Some(6.0)]; - - let result = cv(&high, &low, 2); - - assert_eq!(result, vec![None, None, None, Some(0.0), None, Some(0.0)]); - } } diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 01bc4d0..70eb464 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -48,6 +48,7 @@ pub fn ema(data: &[Option], period: usize) -> Vec> { result } +pub(crate) use ema as ema_aligned; #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/erbear.rs b/core/src/indicators/erbear.rs index 2311dcf..f5ef92a 100644 --- a/core/src/indicators/erbear.rs +++ b/core/src/indicators/erbear.rs @@ -1,17 +1,17 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema_dense; -pub fn erbear(lows: &[Option], closes: &[Option], period: usize) -> Vec> { +pub fn erbear(lows: &[f64], closes: &[f64], period: usize) -> Vec> { let mut erbear = vec![None; lows.len()]; - if lows.len() != closes.len() || lows.len() < period || period == 0 { + if lows.len() < period { return erbear; } - let ema_values = ema_aligned(closes, period); + let ema_values = ema_dense(closes, period); for i in (period - 1)..lows.len() { - if let (Some(low), Some(ema_value)) = (lows[i], ema_values[i]) { - let bear_power = low - ema_value; + if let Some(ema_value) = ema_values[i] { + let bear_power = lows[i] - ema_value; erbear[i] = Some(bear_power); } } @@ -29,14 +29,8 @@ mod tests { fn test_erbear() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") - .into_iter() - .map(Some) - .collect::>(); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = erbear(&lows, &closes, 13); let expected = testutils::load_expected::>(&format!( "../data/expected/erbear_{}.json", diff --git a/core/src/indicators/erbull.rs b/core/src/indicators/erbull.rs index 4373d34..cda1125 100644 --- a/core/src/indicators/erbull.rs +++ b/core/src/indicators/erbull.rs @@ -1,17 +1,17 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema_dense; -pub fn erbull(highs: &[Option], closes: &[Option], period: usize) -> Vec> { +pub fn erbull(highs: &[f64], closes: &[f64], period: usize) -> Vec> { let mut erbull = vec![None; highs.len()]; - if highs.len() != closes.len() || highs.len() < period || period == 0 { + if highs.len() < period { return erbull; } - let ema_values = ema_aligned(closes, period); + let ema_values = ema_dense(closes, period); for i in (period - 1)..highs.len() { - if let (Some(high), Some(ema_value)) = (highs[i], ema_values[i]) { - let bull_power = high - ema_value; + if let Some(ema_value) = ema_values[i] { + let bull_power = highs[i] - ema_value; erbull[i] = Some(bull_power); } } @@ -29,14 +29,8 @@ mod tests { fn test_erbull() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") - .into_iter() - .map(Some) - .collect::>(); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = erbull(&highs, &closes, 13); let expected = testutils::load_expected::>(&format!( "../data/expected/erbull_{}.json", diff --git a/core/src/indicators/ichimoku.rs b/core/src/indicators/ichimoku.rs index 795324c..56afa82 100644 --- a/core/src/indicators/ichimoku.rs +++ b/core/src/indicators/ichimoku.rs @@ -17,23 +17,15 @@ fn leading_span_a_from_lines( forward_shift(span, base_line_period) } -pub fn ichimoku_conversion_line( - highs: &[Option], - lows: &[Option], - period: usize, -) -> Vec> { +pub fn ichimoku_conversion_line(highs: &[f64], lows: &[f64], period: usize) -> Vec> { rolling_midpoint(highs, lows, period) } -pub fn ichimoku_base_line( - highs: &[Option], - lows: &[Option], - period: usize, -) -> Vec> { +pub fn ichimoku_base_line(highs: &[f64], lows: &[f64], period: usize) -> Vec> { rolling_midpoint(highs, lows, period) } -pub fn ichimoku_lagging_span(closes: &[Option], base_line_period: usize) -> Vec> { +pub fn ichimoku_lagging_span(closes: &[f64], base_line_period: usize) -> Vec> { let len = closes.len(); let mut lagging_span = vec![None; len]; @@ -42,15 +34,15 @@ pub fn ichimoku_lagging_span(closes: &[Option], base_line_period: usize) -> } for i in (base_line_period - 1)..len { - lagging_span[i + 1 - base_line_period] = closes[i]; + lagging_span[i + 1 - base_line_period] = Some(closes[i]); } lagging_span } pub fn ichimoku_leading_span_a( - highs: &[Option], - lows: &[Option], + highs: &[f64], + lows: &[f64], conversion_line_period: usize, base_line_period: usize, ) -> Vec> { @@ -60,8 +52,8 @@ pub fn ichimoku_leading_span_a( } pub fn ichimoku_leading_span_b( - highs: &[Option], - lows: &[Option], + highs: &[f64], + lows: &[f64], period: usize, base_line_period: usize, ) -> Vec> { @@ -69,9 +61,9 @@ pub fn ichimoku_leading_span_b( } pub fn ichimoku( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], conversion_line_period: usize, base_line_period: usize, leading_span_b_period: usize, @@ -82,18 +74,6 @@ pub fn ichimoku( Vec>, // Leading span A Vec>, // Leading span B ) { - if highs.len() != lows.len() || highs.len() != closes.len() { - let len = highs.len(); - let projected_len = len.saturating_add(base_line_period.saturating_sub(1)); - return ( - vec![None; len], - vec![None; len], - vec![None; len], - vec![None; projected_len], - vec![None; projected_len], - ); - } - let conversion_line = ichimoku_conversion_line(highs, lows, conversion_line_period); let base_line = ichimoku_base_line(highs, lows, base_line_period); let lagging_span = ichimoku_lagging_span(closes, base_line_period); @@ -122,9 +102,6 @@ mod tests { let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); - let high = high.into_iter().map(Some).collect::>(); - let low = low.into_iter().map(Some).collect::>(); - let close = close.into_iter().map(Some).collect::>(); let (conversion_line, base_line, lagging_span, leading_span_a, leading_span_b) = ichimoku(&high, &low, &close, 9, 26, 52); @@ -182,28 +159,4 @@ mod tests { ); } } - - #[test] - fn test_ichimoku_lagging_span_preserves_gap_when_shifted() { - let closes = vec![Some(1.0), Some(2.0), None, Some(4.0)]; - - let lagging = ichimoku_lagging_span(&closes, 2); - - assert_eq!(lagging, vec![Some(2.0), None, Some(4.0), None]); - } - - #[test] - fn test_ichimoku_mismatched_lengths_fail_closed_with_aligned_shapes() { - let highs = vec![Some(1.0), Some(2.0), Some(3.0)]; - let lows = vec![Some(1.0), Some(2.0)]; - let closes = vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]; - - let (conversion, base, lagging, span_a, span_b) = ichimoku(&highs, &lows, &closes, 2, 3, 4); - - assert_eq!(conversion, vec![None, None, None]); - assert_eq!(base, vec![None, None, None]); - assert_eq!(lagging, vec![None, None, None]); - assert_eq!(span_a, vec![None, None, None, None, None]); - assert_eq!(span_b, vec![None, None, None, None, None]); - } } diff --git a/core/src/indicators/mom.rs b/core/src/indicators/mom.rs index 4fd3c8e..2bf07e1 100644 --- a/core/src/indicators/mom.rs +++ b/core/src/indicators/mom.rs @@ -1,4 +1,4 @@ -pub fn mom(closes: &[Option], period: usize) -> Vec> { +pub fn mom(closes: &[f64], period: usize) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; @@ -7,9 +7,7 @@ pub fn mom(closes: &[Option], period: usize) -> Vec> { } for i in period..len { - if let (Some(current), Some(prev)) = (closes[i], closes[i - period]) { - result[i] = Some(current - prev); - } + result[i] = Some(closes[i] - closes[i - period]); } result @@ -25,10 +23,7 @@ mod tests { fn test_mom() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = mom(&close, 10); let expected = testutils::load_expected::>(&format!( "../data/expected/mom_{}.json", diff --git a/core/src/indicators/pchan.rs b/core/src/indicators/pchan.rs index db1fc37..154c53b 100644 --- a/core/src/indicators/pchan.rs +++ b/core/src/indicators/pchan.rs @@ -1,8 +1,8 @@ use crate::utils::rolling_max_min; pub fn pchan( - highs: &[Option], - lows: &[Option], + highs: &[f64], + lows: &[f64], period: usize, ) -> (Vec>, Vec>, Vec>) { let len = highs.len(); @@ -10,7 +10,7 @@ pub fn pchan( let mut lower = vec![None; len]; let mut middle = vec![None; len]; - if len != lows.len() || period == 0 || len < period { + if period == 0 || len < period { return (upper, middle, lower); } @@ -42,8 +42,6 @@ mod tests { for symbol in test_cases { let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let highs = highs.into_iter().map(Some).collect::>(); - let lows = lows.into_iter().map(Some).collect::>(); let (upper, middle, lower) = pchan(&highs, &lows, 20); let expected_upper = testutils::load_expected::>(&format!( @@ -79,16 +77,4 @@ mod tests { ); } } - - #[test] - fn test_pchan_with_gap_invalidates_previous_window() { - let highs = vec![Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; - let lows = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)]; - - let (upper, middle, lower) = pchan(&highs, &lows, 2); - - assert_eq!(upper, vec![None, None, Some(4.0), None, None]); - assert_eq!(middle, vec![None, None, Some(2.5), None, None]); - assert_eq!(lower, vec![None, None, Some(1.0), None, None]); - } } diff --git a/core/src/indicators/psl.rs b/core/src/indicators/psl.rs index 33e6c7a..115ea32 100644 --- a/core/src/indicators/psl.rs +++ b/core/src/indicators/psl.rs @@ -1,6 +1,4 @@ -use crate::utils::rolling_sum_strict; - -pub fn psl(closes: &[Option], period: usize) -> Vec> { +pub fn psl(closes: &[f64], period: usize) -> Vec> { let mut psl = vec![None; closes.len()]; let len = closes.len(); @@ -8,18 +6,29 @@ pub fn psl(closes: &[Option], period: usize) -> Vec> { return psl; } - let changes = closes - .windows(2) - .map(|window| match (window[0], window[1]) { - (Some(prev), Some(current)) => Some(if current > prev { 1.0 } else { 0.0 }), - _ => None, - }) - .collect::>(); + let mut count = 0; + + // Count initial positive price changes + for i in 1..period { + if closes[i] > closes[i - 1] { + count += 1; + } + } - let sums = rolling_sum_strict(&changes, period); + // Calculate PSL for the rest of the series for i in period..len { - if let Some(sum) = sums[i - 1] { - psl[i] = Some((sum / period as f64) * 100.0); + // Add current price change to the count + if closes[i] > closes[i - 1] { + count += 1; + } + + // Calculate PSL value + let psl_value = (count as f64 / period as f64) * 100.0; + psl[i] = Some(psl_value); + + // Remove oldest price change from the count + if closes[i - period + 1] > closes[i - period] { + count -= 1; } } @@ -36,10 +45,7 @@ mod tests { fn test_psl() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = psl(&close, 12); let expected = testutils::load_expected::>(&format!( "../data/expected/psl_{}.json", @@ -54,13 +60,4 @@ mod tests { ); } } - - #[test] - fn test_psl_with_gap_invalidates_change_window() { - let close = vec![Some(1.0), Some(2.0), Some(3.0), None, Some(4.0), Some(5.0)]; - - let result = psl(&close, 2); - - assert_eq!(result, vec![None, None, Some(100.0), None, None, None]); - } } diff --git a/core/src/indicators/roc.rs b/core/src/indicators/roc.rs index d968232..f460bd8 100644 --- a/core/src/indicators/roc.rs +++ b/core/src/indicators/roc.rs @@ -1,4 +1,4 @@ -pub fn roc(closes: &[Option], period: usize) -> Vec> { +pub fn roc(closes: &[f64], period: usize) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; @@ -7,9 +7,9 @@ pub fn roc(closes: &[Option], period: usize) -> Vec> { } for i in period..len { - if let (Some(curr_close), Some(prev_close)) = (closes[i], closes[i - period]) { - result[i] = Some(((curr_close - prev_close) / prev_close) * 100.0); - } + let curr_close = closes[i]; + let prev_close = closes[i - period]; + result[i] = Some(((curr_close - prev_close) / prev_close) * 100.0); } result @@ -25,10 +25,7 @@ mod tests { fn test_roc() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = roc(&close, 20); let expected = testutils::load_expected::>(&format!( "../data/expected/roc_{}.json", diff --git a/core/src/indicators/rsi.rs b/core/src/indicators/rsi.rs index d1ae07b..84a5ab6 100644 --- a/core/src/indicators/rsi.rs +++ b/core/src/indicators/rsi.rs @@ -1,3 +1,8 @@ +pub(crate) fn rsi_dense(data: &[f64], period: usize) -> Vec> { + let nullable = data.iter().copied().map(Some).collect::>(); + rsi(&nullable, period) +} + pub fn rsi(data: &[Option], period: usize) -> Vec> { let mut rsi = vec![None; data.len()]; diff --git a/core/src/indicators/sma.rs b/core/src/indicators/sma.rs index 519ae1e..3e11858 100644 --- a/core/src/indicators/sma.rs +++ b/core/src/indicators/sma.rs @@ -1,5 +1,4 @@ use crate::utils::rolling_mean_strict; - /// Computes a simple moving average over an aligned nullable series. /// /// The returned vector keeps the same length as the input and emits `None` diff --git a/core/src/indicators/stochf.rs b/core/src/indicators/stochf.rs index d349eb3..474567e 100644 --- a/core/src/indicators/stochf.rs +++ b/core/src/indicators/stochf.rs @@ -1,15 +1,15 @@ use crate::utils::{rolling_max_min, rolling_mean_strict}; pub fn stochf_percent_k( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], fastk_period: usize, ) -> Vec> { let len = closes.len(); let mut percent_k = vec![None; len]; - if len != highs.len() || len != lows.len() || len < fastk_period || fastk_period == 0 { + if len < fastk_period { return percent_k; } @@ -20,14 +20,10 @@ pub fn stochf_percent_k( continue; }; - let Some(close) = closes[i] else { - continue; - }; - percent_k[i] = if max_high == min_low { None } else { - Some(((close - min_low) / (max_high - min_low)) * 100.0) + Some(((closes[i] - min_low) / (max_high - min_low)) * 100.0) }; } @@ -53,9 +49,9 @@ pub fn stochf_percent_d( } pub fn stochf( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], fastk_period: usize, fastd_period: usize, ) -> (Vec>, Vec>) { @@ -74,18 +70,9 @@ mod tests { fn test_stochf() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") - .into_iter() - .map(Some) - .collect::>(); - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") - .into_iter() - .map(Some) - .collect::>(); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let (percent_k, percent_d) = stochf(&highs, &lows, &closes, 14, 3); @@ -112,16 +99,4 @@ mod tests { ); } } - - #[test] - fn test_stochf_with_gap_invalidates_window() { - let highs = vec![Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; - let lows = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)]; - let closes = vec![Some(2.0), Some(3.0), None, Some(5.0), Some(6.0)]; - - let (percent_k, percent_d) = stochf(&highs, &lows, &closes, 3, 2); - - assert_eq!(percent_k, vec![None, None, None, None, None]); - assert_eq!(percent_d, vec![None, None, None, None, None]); - } } diff --git a/core/src/indicators/stochrsi.rs b/core/src/indicators/stochrsi.rs index 5bae5c5..4fda02f 100644 --- a/core/src/indicators/stochrsi.rs +++ b/core/src/indicators/stochrsi.rs @@ -1,8 +1,8 @@ -use crate::indicators::rsi::rsi; +use crate::indicators::rsi::rsi_dense; use crate::utils::{rolling_max_min, rolling_mean_strict}; pub fn stochrsi( - closes: &[Option], + closes: &[f64], period_rsi: usize, period_k: usize, period_d: usize, @@ -14,10 +14,24 @@ pub fn stochrsi( return (percent_k, vec![None; len]); } - let rsi_values = rsi(closes, period_rsi); - let (rolling_max, rolling_min) = rolling_max_min(&rsi_values, &rsi_values, period_k); + let rsi_values = rsi_dense(closes, period_rsi); + let rsi_values_with_nan: Vec = rsi_values + .iter() + .map(|value| value.unwrap_or(f64::NAN)) + .collect(); + let (rolling_max, rolling_min) = + rolling_max_min(&rsi_values_with_nan, &rsi_values_with_nan, period_k); for i in (period_rsi + period_k - 1)..len { + let valid_values: Vec = rsi_values[i + 1 - period_k..=i] + .iter() + .filter_map(|&x| x) + .collect(); + + if valid_values.len() != period_k { + continue; + } + let (Some(rsi), Some(rsi_max), Some(rsi_min)) = (rsi_values[i], rolling_max[i], rolling_min[i]) else { @@ -50,10 +64,7 @@ mod tests { fn test_stochrsi() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let (percent_k, percent_d) = stochrsi(&closes, 14, 14, 3); @@ -80,29 +91,4 @@ mod tests { ); } } - - #[test] - fn test_stochrsi_with_gap_invalidates_rsi_window() { - let closes = vec![ - Some(1.0), - Some(2.0), - Some(3.0), - Some(2.0), - None, - Some(4.0), - Some(5.0), - Some(6.0), - ]; - - let (percent_k, percent_d) = stochrsi(&closes, 3, 2, 2); - - assert_eq!( - percent_k, - vec![None, None, None, None, None, None, None, Some(100.0)] - ); - assert_eq!( - percent_d, - vec![None, None, None, None, None, None, None, None] - ); - } } diff --git a/core/src/indicators/stochs.rs b/core/src/indicators/stochs.rs index 99a1975..3bfcb3d 100644 --- a/core/src/indicators/stochs.rs +++ b/core/src/indicators/stochs.rs @@ -1,15 +1,15 @@ use crate::utils::{rolling_max_min, rolling_mean_strict}; fn stochs_raw_k( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], fastk_period: usize, ) -> Vec> { let len = closes.len(); let mut raw_k = vec![None; len]; - if len != highs.len() || len != lows.len() || len < fastk_period || fastk_period == 0 { + if len < fastk_period { return raw_k; } @@ -20,14 +20,10 @@ fn stochs_raw_k( continue; }; - let Some(close) = closes[i] else { - continue; - }; - raw_k[i] = if max_high == min_low { None } else { - Some(((close - min_low) / (max_high - min_low)) * 100.0) + Some(((closes[i] - min_low) / (max_high - min_low)) * 100.0) }; } @@ -35,9 +31,9 @@ fn stochs_raw_k( } pub fn stoch_percent_k( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], fastk_period: usize, slowk_period: usize, ) -> Vec> { @@ -52,9 +48,9 @@ pub fn stoch_percent_k( } pub fn stoch_percent_d( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], fastk_period: usize, slowk_period: usize, slowd_period: usize, @@ -83,9 +79,9 @@ fn stoch_percent_d_from_k( } pub fn stochs( - highs: &[Option], - lows: &[Option], - closes: &[Option], + highs: &[f64], + lows: &[f64], + closes: &[f64], fastk_period: usize, slowk_period: usize, slowd_period: usize, @@ -105,18 +101,9 @@ mod tests { fn test_stochs() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") - .into_iter() - .map(Some) - .collect::>(); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") - .into_iter() - .map(Some) - .collect::>(); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") - .into_iter() - .map(Some) - .collect::>(); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let (percent_k, percent_d) = stochs(&high, &low, &close, 14, 3, 3); @@ -143,16 +130,4 @@ mod tests { ); } } - - #[test] - fn test_stochs_with_gap_invalidates_slow_windows() { - let high = vec![Some(3.0), Some(4.0), Some(5.0), None, Some(7.0), Some(8.0)]; - let low = vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0), Some(6.0)]; - let close = vec![Some(2.0), Some(3.0), Some(4.0), None, Some(6.0), Some(7.0)]; - - let (percent_k, percent_d) = stochs(&high, &low, &close, 3, 2, 2); - - assert_eq!(percent_k, vec![None, None, None, None, None, None]); - assert_eq!(percent_d, vec![None, None, None, None, None, None]); - } } diff --git a/core/src/indicators/willr.rs b/core/src/indicators/willr.rs index 5c84e48..2c78214 100644 --- a/core/src/indicators/willr.rs +++ b/core/src/indicators/willr.rs @@ -1,15 +1,10 @@ use crate::utils::rolling_max_min; -pub fn willr( - highs: &[Option], - lows: &[Option], - closes: &[Option], - period: usize, -) -> Vec> { +pub fn willr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; - if len != highs.len() || len != lows.len() || len < period || period == 0 { + if len < period { return result; } @@ -20,9 +15,7 @@ pub fn willr( continue; }; - let Some(cc) = closes[i] else { - continue; - }; + let cc = closes[i]; if max_high == min_low { result[i] = None; } else { @@ -46,9 +39,6 @@ mod tests { let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); - let high = high.into_iter().map(Some).collect::>(); - let low = low.into_iter().map(Some).collect::>(); - let close = close.into_iter().map(Some).collect::>(); let result = willr(&high, &low, &close, 14); let expected = testutils::load_expected::>(&format!( "../data/expected/willr_{}.json", diff --git a/core/src/utils.rs b/core/src/utils.rs index 7653635..16d6929 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -30,127 +30,86 @@ pub fn find_min(data: &[f64]) -> f64 { data.iter().cloned().fold(f64::INFINITY, f64::min) } -/// Computes rolling max/min values over paired aligned slices using a monotonic queue. +/// Computes rolling max/min values over paired slices using a monotonic queue. /// -/// A window only emits values when both input slices contain a full valid window. +/// NaN inputs are skipped. If a full window contains no finite values for one side, +/// that side returns `None` for the window. pub fn rolling_max_min( - highs: &[Option], - lows: &[Option], + highs: &[f64], + lows: &[f64], period: usize, ) -> (Vec>, Vec>) { let len = highs.len(); let mut rolling_max = vec![None; len]; let mut rolling_min = vec![None; len]; - if len != lows.len() || len < period || period == 0 { + if len < period || period == 0 { return (rolling_max, rolling_min); } let mut max_deque = VecDeque::with_capacity(period); let mut min_deque = VecDeque::with_capacity(period); - let mut valid_highs = 0usize; - let mut valid_lows = 0usize; for i in 0..len { - if highs[i].is_some() { - push_max_index(&mut max_deque, highs, i); - valid_highs += 1; - } - if lows[i].is_some() { - push_min_index(&mut min_deque, lows, i); - valid_lows += 1; - } + push_max_index(&mut max_deque, highs, i); + push_min_index(&mut min_deque, lows, i); if i + 1 < period { continue; } - if i >= period { - if highs[i - period].is_some() { - valid_highs -= 1; - } - if lows[i - period].is_some() { - valid_lows -= 1; - } - } - let earliest_idx = i + 1 - period; evict_expired(&mut max_deque, earliest_idx); evict_expired(&mut min_deque, earliest_idx); - if valid_highs == period && valid_lows == period { - rolling_max[i] = max_deque.front().and_then(|&idx| highs[idx]); - rolling_min[i] = min_deque.front().and_then(|&idx| lows[idx]); - } + rolling_max[i] = max_deque.front().map(|&idx| highs[idx]); + rolling_min[i] = min_deque.front().map(|&idx| lows[idx]); } (rolling_max, rolling_min) } -/// Computes rolling argmax/argmin source indices over paired aligned slices. +/// Computes rolling argmax/argmin source indices over paired slices using a monotonic queue. /// -/// Returned indices refer to the original input slices. A window only emits indices -/// when both inputs are fully valid across the full window. +/// Returned indices refer to the original input slices. NaN inputs are skipped, so a +/// window with no finite values on one side returns `None` for that side. pub fn rolling_argmax_argmin( - highs: &[Option], - lows: &[Option], + highs: &[f64], + lows: &[f64], period: usize, ) -> (Vec>, Vec>) { let len = highs.len(); let mut max_indices = vec![None; len]; let mut min_indices = vec![None; len]; - if len != lows.len() || len < period || period == 0 { + if len < period || period == 0 { return (max_indices, min_indices); } let mut max_deque = VecDeque::with_capacity(period); let mut min_deque = VecDeque::with_capacity(period); - let mut valid_highs = 0usize; - let mut valid_lows = 0usize; for i in 0..len { - if highs[i].is_some() { - push_max_index(&mut max_deque, highs, i); - valid_highs += 1; - } - if lows[i].is_some() { - push_min_index(&mut min_deque, lows, i); - valid_lows += 1; - } + push_max_index(&mut max_deque, highs, i); + push_min_index(&mut min_deque, lows, i); if i + 1 < period { continue; } - if i >= period { - if highs[i - period].is_some() { - valid_highs -= 1; - } - if lows[i - period].is_some() { - valid_lows -= 1; - } - } - let earliest_idx = i + 1 - period; evict_expired(&mut max_deque, earliest_idx); evict_expired(&mut min_deque, earliest_idx); - if valid_highs == period && valid_lows == period { - max_indices[i] = max_deque.front().copied(); - min_indices[i] = min_deque.front().copied(); - } + max_indices[i] = max_deque.front().copied(); + min_indices[i] = min_deque.front().copied(); } (max_indices, min_indices) } /// Computes the midpoint of the rolling high/low channel for each full window. -pub fn rolling_midpoint( - highs: &[Option], - lows: &[Option], - period: usize, -) -> Vec> { +pub fn rolling_midpoint(highs: &[f64], lows: &[f64], period: usize) -> Vec> { let (rolling_max, rolling_min) = rolling_max_min(highs, lows, period); rolling_max .into_iter() @@ -539,8 +498,8 @@ mod tests { #[test] fn test_rolling_midpoint() { - let highs = vec![Some(10.0), Some(12.0), Some(14.0), Some(16.0), Some(18.0)]; - let lows = vec![Some(4.0), Some(6.0), Some(8.0), Some(10.0), Some(12.0)]; + let highs = vec![10.0, 12.0, 14.0, 16.0, 18.0]; + let lows = vec![4.0, 6.0, 8.0, 10.0, 12.0]; let result = rolling_midpoint(&highs, &lows, 3); @@ -549,8 +508,8 @@ mod tests { #[test] fn test_rolling_max_min() { - let highs = vec![Some(1.0), Some(3.0), Some(2.0), Some(5.0), Some(4.0)]; - let lows = vec![Some(5.0), Some(2.0), Some(3.0), Some(1.0), Some(4.0)]; + let highs = vec![1.0, 3.0, 2.0, 5.0, 4.0]; + let lows = vec![5.0, 2.0, 3.0, 1.0, 4.0]; let (rolling_max, rolling_min) = rolling_max_min(&highs, &lows, 3); @@ -566,8 +525,8 @@ mod tests { #[test] fn test_rolling_argmax_argmin_prefers_latest_duplicate() { - let highs = vec![Some(1.0), Some(5.0), Some(5.0), Some(2.0)]; - let lows = vec![Some(4.0), Some(1.0), Some(1.0), Some(3.0)]; + let highs = vec![1.0, 5.0, 5.0, 2.0]; + let lows = vec![4.0, 1.0, 1.0, 3.0]; let (max_indices, min_indices) = rolling_argmax_argmin(&highs, &lows, 3); @@ -576,31 +535,31 @@ mod tests { } #[test] - fn test_rolling_max_min_invalidates_window_with_gap() { - let highs = vec![Some(1.0), None, Some(3.0)]; - let lows = vec![Some(5.0), None, Some(2.0)]; + fn test_rolling_max_min_ignores_nan_when_finite_values_exist() { + let highs = vec![1.0, f64::NAN, 3.0]; + let lows = vec![5.0, f64::NAN, 2.0]; let (rolling_max, rolling_min) = rolling_max_min(&highs, &lows, 2); - assert_eq!(rolling_max, vec![None, None, None]); - assert_eq!(rolling_min, vec![None, None, None]); + assert_eq!(rolling_max, vec![None, Some(1.0), Some(3.0)]); + assert_eq!(rolling_min, vec![None, Some(5.0), Some(2.0)]); } #[test] - fn test_rolling_argmax_argmin_invalidates_window_with_gap() { - let highs = vec![Some(1.0), None, Some(5.0)]; - let lows = vec![Some(4.0), None, Some(1.0)]; + fn test_rolling_argmax_argmin_ignore_nan_when_finite_values_exist() { + let highs = vec![1.0, f64::NAN, 5.0]; + let lows = vec![4.0, f64::NAN, 1.0]; let (max_indices, min_indices) = rolling_argmax_argmin(&highs, &lows, 2); - assert_eq!(max_indices, vec![None, None, None]); - assert_eq!(min_indices, vec![None, None, None]); + assert_eq!(max_indices, vec![None, Some(0), Some(2)]); + assert_eq!(min_indices, vec![None, Some(0), Some(2)]); } #[test] - fn test_rolling_extrema_all_gap_window_returns_none() { - let highs = vec![None, None, None]; - let lows = vec![None, None, None]; + fn test_rolling_max_min_all_nan_window_returns_none() { + let highs = vec![f64::NAN, f64::NAN, f64::NAN]; + let lows = vec![f64::NAN, f64::NAN, f64::NAN]; let (rolling_max, rolling_min) = rolling_max_min(&highs, &lows, 2); let (max_indices, min_indices) = rolling_argmax_argmin(&highs, &lows, 2); From c13ad0963c2bde1a63354ad14fc17c41cde31d1e Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 14:45:10 +0900 Subject: [PATCH 4/6] Align composite signals with nullable EMA contract --- core/src/indicators/macd.rs | 15 +++++------ core/src/indicators/massi.rs | 11 +++++---- core/src/indicators/ppo.rs | 48 ++++++++++++++++++++++++++++-------- core/src/indicators/pvo.rs | 48 ++++++++++++++++++++++++++++-------- core/src/indicators/sonar.rs | 20 +++++++++++---- 5 files changed, 105 insertions(+), 37 deletions(-) diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index 29d356b..692a1fa 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; +use crate::indicators::ema::ema_aligned; pub fn macd( data: &[Option], @@ -7,7 +7,7 @@ pub fn macd( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let macd_line = macd_line(data, fast_period, slow_period); - let signal_line = ema_reseed_on_gap(&macd_line, signal_period); + let signal_line = ema_aligned(&macd_line, signal_period); let histogram = macd_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn macd_histogram( signal_period: usize, ) -> Vec> { let macd_line = macd_line(data, fast_period, slow_period); - let signal_line = ema_reseed_on_gap(&macd_line, signal_period); + let signal_line = ema_aligned(&macd_line, signal_period); macd_line .iter() @@ -46,7 +46,7 @@ pub fn macd_signal( signal_period: usize, ) -> Vec> { let macd_line = macd_line(data, fast_period, slow_period); - ema_reseed_on_gap(&macd_line, signal_period) + ema_aligned(&macd_line, signal_period) } pub fn macd_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -124,7 +124,7 @@ mod tests { } #[test] - fn test_macd_signal_reseeds_after_sparse_gap() { + fn test_macd_signal_follows_base_ema_contract_across_gaps() { let input = vec![ Some(1.0), Some(2.0), @@ -159,11 +159,12 @@ mod tests { None, Some(0.5), None, - None, + Some(0.5), Some(0.5), Some(0.5), ] ); + assert_eq!(signal, ema_aligned(&line, 2)); assert_eq!( histogram, vec![ @@ -172,7 +173,7 @@ mod tests { None, Some(0.0), None, - None, + Some(0.0), Some(0.0), Some(0.0), ] diff --git a/core/src/indicators/massi.rs b/core/src/indicators/massi.rs index bed0531..fe28646 100644 --- a/core/src/indicators/massi.rs +++ b/core/src/indicators/massi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; +use crate::indicators::ema::ema_aligned; use crate::utils::rolling_sum_strict; pub fn massi( @@ -9,7 +9,7 @@ pub fn massi( period_signal: usize, ) -> (Vec>, Vec>) { let mass = massi_line(highs, lows, period_ema, period_sum); - let signal = ema_reseed_on_gap(&mass, period_signal); + let signal = ema_aligned(&mass, period_signal); (mass, signal) } @@ -22,7 +22,7 @@ pub fn massi_signal( period_signal: usize, ) -> Vec> { let mass = massi_line(highs, lows, period_ema, period_sum); - ema_reseed_on_gap(&mass, period_signal) + ema_aligned(&mass, period_signal) } pub fn massi_line( @@ -141,7 +141,7 @@ mod tests { } #[test] - fn test_massi_signal_reseeds_after_sparse_gap() { + fn test_massi_signal_follows_base_ema_contract_across_gaps() { let highs = vec![ Some(5.0), Some(6.0), @@ -194,10 +194,11 @@ mod tests { Some(2.0), None, None, - None, + Some(2.0), Some(2.0), Some(2.0), ] ); + assert_eq!(signal, ema_aligned(&line, 2)); } } diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index 1dbd5ea..32a3b59 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; +use crate::indicators::ema::ema_aligned; pub fn ppo( data: &[Option], @@ -7,7 +7,7 @@ pub fn ppo( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let ppo_line = ppo_line(data, fast_period, slow_period); - let signal_line = ema_reseed_on_gap(&ppo_line, signal_period); + let signal_line = ema_aligned(&ppo_line, signal_period); let histogram = ppo_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn ppo_histogram( signal_period: usize, ) -> Vec> { let ppo_line = ppo_line(data, fast_period, slow_period); - let signal_line = ema_reseed_on_gap(&ppo_line, signal_period); + let signal_line = ema_aligned(&ppo_line, signal_period); ppo_line .iter() .zip(signal_line.iter()) @@ -45,7 +45,7 @@ pub fn ppo_signal( signal_period: usize, ) -> Vec> { let ppo_line = ppo_line(data, fast_period, slow_period); - ema_reseed_on_gap(&ppo_line, signal_period) + ema_aligned(&ppo_line, signal_period) } pub fn ppo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -125,7 +125,7 @@ mod tests { } #[test] - fn test_ppo_signal_reseeds_after_sparse_gap() { + fn test_ppo_signal_follows_base_ema_contract_across_gaps() { let input = vec![ Some(10.0), Some(11.0), @@ -146,13 +146,41 @@ mod tests { assert!(signal[3].is_some()); assert_eq!(signal[4], None); - assert_eq!(signal[5], None); - assert!((signal[6].unwrap() - ((line[5].unwrap() + line[6].unwrap()) / 2.0)).abs() < 1e-12); - assert!(signal[7].unwrap() < signal[6].unwrap()); + assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!( + round_vec(signal.clone(), 8), + round_vec( + vec![ + None, + None, + None, + Some(4.356060606060606), + None, + Some(4.016122766122766), + Some(3.7196599696599697), + Some(3.4621088787755454), + ], + 8, + ) + ); assert!(histogram[3].is_some()); assert_eq!(histogram[4], None); - assert_eq!(histogram[5], None); - assert!(histogram[6].is_some()); + assert_eq!( + round_vec(histogram, 8), + round_vec( + vec![ + None, + None, + None, + Some(-0.189393939393939), + None, + Some(-0.16996891996891972), + Some(-0.14823139823139803), + Some(-0.12877554544221196), + ], + 8, + ) + ); } } diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index 31fe530..7624e47 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; +use crate::indicators::ema::ema_aligned; pub fn pvo( data: &[Option], @@ -7,7 +7,7 @@ pub fn pvo( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let pvo_line = pvo_line(data, fast_period, slow_period); - let signal_line = ema_reseed_on_gap(&pvo_line, signal_period); + let signal_line = ema_aligned(&pvo_line, signal_period); let histogram = pvo_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn pvo_histogram( signal_period: usize, ) -> Vec> { let pvo_line = pvo_line(data, fast_period, slow_period); - let signal_line = ema_reseed_on_gap(&pvo_line, signal_period); + let signal_line = ema_aligned(&pvo_line, signal_period); pvo_line .iter() .zip(signal_line.iter()) @@ -45,7 +45,7 @@ pub fn pvo_signal( signal_period: usize, ) -> Vec> { let pvo_line = pvo_line(data, fast_period, slow_period); - ema_reseed_on_gap(&pvo_line, signal_period) + ema_aligned(&pvo_line, signal_period) } pub fn pvo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -125,7 +125,7 @@ mod tests { } #[test] - fn test_pvo_signal_reseeds_after_sparse_gap() { + fn test_pvo_signal_follows_base_ema_contract_across_gaps() { let input = vec![ Some(10.0), Some(11.0), @@ -146,13 +146,41 @@ mod tests { assert!(signal[3].is_some()); assert_eq!(signal[4], None); - assert_eq!(signal[5], None); - assert!((signal[6].unwrap() - ((line[5].unwrap() + line[6].unwrap()) / 2.0)).abs() < 1e-12); - assert!(signal[7].unwrap() < signal[6].unwrap()); + assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!( + round_vec(signal.clone(), 8), + round_vec( + vec![ + None, + None, + None, + Some(4.356060606060606), + None, + Some(4.016122766122766), + Some(3.7196599696599697), + Some(3.4621088787755454), + ], + 8, + ) + ); assert!(histogram[3].is_some()); assert_eq!(histogram[4], None); - assert_eq!(histogram[5], None); - assert!(histogram[6].is_some()); + assert_eq!( + round_vec(histogram, 8), + round_vec( + vec![ + None, + None, + None, + Some(-0.189393939393939), + None, + Some(-0.16996891996891972), + Some(-0.14823139823139803), + Some(-0.12877554544221196), + ], + 8, + ) + ); } } diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index e42ecda..73b1090 100644 --- a/core/src/indicators/sonar.rs +++ b/core/src/indicators/sonar.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::{ema_aligned, ema_reseed_on_gap}; +use crate::indicators::ema::ema_aligned; pub fn sonar( data: &[Option], @@ -7,7 +7,7 @@ pub fn sonar( signal_period: usize, ) -> (Vec>, Vec>) { let sonar_line = sonar_line(data, period, step); - let signal_line = ema_reseed_on_gap(&sonar_line, signal_period); + let signal_line = ema_aligned(&sonar_line, signal_period); (sonar_line, signal_line) } @@ -19,7 +19,7 @@ pub fn sonar_signal( signal_period: usize, ) -> Vec> { let sonar_line = sonar_line(data, period, step); - ema_reseed_on_gap(&sonar_line, signal_period) + ema_aligned(&sonar_line, signal_period) } pub fn sonar_line(data: &[Option], period: usize, step: usize) -> Vec> { @@ -106,7 +106,7 @@ mod tests { } #[test] - fn test_sonar_signal_reseeds_after_sparse_gap() { + fn test_sonar_signal_follows_base_ema_contract_across_gaps() { let input = vec![ Some(1.0), Some(2.0), @@ -135,7 +135,17 @@ mod tests { ); assert_eq!( signal, - vec![None, None, None, Some(1.0), None, None, None, Some(1.0),] + vec![ + None, + None, + None, + Some(1.0), + None, + None, + Some(1.0), + Some(1.0), + ] ); + assert_eq!(signal, ema_aligned(&line, 2)); } } From f9167c348d19c6cab811e3ee2b4574dfdf5d9bb5 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Fri, 24 Apr 2026 11:25:21 +0900 Subject: [PATCH 5/6] Remove thin EMA and SMA aliases --- core/src/indicators/efi.rs | 4 ++-- core/src/indicators/ema.rs | 2 -- core/src/indicators/eom.rs | 8 ++++---- core/src/indicators/macd.rs | 14 +++++++------- core/src/indicators/massi.rs | 12 ++++++------ core/src/indicators/nvi.rs | 8 ++++---- core/src/indicators/obv.rs | 8 ++++---- core/src/indicators/ppo.rs | 14 +++++++------- core/src/indicators/pvi.rs | 8 ++++---- core/src/indicators/pvo.rs | 14 +++++++------- core/src/indicators/sma.rs | 2 -- core/src/indicators/sonar.rs | 10 +++++----- 12 files changed, 50 insertions(+), 54 deletions(-) diff --git a/core/src/indicators/efi.rs b/core/src/indicators/efi.rs index 3ff3324..a1207ae 100644 --- a/core/src/indicators/efi.rs +++ b/core/src/indicators/efi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn efi(closes: &[Option], volumes: &[Option], period: usize) -> Vec> { let len = closes.len(); @@ -20,7 +20,7 @@ pub fn efi(closes: &[Option], volumes: &[Option], period: usize) -> Ve if period == 1 { efi = force; } else { - efi = ema_aligned(&force, period); + efi = ema(&force, period); } efi diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 70eb464..875a81d 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -47,8 +47,6 @@ pub fn ema(data: &[Option], period: usize) -> Vec> { result } - -pub(crate) use ema as ema_aligned; #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/eom.rs b/core/src/indicators/eom.rs index b519f6c..f86996b 100644 --- a/core/src/indicators/eom.rs +++ b/core/src/indicators/eom.rs @@ -1,4 +1,4 @@ -use crate::indicators::sma::sma_aligned; +use crate::indicators::sma::sma; pub fn eom( highs: &[Option], @@ -9,7 +9,7 @@ pub fn eom( scale: f64, ) -> (Vec>, Vec>) { let eom_line = eom_line(highs, lows, volumes, period, scale); - let signal = sma_aligned(&eom_line, signal_period); + let signal = sma(&eom_line, signal_period); (eom_line, signal) } @@ -23,7 +23,7 @@ pub fn eom_signal( scale: f64, ) -> Vec> { let eom_line = eom_line(highs, lows, volumes, period, scale); - sma_aligned(&eom_line, signal_period) + sma(&eom_line, signal_period) } pub fn eom_line( @@ -63,7 +63,7 @@ pub fn eom_line( } } - sma_aligned(&eom_values, period) + sma(&eom_values, period) } #[cfg(test)] diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index 692a1fa..d79ace8 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn macd( data: &[Option], @@ -7,7 +7,7 @@ pub fn macd( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let macd_line = macd_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&macd_line, signal_period); + let signal_line = ema(&macd_line, signal_period); let histogram = macd_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn macd_histogram( signal_period: usize, ) -> Vec> { let macd_line = macd_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&macd_line, signal_period); + let signal_line = ema(&macd_line, signal_period); macd_line .iter() @@ -46,7 +46,7 @@ pub fn macd_signal( signal_period: usize, ) -> Vec> { let macd_line = macd_line(data, fast_period, slow_period); - ema_aligned(&macd_line, signal_period) + ema(&macd_line, signal_period) } pub fn macd_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -56,8 +56,8 @@ pub fn macd_line(data: &[Option], fast_period: usize, slow_period: usize) - return macd_line; } - let fast_ema = ema_aligned(data, fast_period); - let slow_ema = ema_aligned(data, slow_period); + let fast_ema = ema(data, fast_period); + let slow_ema = ema(data, slow_period); for i in (slow_period - 1)..data.len() { if let (Some(fast), Some(slow)) = (fast_ema[i], slow_ema[i]) { @@ -164,7 +164,7 @@ mod tests { Some(0.5), ] ); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); assert_eq!( histogram, vec![ diff --git a/core/src/indicators/massi.rs b/core/src/indicators/massi.rs index fe28646..65e0a34 100644 --- a/core/src/indicators/massi.rs +++ b/core/src/indicators/massi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; use crate::utils::rolling_sum_strict; pub fn massi( @@ -9,7 +9,7 @@ pub fn massi( period_signal: usize, ) -> (Vec>, Vec>) { let mass = massi_line(highs, lows, period_ema, period_sum); - let signal = ema_aligned(&mass, period_signal); + let signal = ema(&mass, period_signal); (mass, signal) } @@ -22,7 +22,7 @@ pub fn massi_signal( period_signal: usize, ) -> Vec> { let mass = massi_line(highs, lows, period_ema, period_sum); - ema_aligned(&mass, period_signal) + ema(&mass, period_signal) } pub fn massi_line( @@ -50,8 +50,8 @@ pub fn massi_line( _ => None, }) .collect::>(); - let s_ema = ema_aligned(&high_low_diffs, period_ema); - let d_ema = ema_aligned(&s_ema, period_ema); + let s_ema = ema(&high_low_diffs, period_ema); + let d_ema = ema(&s_ema, period_ema); let ema_ratio = s_ema .iter() .zip(d_ema.iter()) @@ -199,6 +199,6 @@ mod tests { Some(2.0), ] ); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); } } diff --git a/core/src/indicators/nvi.rs b/core/src/indicators/nvi.rs index 3ccecf9..56823bc 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn nvi( closes: &[Option], @@ -6,7 +6,7 @@ pub fn nvi( signal_period: usize, ) -> (Vec>, Vec>) { let nvi_line = nvi_line(closes, volumes); - let signal = ema_aligned(&nvi_line, signal_period); + let signal = ema(&nvi_line, signal_period); (nvi_line, signal) } @@ -17,7 +17,7 @@ pub fn nvi_signal( signal_period: usize, ) -> Vec> { let nvi_line = nvi_line(closes, volumes); - ema_aligned(&nvi_line, signal_period) + ema(&nvi_line, signal_period) } pub fn nvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { @@ -151,7 +151,7 @@ mod tests { Some(1090.0), ] ); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); assert_eq!( round_vec(signal, 8), round_vec( diff --git a/core/src/indicators/obv.rs b/core/src/indicators/obv.rs index 7b4324d..31944c8 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn obv( data: &[Option], @@ -6,7 +6,7 @@ pub fn obv( signal_period: usize, ) -> (Vec>, Vec>) { let obv_line = obv_line(data, volumes); - let obv_signal = ema_aligned(&obv_line, signal_period); + let obv_signal = ema(&obv_line, signal_period); (obv_line, obv_signal) } @@ -17,7 +17,7 @@ pub fn obv_signal( signal_period: usize, ) -> Vec> { let obv_line = obv_line(data, volumes); - ema_aligned(&obv_line, signal_period) + ema(&obv_line, signal_period) } pub fn obv_line(data: &[Option], volumes: &[Option]) -> Vec> { @@ -154,7 +154,7 @@ mod tests { Some(140.0) ] ); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); assert_eq!( round_vec(signal, 8), round_vec( diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index 32a3b59..ed0e6ed 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn ppo( data: &[Option], @@ -7,7 +7,7 @@ pub fn ppo( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let ppo_line = ppo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&ppo_line, signal_period); + let signal_line = ema(&ppo_line, signal_period); let histogram = ppo_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn ppo_histogram( signal_period: usize, ) -> Vec> { let ppo_line = ppo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&ppo_line, signal_period); + let signal_line = ema(&ppo_line, signal_period); ppo_line .iter() .zip(signal_line.iter()) @@ -45,7 +45,7 @@ pub fn ppo_signal( signal_period: usize, ) -> Vec> { let ppo_line = ppo_line(data, fast_period, slow_period); - ema_aligned(&ppo_line, signal_period) + ema(&ppo_line, signal_period) } pub fn ppo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -55,8 +55,8 @@ pub fn ppo_line(data: &[Option], fast_period: usize, slow_period: usize) -> return ppo_line; } - let fast_ema = ema_aligned(data, fast_period); - let slow_ema = ema_aligned(data, slow_period); + let fast_ema = ema(data, fast_period); + let slow_ema = ema(data, slow_period); for i in (slow_period - 1)..data.len() { if let (Some(fast), Some(slow)) = (fast_ema[i], slow_ema[i]) { @@ -146,7 +146,7 @@ mod tests { assert!(signal[3].is_some()); assert_eq!(signal[4], None); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); assert_eq!( round_vec(signal.clone(), 8), round_vec( diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index b9506d3..09f1822 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn pvi( closes: &[Option], @@ -6,7 +6,7 @@ pub fn pvi( signal_period: usize, ) -> (Vec>, Vec>) { let pvi_line = pvi_line(closes, volumes); - let signal = ema_aligned(&pvi_line, signal_period); + let signal = ema(&pvi_line, signal_period); (pvi_line, signal) } @@ -17,7 +17,7 @@ pub fn pvi_signal( signal_period: usize, ) -> Vec> { let pvi_line = pvi_line(closes, volumes); - ema_aligned(&pvi_line, signal_period) + ema(&pvi_line, signal_period) } pub fn pvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { @@ -157,7 +157,7 @@ mod tests { Some(1090.0), ] ); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); assert_eq!( round_vec(signal, 8), round_vec( diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index 7624e47..b0d6c23 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn pvo( data: &[Option], @@ -7,7 +7,7 @@ pub fn pvo( signal_period: usize, ) -> (Vec>, Vec>, Vec>) { let pvo_line = pvo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&pvo_line, signal_period); + let signal_line = ema(&pvo_line, signal_period); let histogram = pvo_line .iter() .zip(signal_line.iter()) @@ -27,7 +27,7 @@ pub fn pvo_histogram( signal_period: usize, ) -> Vec> { let pvo_line = pvo_line(data, fast_period, slow_period); - let signal_line = ema_aligned(&pvo_line, signal_period); + let signal_line = ema(&pvo_line, signal_period); pvo_line .iter() .zip(signal_line.iter()) @@ -45,7 +45,7 @@ pub fn pvo_signal( signal_period: usize, ) -> Vec> { let pvo_line = pvo_line(data, fast_period, slow_period); - ema_aligned(&pvo_line, signal_period) + ema(&pvo_line, signal_period) } pub fn pvo_line(data: &[Option], fast_period: usize, slow_period: usize) -> Vec> { @@ -55,8 +55,8 @@ pub fn pvo_line(data: &[Option], fast_period: usize, slow_period: usize) -> return pvo_line; } - let fast_ema = ema_aligned(data, fast_period); - let slow_ema = ema_aligned(data, slow_period); + let fast_ema = ema(data, fast_period); + let slow_ema = ema(data, slow_period); for i in (slow_period - 1)..data.len() { if let (Some(fast), Some(slow)) = (fast_ema[i], slow_ema[i]) { @@ -146,7 +146,7 @@ mod tests { assert!(signal[3].is_some()); assert_eq!(signal[4], None); - assert_eq!(signal, ema_aligned(&line, 2)); + assert_eq!(signal, ema(&line, 2)); assert_eq!( round_vec(signal.clone(), 8), round_vec( diff --git a/core/src/indicators/sma.rs b/core/src/indicators/sma.rs index 3e11858..f32ffb9 100644 --- a/core/src/indicators/sma.rs +++ b/core/src/indicators/sma.rs @@ -7,8 +7,6 @@ pub fn sma(data: &[Option], period: usize) -> Vec> { rolling_mean_strict(data, period) } -pub(crate) use sma as sma_aligned; - #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index 73b1090..76378ac 100644 --- a/core/src/indicators/sonar.rs +++ b/core/src/indicators/sonar.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema; pub fn sonar( data: &[Option], @@ -7,7 +7,7 @@ pub fn sonar( signal_period: usize, ) -> (Vec>, Vec>) { let sonar_line = sonar_line(data, period, step); - let signal_line = ema_aligned(&sonar_line, signal_period); + let signal_line = ema(&sonar_line, signal_period); (sonar_line, signal_line) } @@ -19,7 +19,7 @@ pub fn sonar_signal( signal_period: usize, ) -> Vec> { let sonar_line = sonar_line(data, period, step); - ema_aligned(&sonar_line, signal_period) + ema(&sonar_line, signal_period) } pub fn sonar_line(data: &[Option], period: usize, step: usize) -> Vec> { @@ -29,7 +29,7 @@ pub fn sonar_line(data: &[Option], period: usize, step: usize) -> Vec Date: Fri, 24 Apr 2026 14:11:00 +0900 Subject: [PATCH 6/6] Fix dense deque helpers after restack --- core/src/utils.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/core/src/utils.rs b/core/src/utils.rs index 16d6929..8f657bd 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -276,10 +276,13 @@ pub fn rolling_mean_stddev_strict( (means, stddevs) } -fn push_max_index(deque: &mut VecDeque, data: &[Option], idx: usize) { - let current = data[idx].expect("push_max_index requires a present value"); +fn push_max_index(deque: &mut VecDeque, data: &[f64], idx: usize) { + if data[idx].is_nan() { + return; + } + while let Some(&back) = deque.back() { - if data[back].expect("deque indices always refer to present values") <= current { + if data[back] <= data[idx] { deque.pop_back(); } else { break; @@ -288,10 +291,13 @@ fn push_max_index(deque: &mut VecDeque, data: &[Option], idx: usize) deque.push_back(idx); } -fn push_min_index(deque: &mut VecDeque, data: &[Option], idx: usize) { - let current = data[idx].expect("push_min_index requires a present value"); +fn push_min_index(deque: &mut VecDeque, data: &[f64], idx: usize) { + if data[idx].is_nan() { + return; + } + while let Some(&back) = deque.back() { - if data[back].expect("deque indices always refer to present values") >= current { + if data[back] >= data[idx] { deque.pop_back(); } else { break;