From 60167deffcfa84e75640238beb0e3af47a753bd7 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Tue, 21 Apr 2026 16:52:40 +0900 Subject: [PATCH 1/8] Support nullable core stateful indicators --- core/src/indicators/ad.rs | 47 +++++++-- core/src/indicators/adx.rs | 49 +++++---- core/src/indicators/adxr.rs | 21 ++-- core/src/indicators/atr.rs | 85 ++++++++++++---- core/src/indicators/cmf.rs | 103 +++++++++++-------- core/src/indicators/co.rs | 64 ++++++++---- core/src/indicators/dmi.rs | 174 ++++++++++++++++++++++++++------ core/src/indicators/efi.rs | 72 +++++++++---- core/src/indicators/eom.rs | 95 ++++++++++++----- core/src/indicators/nvi.rs | 60 +++++++++-- core/src/indicators/obv.rs | 56 +++++++--- core/src/indicators/psar.rs | 161 ++++++++++++++++++++++++----- core/src/indicators/pvi.rs | 66 ++++++++++-- core/src/indicators/rsi.rs | 101 +++++++++++++----- core/src/indicators/stochrsi.rs | 4 +- core/src/indicators/ultosc.rs | 155 ++++++++++++++++++---------- core/src/indicators/vr.rs | 104 ++++++++++--------- core/src/utils.rs | 137 +++++++++++++++++++++++++ 18 files changed, 1181 insertions(+), 373 deletions(-) diff --git a/core/src/indicators/ad.rs b/core/src/indicators/ad.rs index 3d2c8f3..7a6240f 100644 --- a/core/src/indicators/ad.rs +++ b/core/src/indicators/ad.rs @@ -1,6 +1,11 @@ use crate::utils::calc_clv; -pub fn ad(highs: &[f64], lows: &[f64], closes: &[f64], volumes: &[f64]) -> Vec> { +pub fn ad( + highs: &[Option], + lows: &[Option], + closes: &[Option], + volumes: &[Option], +) -> Vec> { let mut ad = vec![None; highs.len()]; let len = highs.len(); @@ -11,7 +16,13 @@ pub fn ad(highs: &[f64], lows: &[f64], closes: &[f64], volumes: &[f64]) -> Vec>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let result = ad(&high, &low, &close, &volume); let expected = testutils::load_expected::>(&format!( @@ -47,4 +70,16 @@ mod tests { ); } } + + #[test] + fn test_ad_with_gap_preserves_running_total() { + let highs = vec![Some(10.0), Some(12.0), None, Some(14.0)]; + let lows = vec![Some(8.0), Some(10.0), None, Some(12.0)]; + let closes = vec![Some(9.0), Some(11.0), None, Some(13.0)]; + let volumes = vec![Some(100.0), Some(100.0), Some(100.0), Some(100.0)]; + + let result = ad(&highs, &lows, &closes, &volumes); + + assert_eq!(result, vec![Some(0.0), Some(0.0), None, Some(0.0)]); + } } diff --git a/core/src/indicators/adx.rs b/core/src/indicators/adx.rs index e93d6e1..b179c73 100644 --- a/core/src/indicators/adx.rs +++ b/core/src/indicators/adx.rs @@ -1,36 +1,45 @@ use crate::indicators::dmi::dmi; pub fn adx( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], dmi_period: usize, adx_period: usize, ) -> Vec> { let (plus_di, minus_di) = dmi(highs, lows, closes, dmi_period); - let mut adx = Vec::with_capacity(plus_di.len()); + let mut adx = vec![None; plus_di.len()]; let mut dx_sum = 0.0; - let mut adx_point = 0.0; + let mut seeded = 0usize; + let mut adx_point = None; + + if adx_period == 0 { + return adx; + } for i in 0..plus_di.len() { - let dx = match (plus_di[i], minus_di[i]) { + let Some(dx) = (match (plus_di[i], minus_di[i]) { (Some(plus), Some(minus)) if plus != 0.0 || minus != 0.0 => { - (plus - minus).abs() / (plus + minus) * 100.0 + Some((plus - minus).abs() / (plus + minus) * 100.0) } - _ => 0.0, + (Some(_), Some(_)) => Some(0.0), + _ => None, + }) else { + continue; }; - let initial_period = dmi_period + adx_period - 1; - if i < initial_period { - dx_sum += dx; - adx.push(None); - } else if i == initial_period { - dx_sum += dx; - adx_point = dx_sum / adx_period as f64; - adx.push(Some(adx_point)); + if let Some(current_adx) = adx_point { + let next_adx = (current_adx * (adx_period - 1) as f64 + dx) / adx_period as f64; + adx_point = Some(next_adx); + adx[i] = Some(next_adx); } else { - adx_point = (adx_point * (adx_period - 1) as f64 + dx) / adx_period as f64; - adx.push(Some(adx_point)); + dx_sum += dx; + seeded += 1; + if seeded == adx_period { + let initial_adx = dx_sum / adx_period as f64; + adx_point = Some(initial_adx); + adx[i] = Some(initial_adx); + } } } @@ -51,6 +60,10 @@ mod tests { let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let highs = highs.into_iter().map(Some).collect::>(); + let lows = lows.into_iter().map(Some).collect::>(); + let closes = closes.into_iter().map(Some).collect::>(); + let result = adx(&highs, &lows, &closes, 14, 14); let expected = testutils::load_expected::>(&format!( "../data/expected/adx_{}.json", diff --git a/core/src/indicators/adxr.rs b/core/src/indicators/adxr.rs index fc88df1..24475c7 100644 --- a/core/src/indicators/adxr.rs +++ b/core/src/indicators/adxr.rs @@ -1,9 +1,9 @@ use crate::indicators::adx::adx; pub fn adxr( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], dmi_period: usize, adx_period: usize, adxr_period: usize, @@ -35,9 +35,18 @@ mod tests { fn test_adxr() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = adxr(&highs, &lows, &closes, 14, 14, 14); let expected = testutils::load_expected::>(&format!( diff --git a/core/src/indicators/atr.rs b/core/src/indicators/atr.rs index bdfe912..f06c872 100644 --- a/core/src/indicators/atr.rs +++ b/core/src/indicators/atr.rs @@ -1,4 +1,9 @@ -pub fn atr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec> { +pub fn atr( + highs: &[Option], + lows: &[Option], + closes: &[Option], + period: usize, +) -> Vec> { let mut atr = vec![None; highs.len()]; let len = highs.len(); @@ -7,25 +12,27 @@ pub fn atr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = atr(&high, &low, &close, 20); let expected = testutils::load_expected::>(&format!( "../data/expected/atr_{}.json", @@ -66,4 +82,39 @@ mod tests { ); } } + + #[test] + fn test_atr_with_gap_resumes_from_prior_state() { + let highs = vec![ + Some(10.0), + Some(12.0), + Some(13.0), + None, + Some(15.0), + Some(16.0), + ]; + let lows = vec![ + Some(8.0), + Some(10.0), + Some(11.0), + None, + Some(13.0), + Some(14.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + Some(12.0), + None, + Some(14.0), + Some(15.0), + ]; + + let result = atr(&highs, &lows, &closes, 3); + + assert_eq!( + result, + vec![None, None, Some(2.5), None, None, Some(2.3333333333333335)] + ); + } } diff --git a/core/src/indicators/cmf.rs b/core/src/indicators/cmf.rs index f671a00..615be4e 100644 --- a/core/src/indicators/cmf.rs +++ b/core/src/indicators/cmf.rs @@ -1,10 +1,10 @@ -use crate::utils::calc_clv; +use crate::utils::{calc_clv, rolling_sum_strict}; pub fn cmf( - highs: &[f64], - lows: &[f64], - closes: &[f64], - volumes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], + volumes: &[Option], period: usize, ) -> Vec> { let mut cmf = vec![None; highs.len()]; @@ -19,38 +19,37 @@ pub fn cmf( return cmf; } - let mut sum_money_flow_volume = 0.0; - let mut sum_volume = 0.0; + let money_flow_volume = highs + .iter() + .zip(lows.iter()) + .zip(closes.iter()) + .zip(volumes.iter()) + .map( + |(((high, low), close), volume)| match (high, low, close, volume) { + (Some(high), Some(low), Some(close), Some(volume)) => { + Some(calc_clv(*high, *low, *close) * *volume) + } + _ => None, + }, + ) + .collect::>(); + let volume_sums = rolling_sum_strict(volumes, period); + let money_flow_sums = rolling_sum_strict(&money_flow_volume, period); - for i in 0..period { - let current_money_flow_volume = calc_clv(highs[i], lows[i], closes[i]) * volumes[i]; - sum_money_flow_volume += current_money_flow_volume; - sum_volume += volumes[i]; - } - - let cmf_point = if sum_volume == 0.0 { - None - } else { - Some(sum_money_flow_volume / sum_volume) - }; - cmf[period - 1] = cmf_point; - - for i in period..len { - let oldest_money_flow_volume = - calc_clv(highs[i - period], lows[i - period], closes[i - period]) * volumes[i - period]; - let current_money_flow_volume = calc_clv(highs[i], lows[i], closes[i]) * volumes[i]; - - sum_money_flow_volume -= oldest_money_flow_volume; - sum_money_flow_volume += current_money_flow_volume; - sum_volume -= volumes[i - period]; - sum_volume += volumes[i]; - - let cmf_point = if sum_volume == 0.0 { - Some(0.0) - } else { - Some(sum_money_flow_volume / sum_volume) - }; - cmf[i] = cmf_point; + for i in 0..len { + if let (Some(sum_money_flow_volume), Some(sum_volume)) = + (money_flow_sums[i], volume_sums[i]) + { + cmf[i] = if sum_volume == 0.0 { + if i == period - 1 { + None + } else { + Some(0.0) + } + } else { + Some(sum_money_flow_volume / sum_volume) + }; + } } cmf @@ -66,10 +65,22 @@ mod tests { fn test_cmf() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); - let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v"); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let result = cmf(&high, &low, &close, &volume, 21); let expected = testutils::load_expected::>(&format!( "../data/expected/cmf_{}.json", @@ -84,4 +95,16 @@ mod tests { ); } } + + #[test] + fn test_cmf_gap_invalidates_window_until_full_window_recovers() { + let highs = vec![Some(10.0), Some(12.0), None, Some(14.0), Some(16.0)]; + let lows = vec![Some(8.0), Some(10.0), None, Some(12.0), Some(14.0)]; + let closes = vec![Some(9.0), Some(11.0), None, Some(13.0), Some(15.0)]; + let volumes = vec![Some(100.0), Some(100.0), None, Some(100.0), Some(100.0)]; + + let result = cmf(&highs, &lows, &closes, &volumes, 2); + + assert_eq!(result, vec![None, Some(0.0), None, None, Some(0.0)]); + } } diff --git a/core/src/indicators/co.rs b/core/src/indicators/co.rs index 8fc22b6..1040e7b 100644 --- a/core/src/indicators/co.rs +++ b/core/src/indicators/co.rs @@ -1,10 +1,10 @@ use crate::indicators::ad; pub fn co( - highs: &[f64], - lows: &[f64], - closes: &[f64], - volumes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], + volumes: &[Option], period_short: usize, period_long: usize, ) -> Vec> { @@ -14,27 +14,27 @@ pub fn co( if len < period_long { return co; } else if period_long == period_short { - return vec![Some(0.0); len]; + return ad(highs, lows, closes, volumes) + .into_iter() + .map(|value| value.map(|_| 0.0)) + .collect(); } let ad_values = ad(highs, lows, closes, volumes); let short_k = 2.0 / (period_short as f64 + 1.0); let long_k = 2.0 / (period_long as f64 + 1.0); + let mut short_ema = None; + let mut long_ema = None; - if ad_values[0].is_none() { - return co; - } - - let mut short_ema = ad_values[0].unwrap(); - let mut long_ema = short_ema; - - for i in 1..len { + for i in 0..len { if let Some(ad) = ad_values[i] { - short_ema = ad * short_k + short_ema * (1.0 - short_k); - long_ema = ad * long_k + long_ema * (1.0 - long_k); + let next_short = short_ema.map_or(ad, |prev| ad * short_k + prev * (1.0 - short_k)); + let next_long = long_ema.map_or(ad, |prev| ad * long_k + prev * (1.0 - long_k)); + short_ema = Some(next_short); + long_ema = Some(next_long); if i >= period_long - 1 { - co[i] = Some(short_ema - long_ema); + co[i] = Some(next_short - next_long); } } } @@ -52,10 +52,22 @@ mod tests { fn test_co() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); - let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v"); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let co_result = co(&highs, &lows, &closes, &volumes, 3, 10); @@ -72,4 +84,16 @@ mod tests { ); } } + + #[test] + fn test_co_gap_preserves_ema_state_and_resumes_on_valid_ad_rows() { + let highs = vec![Some(10.0), Some(12.0), None, Some(14.0), Some(16.0)]; + let lows = vec![Some(8.0), Some(10.0), None, Some(12.0), Some(14.0)]; + let closes = vec![Some(9.0), Some(11.0), None, Some(13.0), Some(15.0)]; + let volumes = vec![Some(100.0), Some(100.0), None, Some(100.0), Some(100.0)]; + + let result = co(&highs, &lows, &closes, &volumes, 2, 3); + + assert_eq!(result, vec![None, None, None, Some(0.0), Some(0.0)]); + } } diff --git a/core/src/indicators/dmi.rs b/core/src/indicators/dmi.rs index 55bb036..4a72ddf 100644 --- a/core/src/indicators/dmi.rs +++ b/core/src/indicators/dmi.rs @@ -1,41 +1,56 @@ -use crate::{utils::calc_true_ranges, wilders_smoothing}; +use crate::utils::{calc_true_ranges_aligned, wilders_smoothing_aligned}; pub fn dmi( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], period: usize, ) -> (Vec>, Vec>) { let len = highs.len(); let mut plus_di = vec![None; len]; let mut minus_di = vec![None; len]; - let delta_highs: Vec = highs.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect(); - let delta_lows: Vec = lows.windows(2).map(|w| (w[0] - w[1]).max(0.0)).collect(); - let trs = calc_true_ranges(highs, lows, closes); - - let plus_dm: Vec = delta_highs - .iter() - .zip(delta_lows.iter()) - .map(|(&dh, &dl)| if dh > dl && dh > 0.0 { dh } else { 0.0 }) - .collect(); - let minus_dm: Vec = delta_highs - .iter() - .zip(delta_lows.iter()) - .map(|(&dh, &dl)| if dl > dh && dl > 0.0 { dl } else { 0.0 }) - .collect(); - - let plus_dm_sum = wilders_smoothing(&plus_dm, period); - let minus_dm_sum = wilders_smoothing(&minus_dm, period); - let tr_sum = wilders_smoothing(&trs, period); - - for i in period..len { - if tr_sum[i - period] == 0.0 { - plus_di[i] = Some(0.0); - minus_di[i] = Some(0.0); + let trs = calc_true_ranges_aligned(highs, lows, closes); + let mut plus_dm = vec![None; len]; + let mut minus_dm = vec![None; len]; + + for i in 1..len { + let (Some(prev_high), Some(high), Some(prev_low), Some(low), Some(_)) = + (highs[i - 1], highs[i], lows[i - 1], lows[i], trs[i]) + else { + continue; + }; + + let delta_high = (high - prev_high).max(0.0); + let delta_low = (prev_low - low).max(0.0); + + plus_dm[i] = Some(if delta_high > delta_low && delta_high > 0.0 { + delta_high } else { - plus_di[i] = Some((plus_dm_sum[i - period] / tr_sum[i - period]) * 100.0); - minus_di[i] = Some((minus_dm_sum[i - period] / tr_sum[i - period]) * 100.0); + 0.0 + }); + minus_dm[i] = Some(if delta_low > delta_high && delta_low > 0.0 { + delta_low + } else { + 0.0 + }); + } + + let plus_dm_sum = wilders_smoothing_aligned(&plus_dm, period); + let minus_dm_sum = wilders_smoothing_aligned(&minus_dm, period); + let tr_sum = wilders_smoothing_aligned(&trs, period); + + for i in 0..len { + if let (Some(plus_sum), Some(minus_sum), Some(tr_total)) = + (plus_dm_sum[i], minus_dm_sum[i], tr_sum[i]) + { + if tr_total == 0.0 { + plus_di[i] = Some(0.0); + minus_di[i] = Some(0.0); + } else { + plus_di[i] = Some((plus_sum / tr_total) * 100.0); + minus_di[i] = Some((minus_sum / tr_total) * 100.0); + } } } @@ -52,9 +67,18 @@ mod tests { fn test_dmi() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let (plus_di, minus_di) = dmi(&highs, &lows, &closes, 14); let expected_plus_di = testutils::load_expected::>(&format!( @@ -80,4 +104,90 @@ mod tests { ); } } + + #[test] + fn test_dmi_with_gap_requires_valid_predecessor_and_resumes() { + let highs = vec![ + Some(10.0), + Some(12.0), + Some(14.0), + None, + Some(15.0), + Some(16.0), + Some(18.0), + ]; + let lows = vec![ + Some(8.0), + Some(9.0), + Some(11.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + Some(13.0), + None, + Some(14.0), + Some(15.0), + Some(17.0), + ]; + + let (plus_di, minus_di) = dmi(&highs, &lows, &closes, 2); + + assert_eq!( + plus_di, + vec![ + None, + None, + Some(66.66666666666666), + None, + None, + Some(58.82352941176471), + Some(63.41463414634146), + ] + ); + assert_eq!( + minus_di, + vec![None, None, Some(0.0), None, None, Some(0.0), Some(0.0)] + ); + } + + #[test] + fn test_dmi_non_synchronous_predecessor_gap_does_not_advance_tr_state() { + let highs = vec![ + Some(10.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + Some(16.0), + ]; + let lows = vec![ + Some(8.0), + None, + Some(11.0), + Some(12.0), + Some(13.0), + Some(14.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + Some(12.0), + Some(13.0), + Some(14.0), + Some(15.0), + ]; + + let (plus_di, minus_di) = dmi(&highs, &lows, &closes, 2); + + assert_eq!( + plus_di, + vec![None, None, None, None, Some(50.0), Some(50.0),] + ); + assert_eq!(minus_di, vec![None, None, None, None, Some(0.0), Some(0.0)]); + } } diff --git a/core/src/indicators/efi.rs b/core/src/indicators/efi.rs index bf54d59..3ff3324 100644 --- a/core/src/indicators/efi.rs +++ b/core/src/indicators/efi.rs @@ -1,6 +1,6 @@ -use crate::indicators::ema::ema_dense; +use crate::indicators::ema::ema_aligned; -pub fn efi(closes: &[f64], volumes: &[f64], period: usize) -> Vec> { +pub fn efi(closes: &[Option], volumes: &[Option], period: usize) -> Vec> { let len = closes.len(); let mut efi = vec![None; len]; @@ -8,27 +8,19 @@ pub fn efi(closes: &[f64], volumes: &[f64], period: usize) -> Vec> { return efi; } - let force: Vec = closes - .windows(2) - .zip(volumes.iter().skip(1)) - .map(|(window, &volume)| (window[1] - window[0]) * volume) - .collect(); + let mut force = vec![None; len]; + for i in 1..len { + let (Some(close), Some(prev_close), Some(volume)) = (closes[i], closes[i - 1], volumes[i]) + else { + continue; + }; + force[i] = Some((close - prev_close) * volume); + } if period == 1 { - efi.iter_mut() - .skip(1) - .zip(force.iter()) - .for_each(|(efi_val, &force_val)| { - *efi_val = Some(force_val); - }); + efi = force; } else { - let ema_result = ema_dense(&force, period); - efi.iter_mut() - .skip(1) - .zip(ema_result.into_iter()) - .for_each(|(efi_val, ema_val)| { - *efi_val = ema_val; - }); + efi = ema_aligned(&force, period); } efi @@ -44,8 +36,14 @@ mod tests { fn test_efi() { let test_cases = vec!["005930", "TSLA"]; 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"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let result = efi(&close, &volume, 14); let expected = testutils::load_expected::>(&format!( "../data/expected/efi_{}.json", @@ -60,4 +58,34 @@ mod tests { ); } } + + #[test] + fn test_efi_gap_skips_missing_pair_and_resumes_ema_state() { + let closes = vec![ + Some(10.0), + Some(11.0), + Some(13.0), + None, + Some(16.0), + Some(17.0), + ]; + let volumes = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + Some(4.0), + Some(5.0), + Some(6.0), + ]; + + let result = efi(&closes, &volumes, 2); + + assert_eq!( + round_vec(result, 8), + round_vec( + vec![None, None, Some(4.0), None, None, Some(5.333333333333333)], + 8 + ) + ); + } } diff --git a/core/src/indicators/eom.rs b/core/src/indicators/eom.rs index 9fed832..956592d 100644 --- a/core/src/indicators/eom.rs +++ b/core/src/indicators/eom.rs @@ -1,9 +1,9 @@ -use crate::indicators::sma::{sma_aligned, sma_dense}; +use crate::indicators::sma::sma_aligned; pub fn eom( - highs: &[f64], - lows: &[f64], - volumes: &[f64], + highs: &[Option], + lows: &[Option], + volumes: &[Option], period: usize, signal_period: usize, scale: f64, @@ -15,9 +15,9 @@ pub fn eom( } pub fn eom_signal( - highs: &[f64], - lows: &[f64], - volumes: &[f64], + highs: &[Option], + lows: &[Option], + volumes: &[Option], period: usize, signal_period: usize, scale: f64, @@ -27,9 +27,9 @@ pub fn eom_signal( } pub fn eom_line( - highs: &[f64], - lows: &[f64], - volumes: &[f64], + highs: &[Option], + lows: &[Option], + volumes: &[Option], period: usize, scale: f64, ) -> Vec> { @@ -38,29 +38,29 @@ pub fn eom_line( return vec![None; len]; } - let mut eom_values = Vec::with_capacity(len - 1); + let mut eom_values = vec![None; len]; for i in 1..len { - let high_low_avg = (highs[i] + lows[i]) / 2.0; - let prev_high_low_avg = (highs[i - 1] + lows[i - 1]) / 2.0; + let (Some(high), Some(low), Some(prev_high), Some(prev_low), Some(volume)) = + (highs[i], lows[i], highs[i - 1], lows[i - 1], volumes[i]) + else { + continue; + }; + + let high_low_avg = (high + low) / 2.0; + let prev_high_low_avg = (prev_high + prev_low) / 2.0; let distance_moved = high_low_avg - prev_high_low_avg; - let high_low_diff = highs[i] - lows[i]; - let box_ratio = if high_low_diff != 0.0 && volumes[i] != 0.0 { - (volumes[i] / scale) / high_low_diff + let high_low_diff = high - low; + let box_ratio = if high_low_diff != 0.0 && volume != 0.0 { + (volume / scale) / high_low_diff } else { 0.0 }; - eom_values.push(distance_moved / box_ratio); + eom_values[i] = Some(distance_moved / box_ratio); } - let mut eom_line = vec![None; len]; - let eom_sma = sma_dense(&eom_values, period); - for (i, &value) in eom_sma.iter().enumerate() { - eom_line[i + 1] = value; - } - - eom_line + sma_aligned(&eom_values, period) } #[cfg(test)] @@ -77,9 +77,18 @@ mod tests { // 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"); - let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v"); + let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let (eom, signal) = eom(&highs, &lows, &volumes, 14, 3, 10000.0); @@ -107,4 +116,36 @@ mod tests { ); } } + + #[test] + fn test_eom_gap_invalidates_sma_window_until_full_valid_window_returns() { + let highs = vec![ + Some(10.0), + Some(12.0), + Some(14.0), + None, + Some(16.0), + Some(18.0), + ]; + let lows = vec![ + Some(8.0), + Some(10.0), + Some(12.0), + None, + Some(14.0), + Some(16.0), + ]; + let volumes = vec![ + Some(100.0), + Some(100.0), + Some(100.0), + None, + Some(100.0), + Some(100.0), + ]; + + let result = eom_line(&highs, &lows, &volumes, 2, 100.0); + + assert_eq!(result, vec![None, None, Some(4.0), None, None, None]); + } } diff --git a/core/src/indicators/nvi.rs b/core/src/indicators/nvi.rs index d9f402a..fcdd9d7 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -1,8 +1,8 @@ use crate::indicators::ema::ema_aligned; pub fn nvi( - closes: &[f64], - volumes: &[f64], + closes: &[Option], + volumes: &[Option], signal_period: usize, ) -> (Vec>, Vec>) { let nvi_line = nvi_line(closes, volumes); @@ -11,12 +11,16 @@ pub fn nvi( (nvi_line, signal) } -pub fn nvi_signal(closes: &[f64], volumes: &[f64], signal_period: usize) -> Vec> { +pub fn nvi_signal( + closes: &[Option], + volumes: &[Option], + 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> { +pub fn nvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { let len = closes.len(); let mut nvi_line = vec![None; len]; @@ -24,14 +28,29 @@ pub fn nvi_line(closes: &[f64], volumes: &[f64]) -> Vec> { return nvi_line; } - let mut nvi_point = 1000.0; - nvi_line[0] = Some(nvi_point); + let mut nvi_point = None; + if let (Some(_), Some(_)) = (closes[0], volumes[0]) { + nvi_point = Some(1000.0); + nvi_line[0] = Some(1000.0); + } for i in 1..len { - if volumes[i] < volumes[i - 1] { - nvi_point += (closes[i] - closes[i - 1]) * 100.0 / closes[i - 1]; + let (Some(close), Some(prev_close), Some(volume), Some(prev_volume), Some(current_nvi)) = ( + closes[i], + closes[i - 1], + volumes[i], + volumes[i - 1], + nvi_point, + ) else { + continue; + }; + + let mut next_nvi = current_nvi; + if volume < prev_volume { + next_nvi += (close - prev_close) * 100.0 / prev_close; } - nvi_line[i] = Some(nvi_point); + nvi_point = Some(next_nvi); + nvi_line[i] = Some(next_nvi); } nvi_line @@ -51,8 +70,14 @@ mod tests { // 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"); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let (nvi, signal) = nvi(&closes, &volumes, 255); @@ -80,4 +105,17 @@ mod tests { ); } } + + #[test] + fn test_nvi_with_gap_requires_valid_predecessor_to_resume() { + let closes = vec![Some(10.0), Some(12.0), None, Some(15.0), Some(18.0)]; + let volumes = vec![Some(100.0), Some(80.0), Some(90.0), Some(70.0), Some(60.0)]; + + let result = nvi_line(&closes, &volumes); + + assert_eq!( + result, + vec![Some(1000.0), Some(1020.0), None, None, Some(1040.0)] + ); + } } diff --git a/core/src/indicators/obv.rs b/core/src/indicators/obv.rs index 43beaa8..c99d6e0 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -1,8 +1,8 @@ use crate::indicators::ema::ema_aligned; pub fn obv( - data: &[f64], - volumes: &[f64], + data: &[Option], + volumes: &[Option], signal_period: usize, ) -> (Vec>, Vec>) { let obv_line = obv_line(data, volumes); @@ -11,12 +11,16 @@ pub fn obv( (obv_line, obv_signal) } -pub fn obv_signal(data: &[f64], volumes: &[f64], signal_period: usize) -> Vec> { +pub fn obv_signal( + data: &[Option], + volumes: &[Option], + 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> { +pub fn obv_line(data: &[Option], volumes: &[Option]) -> Vec> { let mut obv = vec![None; data.len()]; let len = data.len(); @@ -25,13 +29,19 @@ pub fn obv_line(data: &[f64], volumes: &[f64]) -> Vec> { return obv; } - let mut obv_point = volumes[0]; - obv[0] = Some(obv_point); + let mut obv_point = None; + + if let (Some(_), Some(volume)) = (data[0], volumes[0]) { + obv_point = Some(volume); + obv[0] = Some(volume); + } for i in 1..len { - let current_close = data[i]; - let prev_close = data[i - 1]; - let volume = volumes[i]; + let (Some(current_close), Some(prev_close), Some(volume), Some(current_obv)) = + (data[i], data[i - 1], volumes[i], obv_point) + else { + continue; + }; let increment = if prev_close < current_close { volume @@ -41,8 +51,9 @@ pub fn obv_line(data: &[f64], volumes: &[f64]) -> Vec> { 0.0 }; - obv_point += increment; - obv[i] = Some(obv_point); + let next_obv = current_obv + increment; + obv_point = Some(next_obv); + obv[i] = Some(next_obv); } obv @@ -62,8 +73,14 @@ mod tests { // 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"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let (line, signal) = obv(&close, &volume, 9); let expected_line = testutils::load_expected::>(&format!( @@ -91,4 +108,17 @@ mod tests { ); } } + + #[test] + fn test_obv_with_gap_preserves_state_until_valid_predecessor_returns() { + let closes = vec![Some(10.0), Some(11.0), None, Some(13.0), Some(12.0)]; + let volumes = vec![Some(100.0), Some(50.0), Some(40.0), Some(30.0), Some(20.0)]; + + let result = obv_line(&closes, &volumes); + + assert_eq!( + result, + vec![Some(100.0), Some(150.0), None, None, Some(130.0)] + ); + } } diff --git a/core/src/indicators/psar.rs b/core/src/indicators/psar.rs index 56a3e73..97e0f81 100644 --- a/core/src/indicators/psar.rs +++ b/core/src/indicators/psar.rs @@ -1,7 +1,7 @@ pub fn psar( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], increment: f64, initial_acceleration_factor: f64, max_acceleration_factor: f64, @@ -13,41 +13,101 @@ pub fn psar( return psar; } - let mut direction = (closes[1] > closes[0]) as usize; - let mut extreme_point = if direction == 1 { highs[1] } else { lows[1] }; - let mut current_psar = if direction == 1 { lows[0] } else { highs[0] }; + let mut direction = None; + let mut extreme_point = None; + let mut current_psar = None; let mut acceleration_factor = initial_acceleration_factor; + let mut last_valid_highs = Vec::with_capacity(2); + let mut last_valid_lows = Vec::with_capacity(2); + let mut emitted_points = 0usize; - for i in 2..len { - current_psar += acceleration_factor * (extreme_point - current_psar); + for i in 1..len { + if direction.is_none() { + let ( + Some(prev_close), + Some(close), + Some(prev_low), + Some(low), + Some(prev_high), + Some(high), + ) = ( + closes[i - 1], + closes[i], + lows[i - 1], + lows[i], + highs[i - 1], + highs[i], + ) + else { + continue; + }; + + let next_direction = (close > prev_close) as usize; + direction = Some(next_direction); + extreme_point = Some(if next_direction == 1 { high } else { low }); + current_psar = Some(if next_direction == 1 { + prev_low + } else { + prev_high + }); + last_valid_highs = vec![prev_high, high]; + last_valid_lows = vec![prev_low, low]; + continue; + } - if i >= 3 { - current_psar = if direction == 1 { - current_psar.min(lows[i - 1].min(lows[i - 2])) + let ( + Some(high), + Some(low), + Some(mut psar_point), + Some(mut current_extreme), + Some(mut current_direction), + ) = (highs[i], lows[i], current_psar, extreme_point, direction) + else { + continue; + }; + + psar_point += acceleration_factor * (current_extreme - psar_point); + + if emitted_points > 0 && last_valid_highs.len() >= 2 && last_valid_lows.len() >= 2 { + psar_point = if current_direction == 1 { + psar_point.min(last_valid_lows[0].min(last_valid_lows[1])) } else { - current_psar.max(highs[i - 1].max(highs[i - 2])) + psar_point.max(last_valid_highs[0].max(last_valid_highs[1])) }; } - let is_direction_changed = if direction == 1 { - lows[i] < current_psar + let is_direction_changed = if current_direction == 1 { + low < psar_point } else { - highs[i] > current_psar + high > psar_point }; if is_direction_changed { - direction = 1 - direction; - current_psar = extreme_point; - extreme_point = if direction == 1 { highs[i] } else { lows[i] }; + current_direction = 1 - current_direction; + psar_point = current_extreme; + current_extreme = if current_direction == 1 { high } else { low }; acceleration_factor = initial_acceleration_factor; - } else if (direction == 1 && highs[i] > extreme_point) - || (direction == 0 && lows[i] < extreme_point) + } else if (current_direction == 1 && high > current_extreme) + || (current_direction == 0 && low < current_extreme) { - extreme_point = if direction == 1 { highs[i] } else { lows[i] }; + current_extreme = if current_direction == 1 { high } else { low }; acceleration_factor = (acceleration_factor + increment).min(max_acceleration_factor); } - psar[i] = Some(current_psar); + current_psar = Some(psar_point); + extreme_point = Some(current_extreme); + direction = Some(current_direction); + psar[i] = Some(psar_point); + emitted_points += 1; + + if last_valid_highs.len() == 2 { + last_valid_highs.remove(0); + } + if last_valid_lows.len() == 2 { + last_valid_lows.remove(0); + } + last_valid_highs.push(high); + last_valid_lows.push(low); } psar @@ -63,9 +123,18 @@ mod tests { fn test_psar() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = psar(&high, &low, &close, 0.02, 0.02, 0.2); let expected = testutils::load_expected::>(&format!( "../data/expected/psar_{}.json", @@ -80,4 +149,46 @@ mod tests { ); } } + + #[test] + fn test_psar_gap_preserves_state_and_resumes_without_reseed() { + let highs = vec![ + Some(10.0), + Some(11.0), + Some(12.0), + None, + Some(13.0), + Some(14.0), + ]; + let lows = vec![ + Some(8.0), + Some(9.0), + Some(10.0), + None, + Some(11.0), + Some(12.0), + ]; + let closes = vec![ + Some(9.0), + Some(10.0), + Some(11.0), + None, + Some(12.0), + Some(13.0), + ]; + + let result = psar(&highs, &lows, &closes, 0.02, 0.02, 0.2); + + assert_eq!( + result, + vec![ + None, + None, + Some(8.06), + None, + Some(8.217600000000001), + Some(8.504544000000001), + ] + ); + } } diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index f0984c2..92bb912 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -1,8 +1,8 @@ use crate::indicators::ema::ema_aligned; pub fn pvi( - closes: &[f64], - volumes: &[f64], + closes: &[Option], + volumes: &[Option], signal_period: usize, ) -> (Vec>, Vec>) { let pvi_line = pvi_line(closes, volumes); @@ -11,12 +11,16 @@ pub fn pvi( (pvi_line, signal) } -pub fn pvi_signal(closes: &[f64], volumes: &[f64], signal_period: usize) -> Vec> { +pub fn pvi_signal( + closes: &[Option], + volumes: &[Option], + 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> { +pub fn pvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { let len = closes.len(); let mut pvi_line = vec![None; len]; @@ -24,14 +28,29 @@ pub fn pvi_line(closes: &[f64], volumes: &[f64]) -> Vec> { return pvi_line; } - let mut pvi_point = 1000.0; - pvi_line[0] = Some(pvi_point); + let mut pvi_point = None; + if let (Some(_), Some(_)) = (closes[0], volumes[0]) { + pvi_point = Some(1000.0); + pvi_line[0] = Some(1000.0); + } for i in 1..len { - if volumes[i] > volumes[i - 1] { - pvi_point += (closes[i] - closes[i - 1]) * 100.0 / closes[i - 1]; + let (Some(close), Some(prev_close), Some(volume), Some(prev_volume), Some(current_pvi)) = ( + closes[i], + closes[i - 1], + volumes[i], + volumes[i - 1], + pvi_point, + ) else { + continue; + }; + + let mut next_pvi = current_pvi; + if volume > prev_volume { + next_pvi += (close - prev_close) * 100.0 / prev_close; } - pvi_line[i] = Some(pvi_point); + pvi_point = Some(next_pvi); + pvi_line[i] = Some(next_pvi); } pvi_line @@ -51,8 +70,14 @@ mod tests { // 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"); + let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volumes = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let (pvi, signal) = pvi(&closes, &volumes, 255); @@ -80,4 +105,23 @@ mod tests { ); } } + + #[test] + fn test_pvi_with_gap_requires_valid_predecessor_to_resume() { + let closes = vec![Some(10.0), Some(12.0), None, Some(15.0), Some(18.0)]; + let volumes = vec![ + Some(100.0), + Some(120.0), + Some(90.0), + Some(150.0), + Some(180.0), + ]; + + let result = pvi_line(&closes, &volumes); + + assert_eq!( + result, + vec![Some(1000.0), Some(1020.0), None, None, Some(1040.0)] + ); + } } diff --git a/core/src/indicators/rsi.rs b/core/src/indicators/rsi.rs index dfd7335..172c62c 100644 --- a/core/src/indicators/rsi.rs +++ b/core/src/indicators/rsi.rs @@ -1,43 +1,59 @@ -pub fn rsi(data: &[f64], period: usize) -> Vec> { +pub(crate) fn rsi_dense(data: &[f64], period: usize) -> Vec> { + let nullable = data.iter().copied().map(Some).collect::>(); + rsi(&nullable, period) +} + +pub fn rsi(data: &[Option], period: usize) -> Vec> { let mut rsi = vec![None; data.len()]; - if data.len() < period { + if data.len() < period || period <= 1 { return rsi; } let mut total_up = 0.0; let mut total_down = 0.0; - let mut avg_up; - let mut avg_down; + let mut avg_up = None; + let mut avg_down = None; + let mut seeded_changes = 0usize; + + for i in 1..data.len() { + let (Some(current), Some(prev)) = (data[i], data[i - 1]) else { + continue; + }; + + let change = current - prev; + + if let (Some(current_avg_up), Some(current_avg_down)) = (avg_up, avg_down) { + let up = change.max(0.0); + let down = change.min(0.0).abs(); + let next_avg_up = (current_avg_up * (period - 1) as f64 + up) / period as f64; + let next_avg_down = (current_avg_down * (period - 1) as f64 + down) / period as f64; + avg_up = Some(next_avg_up); + avg_down = Some(next_avg_down); + + let rsi_point = if next_avg_down == 0.0 { + 100.0 + } else if next_avg_up == 0.0 { + 0.0 + } else { + (next_avg_up / (next_avg_up + next_avg_down)) * 100.0 + }; + + rsi[i] = Some(rsi_point); + continue; + } - for i in 1..period { - let change = data[i] - data[i - 1]; if change > 0.0 { total_up += change; } else { total_down += change.abs(); } - } - - avg_up = total_up / (period - 1) as f64; - avg_down = total_down / (period - 1) as f64; - for i in period..data.len() { - let change = data[i] - data[i - 1]; - let up = change.max(0.0); - let down = change.min(0.0).abs(); - avg_up = (avg_up * (period - 1) as f64 + up) / period as f64; - avg_down = (avg_down * (period - 1) as f64 + down) / period as f64; - - let rsi_point = if avg_down == 0.0 { - 100.0 - } else if avg_up == 0.0 { - 0.0 - } else { - (avg_up / (avg_up + avg_down)) * 100.0 - }; - - rsi[i] = Some(rsi_point); + seeded_changes += 1; + if seeded_changes == period - 1 { + avg_up = Some(total_up / (period - 1) as f64); + avg_down = Some(total_down / (period - 1) as f64); + } } rsi @@ -53,7 +69,10 @@ mod tests { fn test_rsi() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let input = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let input = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = rsi(&input, 14); let expected = testutils::load_expected::>(&format!( "../data/expected/rsi_{}.json", @@ -68,4 +87,32 @@ mod tests { ); } } + + #[test] + fn test_rsi_with_interior_gap_resumes_from_prior_state() { + let aligned = vec![ + Some(1.0), + Some(2.0), + Some(3.0), + Some(2.0), + None, + Some(4.0), + Some(5.0), + ]; + + let result = rsi(&aligned, 3); + + assert_eq!( + result, + vec![ + None, + None, + None, + Some(66.66666666666666), + None, + None, + Some(77.77777777777779), + ] + ); + } } diff --git a/core/src/indicators/stochrsi.rs b/core/src/indicators/stochrsi.rs index 6c1fdaa..4fda02f 100644 --- a/core/src/indicators/stochrsi.rs +++ b/core/src/indicators/stochrsi.rs @@ -1,4 +1,4 @@ -use crate::indicators::rsi; +use crate::indicators::rsi::rsi_dense; use crate::utils::{rolling_max_min, rolling_mean_strict}; pub fn stochrsi( @@ -14,7 +14,7 @@ pub fn stochrsi( return (percent_k, vec![None; len]); } - let rsi_values = rsi(closes, period_rsi); + let rsi_values = rsi_dense(closes, period_rsi); let rsi_values_with_nan: Vec = rsi_values .iter() .map(|value| value.unwrap_or(f64::NAN)) diff --git a/core/src/indicators/ultosc.rs b/core/src/indicators/ultosc.rs index ff2f6be..e67a633 100644 --- a/core/src/indicators/ultosc.rs +++ b/core/src/indicators/ultosc.rs @@ -1,9 +1,9 @@ -use crate::utils::calc_true_ranges; +use crate::utils::rolling_sum_strict; pub fn ultosc( - highs: &[f64], - lows: &[f64], - closes: &[f64], + highs: &[Option], + lows: &[Option], + closes: &[Option], period_short: usize, period_medium: usize, period_long: usize, @@ -15,65 +15,74 @@ pub fn ultosc( return ultosc; } - let trs = calc_true_ranges(highs, lows, closes); let buying_pressures = calc_buying_pressures(closes, lows); - - let mut long_denomi = 0.0; - let mut long_nomi = 0.0; - let mut medium_denomi = 0.0; - let mut medium_nomi = 0.0; - let mut short_denomi = 0.0; - let mut short_nomi = 0.0; - - for i in 1..len { - let bp = buying_pressures[i - 1]; - let tr = trs[i - 1]; - - long_denomi += bp; - long_nomi += tr; - - if i >= period_long - period_medium + 1 { - medium_denomi += bp; - medium_nomi += tr; + let true_ranges = calc_true_ranges(closes, highs, lows); + let short_bp = rolling_sum_strict(&buying_pressures, period_short); + let medium_bp = rolling_sum_strict(&buying_pressures, period_medium); + let long_bp = rolling_sum_strict(&buying_pressures, period_long); + let short_tr = rolling_sum_strict(&true_ranges, period_short); + let medium_tr = rolling_sum_strict(&true_ranges, period_medium); + let long_tr = rolling_sum_strict(&true_ranges, period_long); + + for i in 0..len { + if let ( + Some(short_bp), + Some(medium_bp), + Some(long_bp), + Some(short_tr), + Some(medium_tr), + Some(long_tr), + ) = ( + short_bp[i], + medium_bp[i], + long_bp[i], + short_tr[i], + medium_tr[i], + long_tr[i], + ) { + let uo_point = + ((long_bp / long_tr + 2.0 * (medium_bp / medium_tr) + 4.0 * (short_bp / short_tr)) + * 100.0) + / 7.0; + ultosc[i] = Some(uo_point); } + } - if i >= period_long - period_short + 1 { - short_denomi += bp; - short_nomi += tr; - } + ultosc +} - if i >= period_long { - let uo_point = ((long_denomi / long_nomi - + 2.0 * (medium_denomi / medium_nomi) - + 4.0 * (short_denomi / short_nomi)) - * 100.0) - / 7.0; +fn calc_buying_pressures(closes: &[Option], lows: &[Option]) -> Vec> { + let len = closes.len(); + let mut buying_pressures = vec![None; len]; - ultosc[i] = Some(uo_point); + for i in 1..len { + let (Some(close), Some(low), Some(prev_close)) = (closes[i], lows[i], closes[i - 1]) else { + continue; + }; - // Remove oldest values from each period - long_denomi -= buying_pressures[i - period_long]; - long_nomi -= trs[i - period_long]; - medium_denomi -= buying_pressures[i - period_medium]; - medium_nomi -= trs[i - period_medium]; - short_denomi -= buying_pressures[i - period_short]; - short_nomi -= trs[i - period_short]; - } + buying_pressures[i] = Some(close - low.min(prev_close)); } - ultosc + buying_pressures } -fn calc_buying_pressures(closes: &[f64], lows: &[f64]) -> Vec { +fn calc_true_ranges( + closes: &[Option], + highs: &[Option], + lows: &[Option], +) -> Vec> { let len = closes.len(); - let mut buying_pressures = Vec::with_capacity(len - 1); + let mut true_ranges = vec![None; len]; for i in 1..len { - let bp = closes[i] - lows[i].min(closes[i - 1]); - buying_pressures.push(bp); + let (Some(prev_close), Some(high), Some(low)) = (closes[i - 1], highs[i], lows[i]) else { + continue; + }; + + true_ranges[i] = Some(high.max(prev_close) - low.min(prev_close)); } - buying_pressures + true_ranges } #[cfg(test)] @@ -86,9 +95,18 @@ mod tests { fn test_ultosc() { let test_cases = vec!["005930", "TSLA"]; for symbol in test_cases { - let high = testutils::load_data(&format!("../data/{}.json", symbol), "h"); - let low = testutils::load_data(&format!("../data/{}.json", symbol), "l"); - let close = testutils::load_data(&format!("../data/{}.json", symbol), "c"); + let high = testutils::load_data(&format!("../data/{}.json", symbol), "h") + .into_iter() + .map(Some) + .collect::>(); + let low = testutils::load_data(&format!("../data/{}.json", symbol), "l") + .into_iter() + .map(Some) + .collect::>(); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); let result = ultosc(&high, &low, &close, 7, 14, 28); let expected = testutils::load_expected::>(&format!( "../data/expected/ultosc_{}.json", @@ -103,4 +121,39 @@ mod tests { ); } } + + #[test] + fn test_ultosc_gap_invalidates_all_windows_until_full_valid_recovery() { + let highs = vec![ + Some(10.0), + Some(12.0), + Some(13.0), + None, + Some(15.0), + Some(16.0), + Some(17.0), + ]; + let lows = vec![ + Some(8.0), + Some(10.0), + Some(11.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + Some(12.0), + None, + Some(14.0), + Some(15.0), + Some(16.0), + ]; + + let result = ultosc(&highs, &lows, &closes, 2, 3, 4); + + assert_eq!(result, vec![None, None, None, None, None, None, None]); + } } diff --git a/core/src/indicators/vr.rs b/core/src/indicators/vr.rs index 54ee250..4831af2 100644 --- a/core/src/indicators/vr.rs +++ b/core/src/indicators/vr.rs @@ -1,5 +1,7 @@ +use crate::utils::rolling_sum_strict; + /// Volume Ratio (VR) -pub fn vr(closes: &[f64], volumes: &[f64], period: usize) -> Vec> { +pub fn vr(closes: &[Option], volumes: &[Option], period: usize) -> Vec> { let len = closes.len(); let mut result = vec![None; len]; @@ -7,57 +9,46 @@ pub fn vr(closes: &[f64], volumes: &[f64], period: usize) -> Vec> { return result; } - let mut up_volume = 0.0; - let mut down_volume = 0.0; - let mut same_volume = 0.0; + let mut up = vec![None; len]; + let mut down = vec![None; len]; + let mut same = vec![None; len]; - // Initialize volumes for the first period - for i in 1..period { - update_volumes( - closes[i] - closes[i - 1], - volumes[i], - &mut up_volume, - &mut down_volume, - &mut same_volume, - ); - } + for i in 1..len { + let (Some(close), Some(prev_close), Some(volume)) = (closes[i], closes[i - 1], volumes[i]) + else { + continue; + }; - // Calculate VR for each point after the initial period - for i in period..len { - update_volumes( - closes[i] - closes[i - 1], - volumes[i], - &mut up_volume, - &mut down_volume, - &mut same_volume, - ); + if close > prev_close { + up[i] = Some(volume); + down[i] = Some(0.0); + same[i] = Some(0.0); + } else if close < prev_close { + up[i] = Some(0.0); + down[i] = Some(volume); + same[i] = Some(0.0); + } else { + up[i] = Some(0.0); + down[i] = Some(0.0); + same[i] = Some(volume); + } + } - result[i] = Some(calculate_vr(up_volume, down_volume, same_volume)); + let up_sum = rolling_sum_strict(&up, period); + let down_sum = rolling_sum_strict(&down, period); + let same_sum = rolling_sum_strict(&same, period); - // Adjust volumes by removing the oldest value - update_volumes( - closes[i - period + 1] - closes[i - period], - -volumes[i - period + 1], - &mut up_volume, - &mut down_volume, - &mut same_volume, - ); + for i in 0..len { + if let (Some(up_volume), Some(down_volume), Some(same_volume)) = + (up_sum[i], down_sum[i], same_sum[i]) + { + result[i] = Some(calculate_vr(up_volume, down_volume, same_volume)); + } } result } -#[inline] -fn update_volumes(diff: f64, volume: f64, up: &mut f64, down: &mut f64, same: &mut f64) { - if diff > 0.0 { - *up += volume; - } else if diff < 0.0 { - *down += volume; - } else { - *same += volume; - } -} - #[inline] fn calculate_vr(up: f64, down: f64, same: f64) -> f64 { let denominator = down + same * 0.5; @@ -78,8 +69,14 @@ mod tests { fn test_vr() { let test_cases = vec!["005930", "TSLA"]; 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"); + let close = testutils::load_data(&format!("../data/{}.json", symbol), "c") + .into_iter() + .map(Some) + .collect::>(); + let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v") + .into_iter() + .map(Some) + .collect::>(); let result = vr(&close, &volume, 20); let expected = testutils::load_expected::>(&format!( "../data/expected/vr_{}.json", @@ -94,4 +91,21 @@ mod tests { ); } } + + #[test] + fn test_vr_gap_invalidates_window_until_full_pairwise_window_returns() { + let closes = vec![ + Some(10.0), + Some(11.0), + Some(10.0), + None, + Some(10.0), + Some(11.0), + ]; + let volumes = vec![Some(1.0), Some(3.0), Some(2.0), None, Some(4.0), Some(5.0)]; + + let result = vr(&closes, &volumes, 2); + + assert_eq!(result, vec![None, None, Some(150.0), None, None, None]); + } } diff --git a/core/src/utils.rs b/core/src/utils.rs index dff8f89..eb11d29 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -154,6 +154,37 @@ pub fn rolling_mean_strict(data: &[Option], period: usize) -> Vec], period: usize) -> Vec> { + let len = data.len(); + let mut sums = vec![None; len]; + + if len < period || period == 0 { + return sums; + } + + let mut sum = 0.0; + let mut valid_count = 0usize; + + for i in 0..len { + if let Some(value) = data[i] { + sum += value; + valid_count += 1; + } + + if i >= period { + if let Some(value) = data[i - period] { + sum -= value; + valid_count -= 1; + } + } + + if i >= period - 1 && valid_count == period { + sums[i] = Some(sum); + } + } + + sums +} /// Computes a rolling weighted mean that only emits a value when the full window is valid. pub fn rolling_weighted_mean_strict(data: &[Option], period: usize) -> Vec> { let len = data.len(); @@ -318,6 +349,31 @@ pub fn calc_true_ranges(highs: &[f64], lows: &[f64], closes: &[f64]) -> Vec result } +pub fn calc_true_ranges_aligned( + highs: &[Option], + lows: &[Option], + closes: &[Option], +) -> Vec> { + let len = highs.len(); + if len == 0 || len != lows.len() || len != closes.len() { + return Vec::new(); + } + + let mut result = vec![None; len]; + + for i in 1..len { + let (Some(_prev_high), Some(_prev_low), Some(high), Some(low), Some(prev_close)) = + (highs[i - 1], lows[i - 1], highs[i], lows[i], closes[i - 1]) + else { + continue; + }; + + result[i] = Some(calc_tr(high, low, prev_close)); + } + + result +} + fn calc_tr(high: f64, low: f64, prev_close: f64) -> f64 { let th = high.max(prev_close); let tl = low.min(prev_close); @@ -336,6 +392,47 @@ pub fn wilders_smoothing(data: &[f64], period: usize) -> Vec { result } +pub fn wilders_smoothing_aligned(data: &[Option], period: usize) -> Vec> { + let len = data.len(); + let mut result = vec![None; len]; + + if period == 0 || len < period { + return result; + } + + let mut seed_count = 0usize; + let mut seed_sum = 0.0; + let mut smoothed = None; + + for (idx, item) in data.iter().enumerate() { + let Some(value) = *item else { + continue; + }; + + if let Some(current) = smoothed { + let next = current - (current / period as f64) + value; + smoothed = Some(next); + result[idx] = Some(next); + continue; + } + + if seed_count < period - 1 { + seed_sum += value; + seed_count += 1; + continue; + } + + if seed_count == period - 1 { + let initial = seed_sum - (seed_sum / period as f64) + value; + smoothed = Some(initial); + result[idx] = Some(initial); + seed_count += 1; + } + } + + result +} + #[cfg(test)] mod tests { use super::*; @@ -484,6 +581,15 @@ mod tests { assert_eq!(means, vec![None, Some(2.0), None, None, Some(6.0)]); } + #[test] + fn test_rolling_sum_strict() { + let data = vec![Some(1.0), Some(3.0), None, Some(5.0), Some(7.0)]; + + let sums = rolling_sum_strict(&data, 2); + + assert_eq!(sums, vec![None, Some(4.0), None, None, Some(12.0)]); + } + #[test] fn test_rolling_weighted_mean_strict() { let data = vec![None, Some(1.0), Some(2.0), None, Some(4.0)]; @@ -549,6 +655,28 @@ mod tests { assert_eq!(result, expected, "Failed for dynamic input"); } + #[test] + fn test_calc_true_ranges_aligned_requires_valid_predecessor() { + let highs = vec![Some(10.0), Some(12.0), None, Some(15.0)]; + let lows = vec![Some(8.0), Some(10.0), None, Some(13.0)]; + let closes = vec![Some(9.0), Some(11.0), None, Some(14.0)]; + + let result = calc_true_ranges_aligned(&highs, &lows, &closes); + + assert_eq!(result, vec![None, Some(3.0), None, None]); + } + + #[test] + fn test_calc_true_ranges_aligned_requires_predecessor_high_low() { + let highs = vec![Some(10.0), None, Some(13.0), Some(15.0)]; + let lows = vec![Some(8.0), None, Some(11.0), Some(13.0)]; + let closes = vec![Some(9.0), Some(12.0), Some(12.5), Some(14.0)]; + + let result = calc_true_ranges_aligned(&highs, &lows, &closes); + + assert_eq!(result, vec![None, None, None, Some(2.5)]); + } + #[test] fn test_calc_wilders_smoothing() { // Using extended data for a more robust test case. @@ -568,4 +696,13 @@ mod tests { round_vec(expected, 8) ); } + + #[test] + fn test_calc_wilders_smoothing_aligned_resumes_after_gap() { + let data = vec![Some(1.0), Some(2.0), None, Some(3.0), Some(4.0)]; + + let result = wilders_smoothing_aligned(&data, 2); + + assert_eq!(result, vec![None, Some(2.5), None, Some(4.25), Some(6.125)]); + } } From 7f806e51f8539ea2404d2cf6e6d65b53274ebcd9 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 11:54:20 +0900 Subject: [PATCH 2/8] Fix nullable stateful seed resets --- core/src/indicators/adx.rs | 62 ++++++++++++++++++++++++ core/src/indicators/adxr.rs | 58 ++++++++++++++++++++++ core/src/indicators/atr.rs | 36 ++++++++++++++ core/src/indicators/co.rs | 95 +++++++++++++++++++++++++++++-------- core/src/indicators/dmi.rs | 33 +++++++++++++ core/src/indicators/rsi.rs | 25 ++++++++++ core/src/utils.rs | 13 +++++ 7 files changed, 302 insertions(+), 20 deletions(-) diff --git a/core/src/indicators/adx.rs b/core/src/indicators/adx.rs index b179c73..815ee68 100644 --- a/core/src/indicators/adx.rs +++ b/core/src/indicators/adx.rs @@ -25,6 +25,10 @@ pub fn adx( (Some(_), Some(_)) => Some(0.0), _ => None, }) else { + if adx_point.is_none() { + dx_sum = 0.0; + seeded = 0; + } continue; }; @@ -78,4 +82,62 @@ mod tests { ); } } + + #[test] + fn test_adx_requires_contiguous_seed_window_and_resumes_after_gap() { + let highs = vec![ + Some(10.0), + Some(12.0), + Some(14.0), + None, + Some(15.0), + Some(16.0), + Some(18.0), + None, + Some(19.0), + Some(20.0), + ]; + let lows = vec![ + Some(8.0), + Some(9.0), + Some(11.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + None, + Some(17.0), + Some(18.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + Some(13.0), + None, + Some(14.0), + Some(15.0), + Some(17.0), + None, + Some(18.0), + Some(19.0), + ]; + + let result = adx(&highs, &lows, &closes, 2, 2); + + assert_eq!( + result, + vec![ + None, + None, + None, + None, + None, + None, + Some(100.0), + None, + None, + Some(100.0) + ] + ); + } } diff --git a/core/src/indicators/adxr.rs b/core/src/indicators/adxr.rs index 24475c7..897453a 100644 --- a/core/src/indicators/adxr.rs +++ b/core/src/indicators/adxr.rs @@ -62,4 +62,62 @@ mod tests { ); } } + + #[test] + fn test_adxr_requires_current_and_lagged_adx_after_gaps() { + let highs = vec![ + Some(10.0), + Some(12.0), + Some(14.0), + None, + Some(15.0), + Some(16.0), + Some(18.0), + None, + Some(19.0), + Some(20.0), + ]; + let lows = vec![ + Some(8.0), + Some(9.0), + Some(11.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + None, + Some(17.0), + Some(18.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + Some(13.0), + None, + Some(14.0), + Some(15.0), + Some(17.0), + None, + Some(18.0), + Some(19.0), + ]; + + let result = adxr(&highs, &lows, &closes, 2, 2, 4); + + assert_eq!( + result, + vec![ + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some(100.0) + ] + ); + } } diff --git a/core/src/indicators/atr.rs b/core/src/indicators/atr.rs index f06c872..d8b3d5b 100644 --- a/core/src/indicators/atr.rs +++ b/core/src/indicators/atr.rs @@ -17,6 +17,10 @@ pub fn atr( for i in 1..len { let (Some(high), Some(low), Some(prev_close)) = (highs[i], lows[i], closes[i - 1]) else { + if prev_atr.is_none() { + tr_sum = 0.0; + seeded_ranges = 0; + } continue; }; @@ -117,4 +121,36 @@ mod tests { vec![None, None, Some(2.5), None, None, Some(2.3333333333333335)] ); } + + #[test] + fn test_atr_requires_contiguous_seed_window() { + let highs = vec![ + Some(10.0), + Some(12.0), + None, + Some(14.0), + Some(15.0), + Some(16.0), + ]; + let lows = vec![ + Some(8.0), + Some(10.0), + None, + Some(12.0), + Some(13.0), + Some(14.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + ]; + + let result = atr(&highs, &lows, &closes, 3); + + assert_eq!(result, vec![None, None, None, None, None, Some(2.0)]); + } } diff --git a/core/src/indicators/co.rs b/core/src/indicators/co.rs index 1040e7b..df2500f 100644 --- a/core/src/indicators/co.rs +++ b/core/src/indicators/co.rs @@ -11,13 +11,13 @@ pub fn co( let len = highs.len(); let mut co = vec![None; len]; - if len < period_long { + if len != lows.len() + || len != closes.len() + || len != volumes.len() + || period_short == 0 + || period_long == 0 + { return co; - } else if period_long == period_short { - return ad(highs, lows, closes, volumes) - .into_iter() - .map(|value| value.map(|_| 0.0)) - .collect(); } let ad_values = ad(highs, lows, closes, volumes); @@ -25,17 +25,33 @@ pub fn co( let long_k = 2.0 / (period_long as f64 + 1.0); let mut short_ema = None; let mut long_ema = None; + let mut seeded_rows = 0usize; + let mut output_seeded = false; for i in 0..len { - if let Some(ad) = ad_values[i] { - let next_short = short_ema.map_or(ad, |prev| ad * short_k + prev * (1.0 - short_k)); - let next_long = long_ema.map_or(ad, |prev| ad * long_k + prev * (1.0 - long_k)); - short_ema = Some(next_short); - long_ema = Some(next_long); - - if i >= period_long - 1 { - co[i] = Some(next_short - next_long); + let Some(ad) = ad_values[i] else { + if !output_seeded { + short_ema = None; + long_ema = None; + seeded_rows = 0; } + continue; + }; + + let next_short = short_ema.map_or(ad, |prev| ad * short_k + prev * (1.0 - short_k)); + let next_long = long_ema.map_or(ad, |prev| ad * long_k + prev * (1.0 - long_k)); + short_ema = Some(next_short); + long_ema = Some(next_long); + + if output_seeded { + co[i] = Some(next_short - next_long); + continue; + } + + seeded_rows += 1; + if seeded_rows == period_long { + output_seeded = true; + co[i] = Some(next_short - next_long); } } @@ -86,14 +102,53 @@ mod tests { } #[test] - fn test_co_gap_preserves_ema_state_and_resumes_on_valid_ad_rows() { - let highs = vec![Some(10.0), Some(12.0), None, Some(14.0), Some(16.0)]; - let lows = vec![Some(8.0), Some(10.0), None, Some(12.0), Some(14.0)]; - let closes = vec![Some(9.0), Some(11.0), None, Some(13.0), Some(15.0)]; - let volumes = vec![Some(100.0), Some(100.0), None, Some(100.0), Some(100.0)]; + fn test_co_requires_contiguous_seed_window_and_resumes_after_gap() { + let highs = vec![ + Some(10.0), + Some(12.0), + None, + Some(14.0), + Some(16.0), + Some(18.0), + None, + Some(20.0), + ]; + let lows = vec![ + Some(8.0), + Some(10.0), + None, + Some(12.0), + Some(14.0), + Some(16.0), + None, + Some(18.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + None, + Some(13.0), + Some(15.0), + Some(17.0), + None, + Some(19.0), + ]; + let volumes = vec![ + Some(100.0), + Some(100.0), + None, + Some(100.0), + Some(100.0), + Some(100.0), + None, + Some(100.0), + ]; let result = co(&highs, &lows, &closes, &volumes, 2, 3); - assert_eq!(result, vec![None, None, None, Some(0.0), Some(0.0)]); + assert_eq!( + result, + vec![None, None, None, None, None, Some(0.0), None, Some(0.0)] + ); } } diff --git a/core/src/indicators/dmi.rs b/core/src/indicators/dmi.rs index 4a72ddf..d556d39 100644 --- a/core/src/indicators/dmi.rs +++ b/core/src/indicators/dmi.rs @@ -155,6 +155,39 @@ mod tests { ); } + #[test] + fn test_dmi_requires_contiguous_seed_window() { + let highs = vec![ + Some(10.0), + Some(12.0), + None, + Some(13.0), + Some(14.0), + Some(15.0), + ]; + let lows = vec![ + Some(8.0), + Some(9.0), + None, + Some(11.0), + Some(12.0), + Some(13.0), + ]; + let closes = vec![ + Some(9.0), + Some(11.0), + None, + Some(12.0), + Some(13.0), + Some(14.0), + ]; + + let (plus_di, minus_di) = dmi(&highs, &lows, &closes, 2); + + assert_eq!(plus_di, vec![None, None, None, None, None, Some(50.0)]); + assert_eq!(minus_di, vec![None, None, None, None, None, Some(0.0)]); + } + #[test] fn test_dmi_non_synchronous_predecessor_gap_does_not_advance_tr_state() { let highs = vec![ diff --git a/core/src/indicators/rsi.rs b/core/src/indicators/rsi.rs index 172c62c..84a5ab6 100644 --- a/core/src/indicators/rsi.rs +++ b/core/src/indicators/rsi.rs @@ -18,6 +18,11 @@ pub fn rsi(data: &[Option], period: usize) -> Vec> { for i in 1..data.len() { let (Some(current), Some(prev)) = (data[i], data[i - 1]) else { + if avg_up.is_none() { + total_up = 0.0; + total_down = 0.0; + seeded_changes = 0; + } continue; }; @@ -115,4 +120,24 @@ mod tests { ] ); } + + #[test] + fn test_rsi_requires_contiguous_seed_window() { + let aligned = vec![ + Some(1.0), + Some(2.0), + None, + Some(3.0), + Some(4.0), + Some(5.0), + Some(4.0), + ]; + + let result = rsi(&aligned, 3); + + assert_eq!( + result, + vec![None, None, None, None, None, None, Some(66.66666666666666)] + ); + } } diff --git a/core/src/utils.rs b/core/src/utils.rs index eb11d29..8f657bd 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -406,6 +406,10 @@ pub fn wilders_smoothing_aligned(data: &[Option], period: usize) -> Vec Date: Thu, 23 Apr 2026 11:58:27 +0900 Subject: [PATCH 3/8] Restore CO equal-period zeros --- core/src/indicators/co.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/core/src/indicators/co.rs b/core/src/indicators/co.rs index df2500f..1d09bf6 100644 --- a/core/src/indicators/co.rs +++ b/core/src/indicators/co.rs @@ -20,6 +20,17 @@ pub fn co( return co; } + if len < period_long { + return co; + } + + if period_long == period_short { + return ad(highs, lows, closes, volumes) + .into_iter() + .map(|value| value.map(|_| 0.0)) + .collect(); + } + let ad_values = ad(highs, lows, closes, volumes); let short_k = 2.0 / (period_short as f64 + 1.0); let long_k = 2.0 / (period_long as f64 + 1.0); @@ -151,4 +162,19 @@ mod tests { vec![None, None, None, None, None, Some(0.0), None, Some(0.0)] ); } + + #[test] + fn test_co_equal_periods_return_zero_on_valid_ad_rows() { + let highs = vec![Some(10.0), Some(12.0), None, Some(14.0), Some(16.0)]; + let lows = vec![Some(8.0), Some(10.0), None, Some(12.0), Some(14.0)]; + let closes = vec![Some(9.0), Some(11.0), None, Some(13.0), Some(15.0)]; + let volumes = vec![Some(100.0), Some(100.0), None, Some(100.0), Some(100.0)]; + + let result = co(&highs, &lows, &closes, &volumes, 3, 3); + + assert_eq!( + result, + vec![Some(0.0), Some(0.0), None, Some(0.0), Some(0.0)] + ); + } } From 6728379393a5dec3f99837c49886ea7227b51e32 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 12:12:33 +0900 Subject: [PATCH 4/8] Reseed sparse signal EMAs --- core/src/indicators/ema.rs | 1 + core/src/indicators/nvi.rs | 44 +++++++++++++++++++++++++++++++++++--- core/src/indicators/obv.rs | 44 +++++++++++++++++++++++++++++++++++--- core/src/indicators/pvi.rs | 44 +++++++++++++++++++++++++++++++++++--- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 5fdb38d..52b2cc0 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -118,4 +118,5 @@ mod tests { assert_eq!(result, expected); } + } diff --git a/core/src/indicators/nvi.rs b/core/src/indicators/nvi.rs index fcdd9d7..53e080b 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema_reseed_on_gap; pub fn nvi( closes: &[Option], @@ -6,7 +6,7 @@ pub fn nvi( signal_period: usize, ) -> (Vec>, Vec>) { let nvi_line = nvi_line(closes, volumes); - let signal = ema_aligned(&nvi_line, signal_period); + let signal = ema_reseed_on_gap(&nvi_line, signal_period); (nvi_line, signal) } @@ -17,7 +17,7 @@ pub fn nvi_signal( signal_period: usize, ) -> Vec> { let nvi_line = nvi_line(closes, volumes); - ema_aligned(&nvi_line, signal_period) + ema_reseed_on_gap(&nvi_line, signal_period) } pub fn nvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { @@ -118,4 +118,42 @@ mod tests { vec![Some(1000.0), Some(1020.0), None, None, Some(1040.0)] ); } + + #[test] + fn test_nvi_signal_reseeds_after_gap_in_line() { + let closes = vec![ + Some(10.0), + Some(12.0), + None, + Some(15.0), + Some(18.0), + Some(27.0), + ]; + let volumes = vec![ + Some(100.0), + Some(80.0), + Some(90.0), + Some(70.0), + Some(60.0), + Some(50.0), + ]; + + let (line, signal) = nvi(&closes, &volumes, 2); + + assert_eq!( + line, + vec![ + Some(1000.0), + Some(1020.0), + None, + None, + Some(1040.0), + Some(1090.0), + ] + ); + assert_eq!( + signal, + vec![None, Some(1010.0), None, None, None, Some(1065.0)] + ); + } } diff --git a/core/src/indicators/obv.rs b/core/src/indicators/obv.rs index c99d6e0..5f6106f 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema_reseed_on_gap; pub fn obv( data: &[Option], @@ -6,7 +6,7 @@ pub fn obv( signal_period: usize, ) -> (Vec>, Vec>) { let obv_line = obv_line(data, volumes); - let obv_signal = ema_aligned(&obv_line, signal_period); + let obv_signal = ema_reseed_on_gap(&obv_line, signal_period); (obv_line, obv_signal) } @@ -17,7 +17,7 @@ pub fn obv_signal( signal_period: usize, ) -> Vec> { let obv_line = obv_line(data, volumes); - ema_aligned(&obv_line, signal_period) + ema_reseed_on_gap(&obv_line, signal_period) } pub fn obv_line(data: &[Option], volumes: &[Option]) -> Vec> { @@ -121,4 +121,42 @@ mod tests { vec![Some(100.0), Some(150.0), None, None, Some(130.0)] ); } + + #[test] + fn test_obv_signal_reseeds_after_gap_in_line() { + let closes = vec![ + Some(10.0), + Some(11.0), + None, + Some(13.0), + Some(12.0), + Some(14.0), + ]; + let volumes = vec![ + Some(100.0), + Some(50.0), + Some(40.0), + Some(30.0), + Some(20.0), + Some(10.0), + ]; + + let (line, signal) = obv(&closes, &volumes, 2); + + assert_eq!( + line, + vec![ + Some(100.0), + Some(150.0), + None, + None, + Some(130.0), + Some(140.0) + ] + ); + assert_eq!( + signal, + vec![None, Some(125.0), None, None, None, Some(135.0)] + ); + } } diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index 92bb912..7812f40 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_aligned; +use crate::indicators::ema::ema_reseed_on_gap; pub fn pvi( closes: &[Option], @@ -6,7 +6,7 @@ pub fn pvi( signal_period: usize, ) -> (Vec>, Vec>) { let pvi_line = pvi_line(closes, volumes); - let signal = ema_aligned(&pvi_line, signal_period); + let signal = ema_reseed_on_gap(&pvi_line, signal_period); (pvi_line, signal) } @@ -17,7 +17,7 @@ pub fn pvi_signal( signal_period: usize, ) -> Vec> { let pvi_line = pvi_line(closes, volumes); - ema_aligned(&pvi_line, signal_period) + ema_reseed_on_gap(&pvi_line, signal_period) } pub fn pvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { @@ -124,4 +124,42 @@ mod tests { vec![Some(1000.0), Some(1020.0), None, None, Some(1040.0)] ); } + + #[test] + fn test_pvi_signal_reseeds_after_gap_in_line() { + let closes = vec![ + Some(10.0), + Some(12.0), + None, + Some(15.0), + Some(18.0), + Some(27.0), + ]; + let volumes = vec![ + Some(100.0), + Some(120.0), + Some(90.0), + Some(150.0), + Some(180.0), + Some(200.0), + ]; + + let (line, signal) = pvi(&closes, &volumes, 2); + + assert_eq!( + line, + vec![ + Some(1000.0), + Some(1020.0), + None, + None, + Some(1040.0), + Some(1090.0), + ] + ); + assert_eq!( + signal, + vec![None, Some(1010.0), None, None, None, Some(1065.0)] + ); + } } From f993e80c371dc3ace638c2ea6bc706dffc37a53c Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 14:40:58 +0900 Subject: [PATCH 5/8] Restore EMA signal contract and fail closed on DMI mismatch --- core/src/indicators/adx.rs | 11 +++++++++++ core/src/indicators/adxr.rs | 11 +++++++++++ core/src/indicators/dmi.rs | 16 ++++++++++++++++ core/src/indicators/ema.rs | 1 - core/src/indicators/nvi.rs | 16 ++++++++++------ core/src/indicators/obv.rs | 23 +++++++++++++++++------ core/src/indicators/pvi.rs | 16 ++++++++++------ 7 files changed, 75 insertions(+), 19 deletions(-) diff --git a/core/src/indicators/adx.rs b/core/src/indicators/adx.rs index 815ee68..ee9ff6c 100644 --- a/core/src/indicators/adx.rs +++ b/core/src/indicators/adx.rs @@ -140,4 +140,15 @@ mod tests { ] ); } + + #[test] + fn test_adx_length_mismatch_fails_closed() { + let highs = vec![Some(10.0), Some(12.0), Some(14.0)]; + let lows = vec![Some(8.0), Some(9.0)]; + let closes = vec![Some(9.0), Some(11.0), Some(13.0)]; + + let result = adx(&highs, &lows, &closes, 2, 2); + + assert_eq!(result, vec![None, None, None]); + } } diff --git a/core/src/indicators/adxr.rs b/core/src/indicators/adxr.rs index 897453a..4c3de47 100644 --- a/core/src/indicators/adxr.rs +++ b/core/src/indicators/adxr.rs @@ -120,4 +120,15 @@ mod tests { ] ); } + + #[test] + fn test_adxr_length_mismatch_fails_closed() { + let highs = vec![Some(10.0), Some(12.0), Some(14.0)]; + let lows = vec![Some(8.0), Some(9.0)]; + let closes = vec![Some(9.0), Some(11.0), Some(13.0)]; + + let result = adxr(&highs, &lows, &closes, 2, 2, 2); + + assert_eq!(result, vec![None, None, None]); + } } diff --git a/core/src/indicators/dmi.rs b/core/src/indicators/dmi.rs index d556d39..a6b57be 100644 --- a/core/src/indicators/dmi.rs +++ b/core/src/indicators/dmi.rs @@ -10,6 +10,10 @@ pub fn dmi( let mut plus_di = vec![None; len]; let mut minus_di = vec![None; len]; + if len == 0 || len != lows.len() || len != closes.len() || period == 0 { + return (plus_di, minus_di); + } + let trs = calc_true_ranges_aligned(highs, lows, closes); let mut plus_dm = vec![None; len]; let mut minus_dm = vec![None; len]; @@ -223,4 +227,16 @@ mod tests { ); assert_eq!(minus_di, vec![None, None, None, None, Some(0.0), Some(0.0)]); } + + #[test] + fn test_dmi_length_mismatch_fails_closed() { + let highs = vec![Some(10.0), Some(12.0), Some(14.0)]; + let lows = vec![Some(8.0), Some(9.0)]; + let closes = vec![Some(9.0), Some(11.0), Some(13.0)]; + + let (plus_di, minus_di) = dmi(&highs, &lows, &closes, 2); + + assert_eq!(plus_di, vec![None, None, None]); + assert_eq!(minus_di, vec![None, None, None]); + } } diff --git a/core/src/indicators/ema.rs b/core/src/indicators/ema.rs index 52b2cc0..5fdb38d 100644 --- a/core/src/indicators/ema.rs +++ b/core/src/indicators/ema.rs @@ -118,5 +118,4 @@ mod tests { assert_eq!(result, expected); } - } diff --git a/core/src/indicators/nvi.rs b/core/src/indicators/nvi.rs index 53e080b..3ccecf9 100644 --- a/core/src/indicators/nvi.rs +++ b/core/src/indicators/nvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_reseed_on_gap; +use crate::indicators::ema::ema_aligned; pub fn nvi( closes: &[Option], @@ -6,7 +6,7 @@ pub fn nvi( signal_period: usize, ) -> (Vec>, Vec>) { let nvi_line = nvi_line(closes, volumes); - let signal = ema_reseed_on_gap(&nvi_line, signal_period); + let signal = ema_aligned(&nvi_line, signal_period); (nvi_line, signal) } @@ -17,7 +17,7 @@ pub fn nvi_signal( signal_period: usize, ) -> Vec> { let nvi_line = nvi_line(closes, volumes); - ema_reseed_on_gap(&nvi_line, signal_period) + ema_aligned(&nvi_line, signal_period) } pub fn nvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { @@ -120,7 +120,7 @@ mod tests { } #[test] - fn test_nvi_signal_reseeds_after_gap_in_line() { + fn test_nvi_signal_follows_base_ema_contract_across_gaps() { let closes = vec![ Some(10.0), Some(12.0), @@ -151,9 +151,13 @@ mod tests { Some(1090.0), ] ); + assert_eq!(signal, ema_aligned(&line, 2)); assert_eq!( - signal, - vec![None, Some(1010.0), None, None, None, Some(1065.0)] + round_vec(signal, 8), + round_vec( + vec![None, Some(1010.0), None, None, Some(1030.0), Some(1070.0),], + 8, + ) ); } } diff --git a/core/src/indicators/obv.rs b/core/src/indicators/obv.rs index 5f6106f..7b4324d 100644 --- a/core/src/indicators/obv.rs +++ b/core/src/indicators/obv.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_reseed_on_gap; +use crate::indicators::ema::ema_aligned; pub fn obv( data: &[Option], @@ -6,7 +6,7 @@ pub fn obv( signal_period: usize, ) -> (Vec>, Vec>) { let obv_line = obv_line(data, volumes); - let obv_signal = ema_reseed_on_gap(&obv_line, signal_period); + let obv_signal = ema_aligned(&obv_line, signal_period); (obv_line, obv_signal) } @@ -17,7 +17,7 @@ pub fn obv_signal( signal_period: usize, ) -> Vec> { let obv_line = obv_line(data, volumes); - ema_reseed_on_gap(&obv_line, signal_period) + ema_aligned(&obv_line, signal_period) } pub fn obv_line(data: &[Option], volumes: &[Option]) -> Vec> { @@ -123,7 +123,7 @@ mod tests { } #[test] - fn test_obv_signal_reseeds_after_gap_in_line() { + fn test_obv_signal_follows_base_ema_contract_across_gaps() { let closes = vec![ Some(10.0), Some(11.0), @@ -154,9 +154,20 @@ mod tests { Some(140.0) ] ); + assert_eq!(signal, ema_aligned(&line, 2)); assert_eq!( - signal, - vec![None, Some(125.0), None, None, None, Some(135.0)] + round_vec(signal, 8), + round_vec( + vec![ + None, + Some(125.0), + None, + None, + Some(128.33333333333334), + Some(136.11111111111111), + ], + 8, + ) ); } } diff --git a/core/src/indicators/pvi.rs b/core/src/indicators/pvi.rs index 7812f40..b9506d3 100644 --- a/core/src/indicators/pvi.rs +++ b/core/src/indicators/pvi.rs @@ -1,4 +1,4 @@ -use crate::indicators::ema::ema_reseed_on_gap; +use crate::indicators::ema::ema_aligned; pub fn pvi( closes: &[Option], @@ -6,7 +6,7 @@ pub fn pvi( signal_period: usize, ) -> (Vec>, Vec>) { let pvi_line = pvi_line(closes, volumes); - let signal = ema_reseed_on_gap(&pvi_line, signal_period); + let signal = ema_aligned(&pvi_line, signal_period); (pvi_line, signal) } @@ -17,7 +17,7 @@ pub fn pvi_signal( signal_period: usize, ) -> Vec> { let pvi_line = pvi_line(closes, volumes); - ema_reseed_on_gap(&pvi_line, signal_period) + ema_aligned(&pvi_line, signal_period) } pub fn pvi_line(closes: &[Option], volumes: &[Option]) -> Vec> { @@ -126,7 +126,7 @@ mod tests { } #[test] - fn test_pvi_signal_reseeds_after_gap_in_line() { + fn test_pvi_signal_follows_base_ema_contract_across_gaps() { let closes = vec![ Some(10.0), Some(12.0), @@ -157,9 +157,13 @@ mod tests { Some(1090.0), ] ); + assert_eq!(signal, ema_aligned(&line, 2)); assert_eq!( - signal, - vec![None, Some(1010.0), None, None, None, Some(1065.0)] + round_vec(signal, 8), + round_vec( + vec![None, Some(1010.0), None, None, Some(1030.0), Some(1070.0),], + 8, + ) ); } } From 38f11e42ba6d3f5b18092b9ed1c1126d14338733 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Thu, 23 Apr 2026 17:20:09 +0900 Subject: [PATCH 6/8] Fail closed on undefined ULTOSC and EOM points --- core/src/indicators/eom.rs | 37 +++++++++++++++++++++++++++++------ core/src/indicators/ultosc.rs | 20 ++++++++++++++++++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/core/src/indicators/eom.rs b/core/src/indicators/eom.rs index 956592d..b519f6c 100644 --- a/core/src/indicators/eom.rs +++ b/core/src/indicators/eom.rs @@ -51,13 +51,16 @@ pub fn eom_line( let distance_moved = high_low_avg - prev_high_low_avg; let high_low_diff = high - low; - let box_ratio = if high_low_diff != 0.0 && volume != 0.0 { - (volume / scale) / high_low_diff - } else { - 0.0 - }; + if high_low_diff == 0.0 || volume == 0.0 { + continue; + } - eom_values[i] = Some(distance_moved / box_ratio); + let box_ratio = (volume / scale) / high_low_diff; + let eom_point = distance_moved / box_ratio; + + if eom_point.is_finite() { + eom_values[i] = Some(eom_point); + } } sma_aligned(&eom_values, period) @@ -148,4 +151,26 @@ mod tests { assert_eq!(result, vec![None, None, Some(4.0), None, None, None]); } + + #[test] + fn test_eom_flat_candles_fail_closed_instead_of_emitting_nan() { + let highs = vec![Some(10.0); 4]; + let lows = vec![Some(10.0); 4]; + let volumes = vec![Some(1000.0); 4]; + + let result = eom_line(&highs, &lows, &volumes, 2, 100.0); + + assert_eq!(result, vec![None, None, None, None]); + } + + #[test] + fn test_eom_zero_volume_fails_closed_instead_of_emitting_inf() { + let highs = vec![Some(10.0), Some(11.0), Some(12.0), Some(13.0)]; + let lows = vec![Some(9.0), Some(10.0), Some(11.0), Some(12.0)]; + let volumes = vec![Some(1000.0), Some(0.0), Some(1000.0), Some(1000.0)]; + + let result = eom_line(&highs, &lows, &volumes, 2, 100.0); + + assert_eq!(result, vec![None, None, None, Some(0.1)]); + } } diff --git a/core/src/indicators/ultosc.rs b/core/src/indicators/ultosc.rs index e67a633..4a306d5 100644 --- a/core/src/indicators/ultosc.rs +++ b/core/src/indicators/ultosc.rs @@ -40,11 +40,18 @@ pub fn ultosc( medium_tr[i], long_tr[i], ) { + if short_tr == 0.0 || medium_tr == 0.0 || long_tr == 0.0 { + continue; + } + let uo_point = ((long_bp / long_tr + 2.0 * (medium_bp / medium_tr) + 4.0 * (short_bp / short_tr)) * 100.0) / 7.0; - ultosc[i] = Some(uo_point); + + if uo_point.is_finite() { + ultosc[i] = Some(uo_point); + } } } @@ -156,4 +163,15 @@ mod tests { assert_eq!(result, vec![None, None, None, None, None, None, None]); } + + #[test] + fn test_ultosc_flat_windows_fail_closed_instead_of_emitting_nan() { + let highs = vec![Some(10.0); 6]; + let lows = vec![Some(10.0); 6]; + let closes = vec![Some(10.0); 6]; + + let result = ultosc(&highs, &lows, &closes, 2, 3, 4); + + assert_eq!(result, vec![None, None, None, None, None, None]); + } } From 91a4ccff51907fb5bf791063486a3e5d931f06ae Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Fri, 24 Apr 2026 10:41:39 +0900 Subject: [PATCH 7/8] Stream stateful rolling indicators --- core/src/indicators/cmf.rs | 46 ++++++----- core/src/indicators/co.rs | 24 ++++-- core/src/indicators/psar.rs | 28 ++++--- core/src/indicators/ultosc.rs | 141 ++++++++++++++++++---------------- core/src/indicators/vr.rs | 53 +++++++------ 5 files changed, 164 insertions(+), 128 deletions(-) diff --git a/core/src/indicators/cmf.rs b/core/src/indicators/cmf.rs index 615be4e..64b5333 100644 --- a/core/src/indicators/cmf.rs +++ b/core/src/indicators/cmf.rs @@ -1,4 +1,4 @@ -use crate::utils::{calc_clv, rolling_sum_strict}; +use crate::utils::calc_clv; pub fn cmf( highs: &[Option], @@ -19,27 +19,33 @@ pub fn cmf( return cmf; } - let money_flow_volume = highs - .iter() - .zip(lows.iter()) - .zip(closes.iter()) - .zip(volumes.iter()) - .map( - |(((high, low), close), volume)| match (high, low, close, volume) { - (Some(high), Some(low), Some(close), Some(volume)) => { - Some(calc_clv(*high, *low, *close) * *volume) - } - _ => None, - }, - ) - .collect::>(); - let volume_sums = rolling_sum_strict(volumes, period); - let money_flow_sums = rolling_sum_strict(&money_flow_volume, period); + let mut money_flow_values = vec![0.0; len]; + let mut volume_values = vec![0.0; len]; + let mut valid = vec![false; len]; + let mut sum_money_flow = 0.0; + let mut sum_volume = 0.0; + let mut valid_count = 0usize; for i in 0..len { - if let (Some(sum_money_flow_volume), Some(sum_volume)) = - (money_flow_sums[i], volume_sums[i]) + if let (Some(high), Some(low), Some(close), Some(volume)) = + (highs[i], lows[i], closes[i], volumes[i]) { + let money_flow = calc_clv(high, low, close) * volume; + money_flow_values[i] = money_flow; + volume_values[i] = volume; + valid[i] = true; + sum_money_flow += money_flow; + sum_volume += volume; + valid_count += 1; + } + + if i >= period { + sum_money_flow -= money_flow_values[i - period]; + sum_volume -= volume_values[i - period]; + valid_count -= valid[i - period] as usize; + } + + if valid_count == period { cmf[i] = if sum_volume == 0.0 { if i == period - 1 { None @@ -47,7 +53,7 @@ pub fn cmf( Some(0.0) } } else { - Some(sum_money_flow_volume / sum_volume) + Some(sum_money_flow / sum_volume) }; } } diff --git a/core/src/indicators/co.rs b/core/src/indicators/co.rs index 1d09bf6..01b884f 100644 --- a/core/src/indicators/co.rs +++ b/core/src/indicators/co.rs @@ -1,4 +1,4 @@ -use crate::indicators::ad; +use crate::utils::calc_clv; pub fn co( highs: &[Option], @@ -25,22 +25,30 @@ pub fn co( } if period_long == period_short { - return ad(highs, lows, closes, volumes) - .into_iter() - .map(|value| value.map(|_| 0.0)) - .collect(); + for i in 0..len { + if highs[i].is_some() + && lows[i].is_some() + && closes[i].is_some() + && volumes[i].is_some() + { + co[i] = Some(0.0); + } + } + return co; } - let ad_values = ad(highs, lows, closes, volumes); let short_k = 2.0 / (period_short as f64 + 1.0); let long_k = 2.0 / (period_long as f64 + 1.0); let mut short_ema = None; let mut long_ema = None; let mut seeded_rows = 0usize; let mut output_seeded = false; + let mut ad_point = 0.0; for i in 0..len { - let Some(ad) = ad_values[i] else { + let (Some(high), Some(low), Some(close), Some(volume)) = + (highs[i], lows[i], closes[i], volumes[i]) + else { if !output_seeded { short_ema = None; long_ema = None; @@ -49,6 +57,8 @@ pub fn co( continue; }; + ad_point += calc_clv(high, low, close) * volume; + let ad = ad_point; let next_short = short_ema.map_or(ad, |prev| ad * short_k + prev * (1.0 - short_k)); let next_long = long_ema.map_or(ad, |prev| ad * long_k + prev * (1.0 - long_k)); short_ema = Some(next_short); diff --git a/core/src/indicators/psar.rs b/core/src/indicators/psar.rs index 97e0f81..6161bbe 100644 --- a/core/src/indicators/psar.rs +++ b/core/src/indicators/psar.rs @@ -17,8 +17,9 @@ pub fn psar( let mut extreme_point = None; let mut current_psar = None; let mut acceleration_factor = initial_acceleration_factor; - let mut last_valid_highs = Vec::with_capacity(2); - let mut last_valid_lows = Vec::with_capacity(2); + let mut last_valid_highs = [0.0; 2]; + let mut last_valid_lows = [0.0; 2]; + let mut last_valid_count = 0usize; let mut emitted_points = 0usize; for i in 1..len { @@ -50,8 +51,9 @@ pub fn psar( } else { prev_high }); - last_valid_highs = vec![prev_high, high]; - last_valid_lows = vec![prev_low, low]; + last_valid_highs = [prev_high, high]; + last_valid_lows = [prev_low, low]; + last_valid_count = 2; continue; } @@ -68,7 +70,7 @@ pub fn psar( psar_point += acceleration_factor * (current_extreme - psar_point); - if emitted_points > 0 && last_valid_highs.len() >= 2 && last_valid_lows.len() >= 2 { + if emitted_points > 0 && last_valid_count == 2 { psar_point = if current_direction == 1 { psar_point.min(last_valid_lows[0].min(last_valid_lows[1])) } else { @@ -100,14 +102,16 @@ pub fn psar( psar[i] = Some(psar_point); emitted_points += 1; - if last_valid_highs.len() == 2 { - last_valid_highs.remove(0); - } - if last_valid_lows.len() == 2 { - last_valid_lows.remove(0); + if last_valid_count < 2 { + last_valid_highs[last_valid_count] = high; + last_valid_lows[last_valid_count] = low; + last_valid_count += 1; + } else { + last_valid_highs[0] = last_valid_highs[1]; + last_valid_highs[1] = high; + last_valid_lows[0] = last_valid_lows[1]; + last_valid_lows[1] = low; } - last_valid_highs.push(high); - last_valid_lows.push(low); } psar diff --git a/core/src/indicators/ultosc.rs b/core/src/indicators/ultosc.rs index 4a306d5..56566e3 100644 --- a/core/src/indicators/ultosc.rs +++ b/core/src/indicators/ultosc.rs @@ -1,5 +1,3 @@ -use crate::utils::rolling_sum_strict; - pub fn ultosc( highs: &[Option], lows: &[Option], @@ -15,39 +13,84 @@ pub fn ultosc( return ultosc; } - let buying_pressures = calc_buying_pressures(closes, lows); - let true_ranges = calc_true_ranges(closes, highs, lows); - let short_bp = rolling_sum_strict(&buying_pressures, period_short); - let medium_bp = rolling_sum_strict(&buying_pressures, period_medium); - let long_bp = rolling_sum_strict(&buying_pressures, period_long); - let short_tr = rolling_sum_strict(&true_ranges, period_short); - let medium_tr = rolling_sum_strict(&true_ranges, period_medium); - let long_tr = rolling_sum_strict(&true_ranges, period_long); - - for i in 0..len { - if let ( - Some(short_bp), - Some(medium_bp), - Some(long_bp), - Some(short_tr), - Some(medium_tr), - Some(long_tr), - ) = ( - short_bp[i], - medium_bp[i], - long_bp[i], - short_tr[i], - medium_tr[i], - long_tr[i], - ) { - if short_tr == 0.0 || medium_tr == 0.0 || long_tr == 0.0 { + let mut bp_values = vec![0.0; len]; + let mut tr_values = vec![0.0; len]; + let mut bp_valid = vec![false; len]; + let mut tr_valid = vec![false; len]; + + let mut short_bp_sum = 0.0; + let mut medium_bp_sum = 0.0; + let mut long_bp_sum = 0.0; + let mut short_tr_sum = 0.0; + let mut medium_tr_sum = 0.0; + let mut long_tr_sum = 0.0; + let mut short_bp_valid = 0usize; + let mut medium_bp_valid = 0usize; + let mut long_bp_valid = 0usize; + let mut short_tr_valid = 0usize; + let mut medium_tr_valid = 0usize; + let mut long_tr_valid = 0usize; + + for i in 1..len { + if let (Some(close), Some(low), Some(prev_close)) = (closes[i], lows[i], closes[i - 1]) { + let bp = close - low.min(prev_close); + bp_values[i] = bp; + bp_valid[i] = true; + short_bp_sum += bp; + medium_bp_sum += bp; + long_bp_sum += bp; + short_bp_valid += 1; + medium_bp_valid += 1; + long_bp_valid += 1; + } + + if let (Some(prev_close), Some(high), Some(low)) = (closes[i - 1], highs[i], lows[i]) { + let tr = high.max(prev_close) - low.min(prev_close); + tr_values[i] = tr; + tr_valid[i] = true; + short_tr_sum += tr; + medium_tr_sum += tr; + long_tr_sum += tr; + short_tr_valid += 1; + medium_tr_valid += 1; + long_tr_valid += 1; + } + + if i >= period_short { + short_bp_sum -= bp_values[i - period_short]; + short_tr_sum -= tr_values[i - period_short]; + short_bp_valid -= bp_valid[i - period_short] as usize; + short_tr_valid -= tr_valid[i - period_short] as usize; + } + if i >= period_medium { + medium_bp_sum -= bp_values[i - period_medium]; + medium_tr_sum -= tr_values[i - period_medium]; + medium_bp_valid -= bp_valid[i - period_medium] as usize; + medium_tr_valid -= tr_valid[i - period_medium] as usize; + } + if i >= period_long { + long_bp_sum -= bp_values[i - period_long]; + long_tr_sum -= tr_values[i - period_long]; + long_bp_valid -= bp_valid[i - period_long] as usize; + long_tr_valid -= tr_valid[i - period_long] as usize; + } + + if short_bp_valid == period_short + && medium_bp_valid == period_medium + && long_bp_valid == period_long + && short_tr_valid == period_short + && medium_tr_valid == period_medium + && long_tr_valid == period_long + { + if short_tr_sum == 0.0 || medium_tr_sum == 0.0 || long_tr_sum == 0.0 { continue; } - let uo_point = - ((long_bp / long_tr + 2.0 * (medium_bp / medium_tr) + 4.0 * (short_bp / short_tr)) - * 100.0) - / 7.0; + let uo_point = ((long_bp_sum / long_tr_sum + + 2.0 * (medium_bp_sum / medium_tr_sum) + + 4.0 * (short_bp_sum / short_tr_sum)) + * 100.0) + / 7.0; if uo_point.is_finite() { ultosc[i] = Some(uo_point); @@ -58,40 +101,6 @@ pub fn ultosc( ultosc } -fn calc_buying_pressures(closes: &[Option], lows: &[Option]) -> Vec> { - let len = closes.len(); - let mut buying_pressures = vec![None; len]; - - for i in 1..len { - let (Some(close), Some(low), Some(prev_close)) = (closes[i], lows[i], closes[i - 1]) else { - continue; - }; - - buying_pressures[i] = Some(close - low.min(prev_close)); - } - - buying_pressures -} - -fn calc_true_ranges( - closes: &[Option], - highs: &[Option], - lows: &[Option], -) -> Vec> { - let len = closes.len(); - let mut true_ranges = vec![None; len]; - - for i in 1..len { - let (Some(prev_close), Some(high), Some(low)) = (closes[i - 1], highs[i], lows[i]) else { - continue; - }; - - true_ranges[i] = Some(high.max(prev_close) - low.min(prev_close)); - } - - true_ranges -} - #[cfg(test)] mod tests { use super::*; diff --git a/core/src/indicators/vr.rs b/core/src/indicators/vr.rs index 4831af2..a0483ec 100644 --- a/core/src/indicators/vr.rs +++ b/core/src/indicators/vr.rs @@ -1,5 +1,3 @@ -use crate::utils::rolling_sum_strict; - /// Volume Ratio (VR) pub fn vr(closes: &[Option], volumes: &[Option], period: usize) -> Vec> { let len = closes.len(); @@ -9,40 +7,49 @@ pub fn vr(closes: &[Option], volumes: &[Option], period: usize) -> Vec return result; } - let mut up = vec![None; len]; - let mut down = vec![None; len]; - let mut same = vec![None; len]; + let mut up_values = vec![0.0; len]; + let mut down_values = vec![0.0; len]; + let mut same_values = vec![0.0; len]; + let mut valid = vec![false; len]; + let mut up_sum = 0.0; + let mut down_sum = 0.0; + let mut same_sum = 0.0; + let mut valid_count = 0usize; for i in 1..len { let (Some(close), Some(prev_close), Some(volume)) = (closes[i], closes[i - 1], volumes[i]) else { + if i >= period { + up_sum -= up_values[i - period]; + down_sum -= down_values[i - period]; + same_sum -= same_values[i - period]; + valid_count -= valid[i - period] as usize; + } continue; }; if close > prev_close { - up[i] = Some(volume); - down[i] = Some(0.0); - same[i] = Some(0.0); + up_values[i] = volume; } else if close < prev_close { - up[i] = Some(0.0); - down[i] = Some(volume); - same[i] = Some(0.0); + down_values[i] = volume; } else { - up[i] = Some(0.0); - down[i] = Some(0.0); - same[i] = Some(volume); + same_values[i] = volume; } - } + valid[i] = true; + up_sum += up_values[i]; + down_sum += down_values[i]; + same_sum += same_values[i]; + valid_count += 1; - let up_sum = rolling_sum_strict(&up, period); - let down_sum = rolling_sum_strict(&down, period); - let same_sum = rolling_sum_strict(&same, period); + if i >= period { + up_sum -= up_values[i - period]; + down_sum -= down_values[i - period]; + same_sum -= same_values[i - period]; + valid_count -= valid[i - period] as usize; + } - for i in 0..len { - if let (Some(up_volume), Some(down_volume), Some(same_volume)) = - (up_sum[i], down_sum[i], same_sum[i]) - { - result[i] = Some(calculate_vr(up_volume, down_volume, same_volume)); + if valid_count == period { + result[i] = Some(calculate_vr(up_sum, down_sum, same_sum)); } } From aa4fc2bb40731c46e99c20bd3f5223fb3cf3d2e0 Mon Sep 17 00:00:00 2001 From: Minki Kim Date: Fri, 24 Apr 2026 11:47:39 +0900 Subject: [PATCH 8/8] Remove unused sma_dense --- core/src/indicators/sma.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/core/src/indicators/sma.rs b/core/src/indicators/sma.rs index c1bb181..519ae1e 100644 --- a/core/src/indicators/sma.rs +++ b/core/src/indicators/sma.rs @@ -1,10 +1,5 @@ use crate::utils::rolling_mean_strict; -pub(crate) fn sma_dense(data: &[f64], period: usize) -> Vec> { - let nullable = data.iter().copied().map(Some).collect::>(); - rolling_mean_strict(&nullable, period) -} - /// Computes a simple moving average over an aligned nullable series. /// /// The returned vector keeps the same length as the input and emits `None`