diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index e377839..06d9fae 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,15 +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()]; + if period == 0 { + return result; + } + + let alpha = 2.0 / (period as f64 + 1.0); + let mut seeded_count = 0usize; + let mut seed_sum = 0.0; + let mut ema = None; + + for (idx, value) in data.iter().enumerate() { + let Some(value) = value else { + continue; + }; + + 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 +} + #[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); @@ -35,6 +83,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(result, 8), round_vec(expected, 8), @@ -43,4 +92,32 @@ 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/eom.rs b/core/src/indicators/eom.rs index f6aadb9..8ef4461 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], @@ -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,15 +60,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; - } - - (eom_line, signal) + eom_line } #[cfg(test)] @@ -52,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"); @@ -71,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 cdcde1d..0cb3d9f 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], @@ -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,34 +68,19 @@ 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); - - for i in 0..ema_values.len() { - if let Some(ema_value) = ema_values[i] { - signal_line[i + null_count] = Some(ema_value); - } - } - - signal_line -} - #[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); @@ -119,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 b84f9cd..e008241 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], @@ -7,23 +7,44 @@ 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(); 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,14 +58,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; - } - - (mass, signal) + mass } #[cfg(test)] @@ -53,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"); @@ -71,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 5f46065..d9f402a 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -1,16 +1,27 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::ema_aligned; pub fn nvi( closes: &[f64], 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,14 +34,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; - } - - (nvi_line, signal) + nvi_line } #[cfg(test)] @@ -39,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"); @@ -57,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 7a76927..43beaa8 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -1,17 +1,22 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::ema_aligned; pub fn obv( data: &[f64], 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,35 +48,19 @@ fn calc_obv_line(data: &[f64], volumes: &[f64]) -> Vec> { obv } -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 -} - #[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"); @@ -86,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 bec1bb9..100f39d 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], @@ -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,31 +69,19 @@ 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 -} - #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard PPO output against fixture data. #[test] 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); @@ -83,6 +99,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(ppo_line, 8), round_vec(expected_ppo, 8), diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index 5b54ccb..f0984c2 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -1,16 +1,27 @@ -use crate::indicators::ema::ema; +use crate::indicators::ema::ema_aligned; pub fn pvi( closes: &[f64], 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,14 +34,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; - } - - (pvi_line, signal) + pvi_line } #[cfg(test)] @@ -39,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"); @@ -57,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 c5c65b6..bbb6b64 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], @@ -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,30 +69,19 @@ 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 -} #[cfg(test)] mod tests { use super::*; use crate::testutils; use crate::utils::round_vec; + /// Verifies the standard PVO output against fixture data. #[test] 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); @@ -84,6 +99,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 c1efe3a..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,15 +23,56 @@ 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 { + 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::*; 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); @@ -36,6 +81,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(result, 8), expected, @@ -44,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); + } } diff --git a/core/src/indicators/sonar.rs b/core/src/indicators/sonar.rs index e6e3db2..688aae8 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], @@ -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,15 +37,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; - } - - (sonar_line, signal_line) + sonar_line } #[cfg(test)] @@ -38,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); @@ -54,6 +66,7 @@ mod tests { symbol )); + // Then assert_eq!( round_vec(sonar_line, 8), round_vec(expected_sonar, 8),