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),