From 86b3778ef05c951938157cb05977a96bb9b83048 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 16 Apr 2026 00:59:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=A1=20Optimize=20MACD=20signal=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/indicators/macd.rs | 39 +++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index cdcde1d..2f7d26b 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -75,24 +75,39 @@ fn calc_macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - let mut signal_line: Vec> = vec![None; macd_line.len()]; - let null_count = macd_line.iter().take_while(|&&x| x.is_none()).count(); - let macd_values: Vec = macd_line - .iter() - .skip(null_count) - .filter_map(|&x| x) - .collect(); - let ema_values = ema(&macd_values, signal_period); + let mut signal_line = vec![None; macd_line.len()]; + let Some(first_valid_idx) = macd_line.iter().position(|value| value.is_some()) else { + return signal_line; + }; + + let valid_len = macd_line.len() - first_valid_idx; + if signal_period == 0 || valid_len < signal_period { + return signal_line; + } - for i in 0..ema_values.len() { - if let Some(ema_value) = ema_values[i] { - signal_line[i + null_count] = Some(ema_value); - } + let first_signal_idx = first_valid_idx + signal_period - 1; + let mut signal = mean_macd_window(macd_line, first_valid_idx, signal_period); + let alpha = 2.0 / (signal_period as f64 + 1.0); + + signal_line[first_signal_idx] = Some(signal); + + for (idx, value) in macd_line.iter().enumerate().skip(first_signal_idx + 1) { + let macd = value.expect("macd_line becomes contiguous after the first valid value"); + signal = alpha * macd + (1.0 - alpha) * signal; + signal_line[idx] = Some(signal); } signal_line } +fn mean_macd_window(macd_line: &[Option], start_idx: usize, period: usize) -> f64 { + macd_line[start_idx..start_idx + period] + .iter() + .map(|value| value.expect("initial signal window must be fully populated")) + .sum::() + / period as f64 +} + #[cfg(test)] mod tests { use super::*; From f4bcea94773b9432f249fd960aad106b86b8b673 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 16 Apr 2026 21:05:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9A=A1=20Optimize=20aligned=20signal=20c?= =?UTF-8?q?alculations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/indicators/ema.rs | 34 ++++++++++++++++++++++++++++++++++ core/src/indicators/eom.rs | 10 ++-------- core/src/indicators/macd.rs | 34 ++-------------------------------- core/src/indicators/massi.rs | 18 ++++++------------ core/src/indicators/nvi.rs | 9 ++------- core/src/indicators/obv.rs | 20 ++------------------ core/src/indicators/ppo.rs | 16 ++-------------- core/src/indicators/pvi.rs | 9 ++------- core/src/indicators/pvo.rs | 16 ++-------------- core/src/indicators/sma.rs | 31 +++++++++++++++++++++++++++++++ core/src/indicators/sonar.rs | 10 ++-------- 11 files changed, 87 insertions(+), 120 deletions(-) diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index e377839..287ab62 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -18,6 +18,40 @@ pub fn ema(data: &[f64], period: usize) -> Vec> { result } +pub(crate) fn ema_aligned(data: &[Option], period: usize) -> Vec> { + let mut result = vec![None; data.len()]; + let Some(first_valid_idx) = data.iter().position(|value| value.is_some()) else { + return result; + }; + + let valid_len = data.len() - first_valid_idx; + if period == 0 || valid_len < period { + return result; + } + + let first_signal_idx = first_valid_idx + period - 1; + let alpha = 2.0 / (period as f64 + 1.0); + let mut ema = mean_window(data, first_valid_idx, period); + + result[first_signal_idx] = Some(ema); + + for (idx, value) in data.iter().enumerate().skip(first_signal_idx + 1) { + let value = value.expect("aligned EMA input must be contiguous after the first value"); + ema = alpha * value + (1.0 - alpha) * ema; + result[idx] = Some(ema); + } + + result +} + +fn mean_window(data: &[Option], start_idx: usize, period: usize) -> f64 { + data[start_idx..start_idx + period] + .iter() + .map(|value| value.expect("initial EMA window must be fully populated")) + .sum::() + / period as f64 +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/eom.rs b/core/src/indicators/eom.rs index f6aadb9..5c6f656 100644 --- a/core/src/indicators/eom.rs +++ b/core/src/indicators/eom.rs @@ -1,4 +1,4 @@ -use crate::indicators::sma::sma; +use crate::indicators::sma::{sma, sma_aligned}; pub fn eom( highs: &[f64], @@ -35,13 +35,7 @@ pub fn eom( eom_line[i + 1] = value; } - let eom_values: Vec = eom_line.iter().filter_map(|&x| x).collect(); - let signal_sma = sma(&eom_values, signal_period); - let mut signal = vec![None; eom_line.len()]; - let signal_offset = eom_line.len() - signal_sma.len(); - for (i, &s) in signal_sma.iter().enumerate() { - signal[i + signal_offset] = s; - } + let signal = sma_aligned(&eom_line, signal_period); (eom_line, signal) } diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index 2f7d26b..47eb82a 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::{ema, ema_aligned}; pub fn macd( data: &[f64], @@ -75,37 +75,7 @@ fn calc_macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - let mut signal_line = vec![None; macd_line.len()]; - let Some(first_valid_idx) = macd_line.iter().position(|value| value.is_some()) else { - return signal_line; - }; - - let valid_len = macd_line.len() - first_valid_idx; - if signal_period == 0 || valid_len < signal_period { - return signal_line; - } - - let first_signal_idx = first_valid_idx + signal_period - 1; - let mut signal = mean_macd_window(macd_line, first_valid_idx, signal_period); - let alpha = 2.0 / (signal_period as f64 + 1.0); - - signal_line[first_signal_idx] = Some(signal); - - for (idx, value) in macd_line.iter().enumerate().skip(first_signal_idx + 1) { - let macd = value.expect("macd_line becomes contiguous after the first valid value"); - signal = alpha * macd + (1.0 - alpha) * signal; - signal_line[idx] = Some(signal); - } - - signal_line -} - -fn mean_macd_window(macd_line: &[Option], start_idx: usize, period: usize) -> f64 { - macd_line[start_idx..start_idx + period] - .iter() - .map(|value| value.expect("initial signal window must be fully populated")) - .sum::() - / period as f64 + ema_aligned(macd_line, signal_period) } #[cfg(test)] diff --git a/core/src/indicators/massi.rs b/core/src/indicators/massi.rs index b84f9cd..d083f8e 100644 --- a/core/src/indicators/massi.rs +++ b/core/src/indicators/massi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::{ema, ema_aligned}; pub fn massi( highs: &[f64], @@ -17,13 +17,12 @@ pub fn massi( let high_low_diffs: Vec = highs.iter().zip(lows.iter()).map(|(h, l)| h - l).collect(); let s_ema = ema(&high_low_diffs, period_ema); - let s_ema_filtered: Vec = s_ema.iter().filter_map(|&x| x).collect(); let offset: usize = period_ema - 1; - let d_ema = ema(&s_ema_filtered, period_ema); + let d_ema = ema_aligned(&s_ema, period_ema); - let mut ema_ratio = Vec::with_capacity(d_ema.len()); - for i in offset..d_ema.len() + offset { - if let (Some(s), Some(d)) = (s_ema[i], d_ema[i - offset]) { + 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); } } @@ -37,12 +36,7 @@ pub fn massi( } } - let mass_values: Vec = mass.iter().filter_map(|&x| x).collect(); - let signal_ema = ema(&mass_values, period_signal); - let signal_offset = len - signal_ema.len(); - for (i, &s) in signal_ema.iter().enumerate() { - signal[i + signal_offset] = s; - } + signal = ema_aligned(&mass, period_signal); (mass, signal) } diff --git a/core/src/indicators/nvi.rs b/core/src/indicators/nvi.rs index 5f46065..09b8d70 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::ema_aligned; pub fn nvi( closes: &[f64], @@ -23,12 +23,7 @@ pub fn nvi( nvi_line[i] = Some(nvi_point); } - let nvi_values: Vec = nvi_line.iter().filter_map(|&x| x).collect(); - let signal_ema = ema(&nvi_values, signal_period); - let signal_offset = len - signal_ema.len(); - for (i, &s) in signal_ema.iter().enumerate() { - signal[i + signal_offset] = s; - } + signal = ema_aligned(&nvi_line, signal_period); (nvi_line, signal) } diff --git a/core/src/indicators/obv.rs b/core/src/indicators/obv.rs index 7a76927..250c676 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::ema_aligned; pub fn obv( data: &[f64], @@ -44,23 +44,7 @@ fn calc_obv_line(data: &[f64], volumes: &[f64]) -> Vec> { } fn obv_signal_by_obvline(obv_line: &[Option], signal_period: usize) -> Vec> { - let mut obv_signal = vec![None; obv_line.len()]; - - let null_count = obv_line.iter().take_while(|&&x| x.is_none()).count(); - let obv_values = obv_line - .iter() - .skip(null_count) - .filter_map(|&v| v) - .collect::>(); - let ema_obv = ema(&obv_values, signal_period); - - for i in 0..ema_obv.len() { - if let Some(ema_value) = ema_obv[i] { - obv_signal[i + null_count] = Some(ema_value); - } - } - - obv_signal + ema_aligned(obv_line, signal_period) } #[cfg(test)] diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index bec1bb9..189af50 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::{ema, ema_aligned}; pub fn ppo( data: &[f64], @@ -42,19 +42,7 @@ fn calc_ppo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - let mut signal_line: Vec> = vec![None; ppo_line.len()]; - let ppo_values: Vec = ppo_line.iter().filter_map(|&x| x).collect(); - let offset = ppo_line.len() - ppo_values.len(); - - let ema_values = ema(&ppo_values, signal_period); - - for i in 0..ema_values.len() { - if let Some(ema_value) = ema_values[i] { - signal_line[i + offset] = Some(ema_value); - } - } - - signal_line + ema_aligned(ppo_line, signal_period) } #[cfg(test)] diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index 5b54ccb..0fe8399 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::ema_aligned; pub fn pvi( closes: &[f64], @@ -23,12 +23,7 @@ pub fn pvi( pvi_line[i] = Some(pvi_point); } - let pvi_values: Vec = pvi_line.iter().filter_map(|&x| x).collect(); - let signal_ema = ema(&pvi_values, signal_period); - let signal_offset = len - signal_ema.len(); - for (i, &s) in signal_ema.iter().enumerate() { - signal[i + signal_offset] = s; - } + signal = ema_aligned(&pvi_line, signal_period); (pvi_line, signal) } diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index c5c65b6..6e0c48e 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::{ema, ema_aligned}; pub fn pvo( data: &[f64], @@ -44,19 +44,7 @@ fn calc_pvo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - let mut signal_line: Vec> = vec![None; pvo_line.len()]; - let pvo_values: Vec = pvo_line.iter().filter_map(|&x| x).collect(); - let offset = pvo_line.len() - pvo_values.len(); - - let ema_values = ema(&pvo_values, signal_period); - - for i in 0..ema_values.len() { - if let Some(ema_value) = ema_values[i] { - signal_line[i + offset] = Some(ema_value); - } - } - - signal_line + ema_aligned(pvo_line, signal_period) } #[cfg(test)] mod tests { diff --git a/core/src/indicators/sma.rs b/core/src/indicators/sma.rs index c1efe3a..a88306c 100644 --- a/core/src/indicators/sma.rs +++ b/core/src/indicators/sma.rs @@ -19,6 +19,37 @@ pub fn sma(data: &[f64], period: usize) -> Vec> { sma } +pub(crate) fn sma_aligned(data: &[Option], period: usize) -> Vec> { + let mut result = vec![None; data.len()]; + let Some(first_valid_idx) = data.iter().position(|value| value.is_some()) else { + return result; + }; + + let valid_len = data.len() - first_valid_idx; + if period == 0 || valid_len < period { + return result; + } + + let mut sum = 0.0; + for value in data.iter().skip(first_valid_idx).take(period) { + sum += value.expect("initial SMA window must be fully populated"); + } + + let first_signal_idx = first_valid_idx + period - 1; + result[first_signal_idx] = Some(sum / period as f64); + + for idx in (first_signal_idx + 1)..data.len() { + let entering = + data[idx].expect("aligned SMA input must be contiguous after the first value"); + let leaving = + data[idx - period].expect("aligned SMA input must be contiguous after the first value"); + sum += entering - leaving; + result[idx] = Some(sum / period as f64); + } + + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index e6e3db2..4c42984 100644 --- a/core/src/indicators/sonar.rs +++ b/core/src/indicators/sonar.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::{ema, ema_aligned}; pub fn sonar( data: &[f64], @@ -21,13 +21,7 @@ pub fn sonar( } } - let sonar_values: Vec = sonar_line.iter().filter_map(|&x| x).collect(); - let signal_ema = ema(&sonar_values, signal_period); - let offset = data.len() - sonar_values.len(); - - for (i, &value) in signal_ema.iter().enumerate() { - signal_line[i + offset] = value; - } + signal_line = ema_aligned(&sonar_line, signal_period); (sonar_line, signal_line) } From d0ccb8a88c3709e069d8b5c4cb94d0130d043ac0 Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 17 Apr 2026 00:26:09 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9D=20Add=20aligned=20helper=20doc?= =?UTF-8?q?s=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/indicators/ema.rs | 93 +++++++++++++++++++++++++++++--------- core/src/indicators/ppo.rs | 5 ++ core/src/indicators/pvo.rs | 5 ++ core/src/indicators/sma.rs | 29 ++++++++++++ 4 files changed, 111 insertions(+), 21 deletions(-) diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 287ab62..3546c94 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -1,3 +1,7 @@ +/// Computes an exponential moving average over a dense `f64` series. +/// +/// The returned vector keeps the same length as the input and emits `None` +/// until the first full `period` window has been observed. pub fn ema(data: &[f64], period: usize) -> Vec> { let mut result = vec![None; data.len()]; @@ -18,49 +22,59 @@ pub fn ema(data: &[f64], period: usize) -> Vec> { result } +/// Computes an EMA over an optional series while preserving original alignment. +/// +/// The EMA state advances only on `Some(f64)` values, so interior `None` holes +/// are skipped safely. This lets callers avoid compacting a sparse aligned +/// series into a temporary dense vector before applying `ema`. pub(crate) fn ema_aligned(data: &[Option], period: usize) -> Vec> { let mut result = vec![None; data.len()]; - let Some(first_valid_idx) = data.iter().position(|value| value.is_some()) else { - return result; - }; - - let valid_len = data.len() - first_valid_idx; - if period == 0 || valid_len < period { + if period == 0 { return result; } - let first_signal_idx = first_valid_idx + period - 1; let alpha = 2.0 / (period as f64 + 1.0); - let mut ema = mean_window(data, first_valid_idx, period); + let mut seeded_count = 0usize; + let mut seed_sum = 0.0; + let mut ema = None; - result[first_signal_idx] = Some(ema); + for (idx, value) in data.iter().enumerate() { + let Some(value) = value else { + continue; + }; - for (idx, value) in data.iter().enumerate().skip(first_signal_idx + 1) { - let value = value.expect("aligned EMA input must be contiguous after the first value"); - ema = alpha * value + (1.0 - alpha) * ema; - result[idx] = Some(ema); + if let Some(current_ema) = ema { + let next_ema = alpha * value + (1.0 - alpha) * current_ema; + ema = Some(next_ema); + result[idx] = Some(next_ema); + continue; + } + + seed_sum += value; + seeded_count += 1; + if seeded_count == period { + let initial_ema = seed_sum / period as f64; + ema = Some(initial_ema); + result[idx] = Some(initial_ema); + } } result } -fn mean_window(data: &[Option], start_idx: usize, period: usize) -> f64 { - data[start_idx..start_idx + period] - .iter() - .map(|value| value.expect("initial EMA window must be fully populated")) - .sum::() - / period as f64 -} - #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard EMA output against fixture data. #[test] fn test_ema() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = ema(&input, 20); @@ -69,6 +83,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(result, 8), round_vec(expected, 8), @@ -77,4 +92,40 @@ mod tests { ); } } + + /// Verifies that aligned EMA preserves offsets while matching dense EMA values. + #[test] + fn test_ema_aligned() { + // Given + let aligned = vec![None, None, Some(1.0), Some(2.0), Some(3.0), Some(4.0)]; + let expected = vec![None, None, None, Some(1.5), Some(2.5), Some(3.5)]; + + // When + let result = ema_aligned(&aligned, 2); + + // Then + assert_eq!(result, expected); + } + + /// Verifies that aligned EMA skips interior gaps without panicking. + #[test] + fn test_ema_aligned_with_interior_gaps() { + // Given + let aligned = vec![ + None, + None, + Some(1.0), + Some(2.0), + None, + Some(3.0), + Some(4.0), + ]; + let expected = vec![None, None, None, Some(1.5), None, Some(2.5), Some(3.5)]; + + // When + let result = ema_aligned(&aligned, 2); + + // Then + assert_eq!(result, expected); + } } diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index 189af50..ac2bba5 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -52,8 +52,12 @@ mod tests { use crate::utils::round_vec; #[test] + /// Verifies the standard PPO output against fixture data. fn test_ppo() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let (ppo_line, signal_line, histogram) = ppo(&input, 12, 26, 9); @@ -71,6 +75,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(ppo_line, 8), round_vec(expected_ppo, 8), diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index 6e0c48e..d8411a9 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -53,8 +53,12 @@ mod tests { use crate::utils::round_vec; #[test] + /// Verifies the standard PVO output against fixture data. fn test_pvo() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let input = testutils::load_data(&format!("../data/{}.json", symbol), "v"); let (pvo_line, signal_line, histogram) = pvo(&input, 12, 26, 9); @@ -72,6 +76,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(pvo_line, 8), round_vec(expected_pvo, 8), diff --git a/core/src/indicators/sma.rs b/core/src/indicators/sma.rs index a88306c..2ed340d 100644 --- a/core/src/indicators/sma.rs +++ b/core/src/indicators/sma.rs @@ -1,3 +1,7 @@ +/// Computes a simple moving average over a dense `f64` series. +/// +/// The returned vector keeps the same length as the input and emits `None` +/// until the first full `period` window has been observed. pub fn sma(data: &[f64], period: usize) -> Vec> { let mut sma = vec![None; data.len()]; let mut sum = 0.0; @@ -19,6 +23,12 @@ pub fn sma(data: &[f64], period: usize) -> Vec> { sma } +/// Computes an SMA over an aligned optional series. +/// +/// The input is expected to contain an optional prefix of `None` values followed +/// by a contiguous run of `Some(f64)` values. The returned vector preserves the +/// original alignment while avoiding the extra compaction/remapping pass that +/// would otherwise be needed before applying `sma`. pub(crate) fn sma_aligned(data: &[Option], period: usize) -> Vec> { let mut result = vec![None; data.len()]; let Some(first_valid_idx) = data.iter().position(|value| value.is_some()) else { @@ -56,9 +66,13 @@ mod tests { use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard SMA output against fixture data. #[test] fn test_sma() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let result = sma(&input, 20); @@ -67,6 +81,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(result, 8), expected, @@ -75,4 +90,18 @@ mod tests { ); } } + + /// Verifies that aligned SMA preserves offsets while matching dense SMA values. + #[test] + fn test_sma_aligned() { + // Given + let aligned = vec![None, None, Some(1.0), Some(2.0), Some(3.0), Some(4.0)]; + let expected = vec![None, None, None, Some(1.5), Some(2.5), Some(3.5)]; + + // When + let result = sma_aligned(&aligned, 2); + + // Then + assert_eq!(result, expected); + } } From 6a9b94e9f02cf196e5939e7e9ff67d15e9e5553e Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 17 Apr 2026 00:36:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Split=20aligned=20sign?= =?UTF-8?q?al=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/indicators/ema.rs | 10 +------- core/src/indicators/eom.rs | 36 +++++++++++++++++++++++++---- core/src/indicators/macd.rs | 45 ++++++++++++++++-------------------- core/src/indicators/massi.rs | 37 ++++++++++++++++++++++++----- core/src/indicators/nvi.rs | 26 ++++++++++++++++----- core/src/indicators/obv.rs | 20 ++++++++++------ core/src/indicators/ppo.rs | 40 +++++++++++++++++++++++++------- core/src/indicators/pvi.rs | 26 ++++++++++++++++----- core/src/indicators/pvo.rs | 41 ++++++++++++++++++++++++-------- core/src/indicators/sonar.rs | 29 +++++++++++++++++++---- 10 files changed, 225 insertions(+), 85 deletions(-) diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 3546c94..06d9fae 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -111,15 +111,7 @@ mod tests { #[test] fn test_ema_aligned_with_interior_gaps() { // Given - let aligned = vec![ - None, - None, - Some(1.0), - Some(2.0), - None, - Some(3.0), - Some(4.0), - ]; + let aligned = vec![None, None, Some(1.0), Some(2.0), None, Some(3.0), Some(4.0)]; let expected = vec![None, None, None, Some(1.5), None, Some(2.5), Some(3.5)]; // When diff --git a/core/src/indicators/eom.rs b/core/src/indicators/eom.rs index 5c6f656..8ef4461 100644 --- a/core/src/indicators/eom.rs +++ b/core/src/indicators/eom.rs @@ -8,9 +8,34 @@ pub fn eom( signal_period: usize, scale: f64, ) -> (Vec>, Vec>) { + let eom_line = eom_line(highs, lows, volumes, period, scale); + let signal = sma_aligned(&eom_line, signal_period); + + (eom_line, signal) +} + +pub fn eom_signal( + highs: &[f64], + lows: &[f64], + volumes: &[f64], + period: usize, + signal_period: usize, + scale: f64, +) -> Vec> { + let eom_line = eom_line(highs, lows, volumes, period, scale); + sma_aligned(&eom_line, signal_period) +} + +pub fn eom_line( + highs: &[f64], + lows: &[f64], + volumes: &[f64], + period: usize, + scale: f64, +) -> Vec> { let len = highs.len(); if len < 2 || len != lows.len() || len != volumes.len() { - return (vec![None; len], vec![None; len]); + return vec![None; len]; } let mut eom_values = Vec::with_capacity(len - 1); @@ -35,9 +60,7 @@ pub fn eom( eom_line[i + 1] = value; } - let signal = sma_aligned(&eom_line, signal_period); - - (eom_line, signal) + eom_line } #[cfg(test)] @@ -46,9 +69,13 @@ mod tests { use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard EOM outputs against fixture data. #[test] fn test_eom() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When 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"); @@ -65,6 +92,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(eom, 8), round_vec(expected_eom, 8), diff --git a/core/src/indicators/macd.rs b/core/src/indicators/macd.rs index 47eb82a..0cb3d9f 100644 --- a/core/src/indicators/macd.rs +++ b/core/src/indicators/macd.rs @@ -6,10 +6,8 @@ pub fn macd( slow_period: usize, signal_period: usize, ) -> (Vec>, Vec>, Vec>) { - let macd_line = calc_macd_line(data, fast_period, slow_period); - let signal_line = calc_macd_signal(&macd_line, signal_period); - - // Calculate the histogram + let macd_line = macd_line(data, fast_period, slow_period); + let signal_line = ema_aligned(&macd_line, signal_period); let histogram = macd_line .iter() .zip(signal_line.iter()) @@ -22,28 +20,14 @@ pub fn macd( (macd_line, signal_line, histogram) } -pub fn macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { - calc_macd_line(data, fast_period, slow_period) -} - -pub fn macd_signal( - data: &[f64], - fast_period: usize, - slow_period: usize, - signal_period: usize, -) -> Vec> { - let macd_line = calc_macd_line(data, fast_period, slow_period); - calc_macd_signal(&macd_line, signal_period) -} - pub fn macd_histogram( data: &[f64], fast_period: usize, slow_period: usize, signal_period: usize, ) -> Vec> { - let macd_line = calc_macd_line(data, fast_period, slow_period); - let signal_line = calc_macd_signal(&macd_line, signal_period); + let macd_line = macd_line(data, fast_period, slow_period); + let signal_line = ema_aligned(&macd_line, signal_period); macd_line .iter() @@ -55,7 +39,17 @@ pub fn macd_histogram( .collect() } -fn calc_macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { +pub fn macd_signal( + data: &[f64], + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> Vec> { + let macd_line = macd_line(data, fast_period, slow_period); + ema_aligned(&macd_line, signal_period) +} + +pub fn macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { let mut macd_line = vec![None; data.len()]; if data.len() < slow_period || fast_period >= slow_period { @@ -74,19 +68,19 @@ fn calc_macd_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - ema_aligned(macd_line, signal_period) -} - #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard MACD outputs against fixture data. #[test] fn test_macd() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let (macd_line, signal_line, histogram) = macd(&input, 12, 26, 9); @@ -104,6 +98,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(macd_line, 8), round_vec(expected_macd, 8), diff --git a/core/src/indicators/massi.rs b/core/src/indicators/massi.rs index d083f8e..e008241 100644 --- a/core/src/indicators/massi.rs +++ b/core/src/indicators/massi.rs @@ -7,12 +7,34 @@ pub fn massi( period_sum: usize, period_signal: usize, ) -> (Vec>, Vec>) { + let mass = massi_line(highs, lows, period_ema, period_sum); + let signal = ema_aligned(&mass, period_signal); + + (mass, signal) +} + +pub fn massi_signal( + highs: &[f64], + lows: &[f64], + period_ema: usize, + period_sum: usize, + period_signal: usize, +) -> Vec> { + let mass = massi_line(highs, lows, period_ema, period_sum); + ema_aligned(&mass, period_signal) +} + +pub fn massi_line( + highs: &[f64], + lows: &[f64], + period_ema: usize, + period_sum: usize, +) -> Vec> { let len = highs.len(); let mut mass = vec![None; len]; - let mut signal = vec![None; len]; - if len < 2 * (period_ema - 1) + (period_sum - 1) + 1 { - return (mass, signal); + if len != lows.len() || 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(); @@ -36,9 +58,7 @@ pub fn massi( } } - signal = ema_aligned(&mass, period_signal); - - (mass, signal) + mass } #[cfg(test)] @@ -47,9 +67,13 @@ mod tests { use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard MASSI outputs against fixture data. #[test] fn test_massi() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When 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"); @@ -65,6 +89,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(mass, 8), round_vec(expected_mass, 8), diff --git a/core/src/indicators/nvi.rs b/core/src/indicators/nvi.rs index 09b8d70..d9f402a 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -5,12 +5,23 @@ pub fn nvi( volumes: &[f64], signal_period: usize, ) -> (Vec>, Vec>) { + let nvi_line = nvi_line(closes, volumes); + let signal = ema_aligned(&nvi_line, signal_period); + + (nvi_line, signal) +} + +pub fn nvi_signal(closes: &[f64], volumes: &[f64], signal_period: usize) -> Vec> { + let nvi_line = nvi_line(closes, volumes); + ema_aligned(&nvi_line, signal_period) +} + +pub fn nvi_line(closes: &[f64], volumes: &[f64]) -> Vec> { let len = closes.len(); let mut nvi_line = vec![None; len]; - let mut signal = vec![None; len]; - if len < 2 { - return (nvi_line, signal); + if len < 2 || len != volumes.len() { + return nvi_line; } let mut nvi_point = 1000.0; @@ -23,9 +34,7 @@ pub fn nvi( nvi_line[i] = Some(nvi_point); } - signal = ema_aligned(&nvi_line, signal_period); - - (nvi_line, signal) + nvi_line } #[cfg(test)] @@ -34,9 +43,13 @@ mod tests { use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard NVI outputs against fixture data. #[test] fn test_nvi() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v"); @@ -52,6 +65,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(nvi, 8), round_vec(expected_nvi, 8), diff --git a/core/src/indicators/obv.rs b/core/src/indicators/obv.rs index 250c676..43beaa8 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -5,13 +5,18 @@ pub fn obv( volumes: &[f64], signal_period: usize, ) -> (Vec>, Vec>) { - let obv_line = calc_obv_line(data, volumes); - let obv_signal = obv_signal_by_obvline(&obv_line, signal_period); + let obv_line = obv_line(data, volumes); + let obv_signal = ema_aligned(&obv_line, signal_period); (obv_line, obv_signal) } -fn calc_obv_line(data: &[f64], volumes: &[f64]) -> Vec> { +pub fn obv_signal(data: &[f64], volumes: &[f64], signal_period: usize) -> Vec> { + let obv_line = obv_line(data, volumes); + ema_aligned(&obv_line, signal_period) +} + +pub fn obv_line(data: &[f64], volumes: &[f64]) -> Vec> { let mut obv = vec![None; data.len()]; let len = data.len(); @@ -43,19 +48,19 @@ fn calc_obv_line(data: &[f64], volumes: &[f64]) -> Vec> { obv } -fn obv_signal_by_obvline(obv_line: &[Option], signal_period: usize) -> Vec> { - ema_aligned(obv_line, signal_period) -} - #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard OBV outputs against fixture data. #[test] fn test_obv() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v"); @@ -70,6 +75,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(line, 4), round_vec(expected_line, 4), diff --git a/core/src/indicators/ppo.rs b/core/src/indicators/ppo.rs index ac2bba5..100f39d 100644 --- a/core/src/indicators/ppo.rs +++ b/core/src/indicators/ppo.rs @@ -6,8 +6,8 @@ pub fn ppo( slow_period: usize, signal_period: usize, ) -> (Vec>, Vec>, Vec>) { - let ppo_line = calc_ppo_line(data, fast_period, slow_period); - let signal_line = calc_ppo_signal(&ppo_line, signal_period); + let ppo_line = ppo_line(data, fast_period, slow_period); + let signal_line = ema_aligned(&ppo_line, signal_period); let histogram = ppo_line .iter() .zip(signal_line.iter()) @@ -20,7 +20,35 @@ pub fn ppo( (ppo_line, signal_line, histogram) } -fn calc_ppo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { +pub fn ppo_histogram( + data: &[f64], + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> Vec> { + let ppo_line = ppo_line(data, fast_period, slow_period); + let signal_line = ema_aligned(&ppo_line, signal_period); + ppo_line + .iter() + .zip(signal_line.iter()) + .map(|(&ppo, &signal)| match (ppo, signal) { + (Some(p), Some(s)) => Some(p - s), + _ => None, + }) + .collect() +} + +pub fn ppo_signal( + data: &[f64], + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> Vec> { + let ppo_line = ppo_line(data, fast_period, slow_period); + ema_aligned(&ppo_line, signal_period) +} + +pub fn ppo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { let mut ppo_line = vec![None; data.len()]; if data.len() < slow_period || fast_period >= slow_period { @@ -41,18 +69,14 @@ fn calc_ppo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - ema_aligned(ppo_line, signal_period) -} - #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; - #[test] /// Verifies the standard PPO output against fixture data. + #[test] fn test_ppo() { // Given let test_cases = vec!["005930", "TSLA"]; diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index 0fe8399..f0984c2 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -5,12 +5,23 @@ pub fn pvi( volumes: &[f64], signal_period: usize, ) -> (Vec>, Vec>) { + let pvi_line = pvi_line(closes, volumes); + let signal = ema_aligned(&pvi_line, signal_period); + + (pvi_line, signal) +} + +pub fn pvi_signal(closes: &[f64], volumes: &[f64], signal_period: usize) -> Vec> { + let pvi_line = pvi_line(closes, volumes); + ema_aligned(&pvi_line, signal_period) +} + +pub fn pvi_line(closes: &[f64], volumes: &[f64]) -> Vec> { let len = closes.len(); let mut pvi_line = vec![None; len]; - let mut signal = vec![None; len]; - if len < 2 { - return (pvi_line, signal); + if len < 2 || len != volumes.len() { + return pvi_line; } let mut pvi_point = 1000.0; @@ -23,9 +34,7 @@ pub fn pvi( pvi_line[i] = Some(pvi_point); } - signal = ema_aligned(&pvi_line, signal_period); - - (pvi_line, signal) + pvi_line } #[cfg(test)] @@ -34,9 +43,13 @@ mod tests { use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard PVI outputs against fixture data. #[test] fn test_pvi() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v"); @@ -52,6 +65,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(pvi, 8), round_vec(expected_pvi, 8), diff --git a/core/src/indicators/pvo.rs b/core/src/indicators/pvo.rs index d8411a9..bbb6b64 100644 --- a/core/src/indicators/pvo.rs +++ b/core/src/indicators/pvo.rs @@ -6,10 +6,8 @@ pub fn pvo( slow_period: usize, signal_period: usize, ) -> (Vec>, Vec>, Vec>) { - let pvo_line = calc_pvo_line(data, fast_period, slow_period); - let signal_line = calc_pvo_signal(&pvo_line, signal_period); - - // Calculate the histogram + let pvo_line = pvo_line(data, fast_period, slow_period); + let signal_line = ema_aligned(&pvo_line, signal_period); let histogram = pvo_line .iter() .zip(signal_line.iter()) @@ -22,7 +20,35 @@ pub fn pvo( (pvo_line, signal_line, histogram) } -fn calc_pvo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { +pub fn pvo_histogram( + data: &[f64], + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> Vec> { + let pvo_line = pvo_line(data, fast_period, slow_period); + let signal_line = ema_aligned(&pvo_line, signal_period); + pvo_line + .iter() + .zip(signal_line.iter()) + .map(|(&pvo, &signal)| match (pvo, signal) { + (Some(p), Some(s)) => Some(p - s), + _ => None, + }) + .collect() +} + +pub fn pvo_signal( + data: &[f64], + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> Vec> { + let pvo_line = pvo_line(data, fast_period, slow_period); + ema_aligned(&pvo_line, signal_period) +} + +pub fn pvo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec> { let mut pvo_line = vec![None; data.len()]; if data.len() < slow_period || fast_period >= slow_period { @@ -43,17 +69,14 @@ fn calc_pvo_line(data: &[f64], fast_period: usize, slow_period: usize) -> Vec], signal_period: usize) -> Vec> { - ema_aligned(pvo_line, signal_period) -} #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; - #[test] /// Verifies the standard PVO output against fixture data. + #[test] fn test_pvo() { // Given let test_cases = vec!["005930", "TSLA"]; diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index 4c42984..688aae8 100644 --- a/core/src/indicators/sonar.rs +++ b/core/src/indicators/sonar.rs @@ -6,11 +6,27 @@ pub fn sonar( step: usize, signal_period: usize, ) -> (Vec>, Vec>) { + let sonar_line = sonar_line(data, period, step); + let signal_line = ema_aligned(&sonar_line, signal_period); + + (sonar_line, signal_line) +} + +pub fn sonar_signal( + data: &[f64], + period: usize, + step: usize, + signal_period: usize, +) -> Vec> { + let sonar_line = sonar_line(data, period, step); + ema_aligned(&sonar_line, signal_period) +} + +pub fn sonar_line(data: &[f64], period: usize, step: usize) -> Vec> { let mut sonar_line = vec![None; data.len()]; - let mut signal_line = vec![None; data.len()]; if data.len() < period + step { - return (sonar_line, signal_line); + return sonar_line; } let ema_values = ema(data, period); @@ -21,9 +37,7 @@ pub fn sonar( } } - signal_line = ema_aligned(&sonar_line, signal_period); - - (sonar_line, signal_line) + sonar_line } #[cfg(test)] @@ -32,9 +46,13 @@ mod tests { use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard SONAR outputs against fixture data. #[test] fn test_sonar() { + // Given let test_cases = vec!["005930", "TSLA"]; + + // When for symbol in test_cases { let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); let (sonar_line, signal_line) = sonar(&input, 9, 6, 5); @@ -48,6 +66,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(sonar_line, 8), round_vec(expected_sonar, 8),