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..ee9ff6c 100644 --- a/core/src/indicators/adx.rs +++ b/core/src/indicators/adx.rs @@ -1,36 +1,49 @@ 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) + } + (Some(_), Some(_)) => Some(0.0), + _ => None, + }) else { + if adx_point.is_none() { + dx_sum = 0.0; + seeded = 0; } - _ => 0.0, + 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 +64,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", @@ -65,4 +82,73 @@ 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) + ] + ); + } + + #[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 fc88df1..4c3de47 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!( @@ -53,4 +62,73 @@ 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) + ] + ); + } + + #[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/atr.rs b/core/src/indicators/atr.rs index bdfe912..d8b3d5b 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,31 @@ 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 +86,71 @@ 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)] + ); + } + + #[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/cmf.rs b/core/src/indicators/cmf.rs index f671a00..64b5333 100644 --- a/core/src/indicators/cmf.rs +++ b/core/src/indicators/cmf.rs @@ -1,10 +1,10 @@ use crate::utils::calc_clv; 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,43 @@ pub fn cmf( return cmf; } - let mut sum_money_flow_volume = 0.0; + 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..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]; + for i in 0..len { + 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; + } - 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]; + if i >= period { + sum_money_flow -= money_flow_values[i - period]; + sum_volume -= volume_values[i - period]; + valid_count -= valid[i - period] as usize; + } - let cmf_point = if sum_volume == 0.0 { - Some(0.0) - } else { - Some(sum_money_flow_volume / sum_volume) - }; - cmf[i] = cmf_point; + if valid_count == period { + cmf[i] = if sum_volume == 0.0 { + if i == period - 1 { + None + } else { + Some(0.0) + } + } else { + Some(sum_money_flow / sum_volume) + }; + } } cmf @@ -66,10 +71,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 +101,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..01b884f 100644 --- a/core/src/indicators/co.rs +++ b/core/src/indicators/co.rs @@ -1,41 +1,78 @@ -use crate::indicators::ad; +use crate::utils::calc_clv; 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> { let len = highs.len(); let mut co = vec![None; len]; + if len != lows.len() + || len != closes.len() + || len != volumes.len() + || period_short == 0 + || period_long == 0 + { + return co; + } + if len < period_long { return co; - } else if period_long == period_short { - return vec![Some(0.0); len]; } - let ad_values = ad(highs, lows, closes, volumes); + if period_long == period_short { + 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 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; - if ad_values[0].is_none() { - return co; - } + for i in 0..len { + 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; + seeded_rows = 0; + } + continue; + }; - let mut short_ema = ad_values[0].unwrap(); - let mut long_ema = short_ema; + 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); + long_ema = Some(next_long); - for i in 1..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); + if output_seeded { + co[i] = Some(next_short - next_long); + continue; + } - if i >= period_long - 1 { - co[i] = Some(short_ema - long_ema); - } + seeded_rows += 1; + if seeded_rows == period_long { + output_seeded = true; + co[i] = Some(next_short - next_long); } } @@ -52,10 +89,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 +121,70 @@ mod tests { ); } } + + #[test] + 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, 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)] + ); + } } diff --git a/core/src/indicators/dmi.rs b/core/src/indicators/dmi.rs index 55bb036..a6b57be 100644 --- a/core/src/indicators/dmi.rs +++ b/core/src/indicators/dmi.rs @@ -1,41 +1,60 @@ -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); + 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]; + + 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 { + 0.0 + }); + minus_dm[i] = Some(if delta_low > delta_high && delta_low > 0.0 { + delta_low } 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 + }); + } + + 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 +71,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 +108,135 @@ 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_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![ + 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)]); + } + + #[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/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..b519f6c 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,32 @@ 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 - } else { - 0.0 - }; + let high_low_diff = high - low; + if high_low_diff == 0.0 || volume == 0.0 { + continue; + } - eom_values.push(distance_moved / box_ratio); - } + let box_ratio = (volume / scale) / high_low_diff; + let eom_point = 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; + if eom_point.is_finite() { + eom_values[i] = Some(eom_point); + } } - eom_line + sma_aligned(&eom_values, period) } #[cfg(test)] @@ -77,9 +80,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 +119,58 @@ 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]); + } + + #[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/nvi.rs b/core/src/indicators/nvi.rs index d9f402a..3ccecf9 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,59 @@ 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)] + ); + } + + #[test] + fn test_nvi_signal_follows_base_ema_contract_across_gaps() { + 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, ema_aligned(&line, 2)); + assert_eq!( + 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 43beaa8..7b4324d 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,66 @@ 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)] + ); + } + + #[test] + fn test_obv_signal_follows_base_ema_contract_across_gaps() { + 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, ema_aligned(&line, 2)); + assert_eq!( + 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/psar.rs b/core/src/indicators/psar.rs index 56a3e73..6161bbe 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,105 @@ 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 = [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 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 = [prev_high, high]; + last_valid_lows = [prev_low, low]; + last_valid_count = 2; + 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_count == 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_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; + } } psar @@ -63,9 +127,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 +153,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..b9506d3 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,65 @@ 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)] + ); + } + + #[test] + fn test_pvi_signal_follows_base_ema_contract_across_gaps() { + 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, ema_aligned(&line, 2)); + assert_eq!( + 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/rsi.rs b/core/src/indicators/rsi.rs index dfd7335..84a5ab6 100644 --- a/core/src/indicators/rsi.rs +++ b/core/src/indicators/rsi.rs @@ -1,43 +1,64 @@ -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 { + if avg_up.is_none() { + total_up = 0.0; + total_down = 0.0; + seeded_changes = 0; + } + 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 +74,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 +92,52 @@ 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), + ] + ); + } + + #[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/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` 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..56566e3 100644 --- a/core/src/indicators/ultosc.rs +++ b/core/src/indicators/ultosc.rs @@ -1,9 +1,7 @@ -use crate::utils::calc_true_ranges; - 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,67 +13,94 @@ 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; + 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 { - 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; + 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 i >= period_long - period_short + 1 { - short_denomi += bp; - short_nomi += tr; + 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 { - let uo_point = ((long_denomi / long_nomi - + 2.0 * (medium_denomi / medium_nomi) - + 4.0 * (short_denomi / short_nomi)) + 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_sum / long_tr_sum + + 2.0 * (medium_bp_sum / medium_tr_sum) + + 4.0 * (short_bp_sum / short_tr_sum)) * 100.0) / 7.0; - ultosc[i] = Some(uo_point); - - // 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]; + if uo_point.is_finite() { + ultosc[i] = Some(uo_point); + } } } ultosc } -fn calc_buying_pressures(closes: &[f64], lows: &[f64]) -> Vec { - let len = closes.len(); - let mut buying_pressures = Vec::with_capacity(len - 1); - - for i in 1..len { - let bp = closes[i] - lows[i].min(closes[i - 1]); - buying_pressures.push(bp); - } - - buying_pressures -} - #[cfg(test)] mod tests { use super::*; @@ -86,9 +111,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 +137,50 @@ 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]); + } + + #[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]); + } } diff --git a/core/src/indicators/vr.rs b/core/src/indicators/vr.rs index 54ee250..a0483ec 100644 --- a/core/src/indicators/vr.rs +++ b/core/src/indicators/vr.rs @@ -1,5 +1,5 @@ /// 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 +7,55 @@ 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_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; - // 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 { + 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; + }; - // 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_values[i] = volume; + } else if close < prev_close { + down_values[i] = volume; + } else { + 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; - result[i] = Some(calculate_vr(up_volume, down_volume, same_volume)); + 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; + } - // 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, - ); + if valid_count == period { + result[i] = Some(calculate_vr(up_sum, down_sum, same_sum)); + } } 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 +76,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 +98,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..8f657bd 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,51 @@ 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 { + if smoothed.is_none() { + seed_count = 0; + seed_sum = 0.0; + } + 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 +585,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 +659,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 +700,22 @@ 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)]); + } + + #[test] + fn test_calc_wilders_smoothing_aligned_requires_contiguous_seed_window() { + let data = vec![Some(1.0), None, Some(2.0), Some(3.0), Some(4.0)]; + + let result = wilders_smoothing_aligned(&data, 2); + + assert_eq!(result, vec![None, None, None, Some(4.0), Some(6.0)]); + } }