1use crate::error::FinError;
20use crate::tick::Tick;
21use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
22use rust_decimal::Decimal;
23
24#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
26pub struct OhlcvBar {
27 pub symbol: Symbol,
29 pub open: Price,
31 pub high: Price,
33 pub low: Price,
35 pub close: Price,
37 pub volume: Quantity,
39 pub ts_open: NanoTimestamp,
41 pub ts_close: NanoTimestamp,
43 pub tick_count: u64,
45}
46
47#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
53pub struct PivotPoints {
54 pub pp: Decimal,
56 pub r1: Decimal,
58 pub s1: Decimal,
60 pub r2: Decimal,
62 pub s2: Decimal,
64}
65
66impl OhlcvBar {
67 #[allow(clippy::too_many_arguments)]
76 pub fn new(
77 symbol: Symbol,
78 open: Price,
79 high: Price,
80 low: Price,
81 close: Price,
82 volume: Quantity,
83 ts_open: NanoTimestamp,
84 ts_close: NanoTimestamp,
85 tick_count: u64,
86 ) -> Result<Self, FinError> {
87 let bar = Self {
88 symbol,
89 open,
90 high,
91 low,
92 close,
93 volume,
94 ts_open,
95 ts_close,
96 tick_count,
97 };
98 bar.validate()?;
99 Ok(bar)
100 }
101
102 pub fn validate(&self) -> Result<(), FinError> {
112 let h = self.high.value();
113 let l = self.low.value();
114 let o = self.open.value();
115 let c = self.close.value();
116 if h < o {
117 return Err(FinError::BarInvariant(format!("high {h} < open {o}")));
118 }
119 if h < c {
120 return Err(FinError::BarInvariant(format!("high {h} < close {c}")));
121 }
122 if l > o {
123 return Err(FinError::BarInvariant(format!("low {l} > open {o}")));
124 }
125 if l > c {
126 return Err(FinError::BarInvariant(format!("low {l} > close {c}")));
127 }
128 if h < l {
129 return Err(FinError::BarInvariant(format!("high {h} < low {l}")));
130 }
131 Ok(())
132 }
133
134 pub fn to_bar_input(&self) -> crate::signals::BarInput {
136 crate::signals::BarInput::from(self)
137 }
138
139 pub fn typical_price(&self) -> Decimal {
141 (self.high.value() + self.low.value() + self.close.value()) / Decimal::from(3u32)
142 }
143
144 pub fn range(&self) -> Decimal {
146 self.high.value() - self.low.value()
147 }
148
149 pub fn hlcc4(&self) -> Decimal {
154 (self.high.value() + self.low.value() + self.close.value() + self.close.value())
155 / Decimal::from(4u32)
156 }
157
158 pub fn weighted_close(&self) -> Decimal {
163 self.hlcc4()
164 }
165
166 pub fn ohlc4(&self) -> Decimal {
171 (self.open.value() + self.high.value() + self.low.value() + self.close.value())
172 / Decimal::from(4u32)
173 }
174
175 pub fn dollar_volume(&self) -> Decimal {
181 self.typical_price() * self.volume.value()
182 }
183
184 pub fn is_gap_fill(&self) -> bool {
189 self.tick_count == 0
190 }
191
192 pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
198 self.high.value() < prev.high.value() && self.low.value() > prev.low.value()
199 }
200
201 pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
206 self.high.value() > prev.high.value() && self.low.value() < prev.low.value()
207 }
208
209 pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
214 let s_o = self.open.value();
215 let s_c = self.close.value();
216 let p_o = prev.open.value();
217 let p_c = prev.close.value();
218 let bullish = p_c < p_o && s_c > s_o && s_c >= p_o && s_o <= p_c;
219 let bearish = p_c > p_o && s_c < s_o && s_c <= p_o && s_o >= p_c;
220 bullish || bearish
221 }
222
223 pub fn is_bullish(&self) -> bool {
225 self.close.value() >= self.open.value()
226 }
227
228 pub fn is_bearish(&self) -> bool {
230 self.close.value() < self.open.value()
231 }
232
233 pub fn is_hammer(&self) -> bool {
237 let body = self.body_size();
238 if body.is_zero() {
239 return false;
240 }
241 self.lower_shadow() >= body * Decimal::TWO && self.upper_shadow() <= body
242 }
243
244 pub fn is_marubozu(&self) -> bool {
249 let range = self.range();
250 if range.is_zero() {
251 return false;
252 }
253 let body = self.body_size();
254 if body.is_zero() {
255 return false;
256 }
257 let threshold = range / Decimal::from(20u32); self.upper_shadow() < threshold && self.lower_shadow() < threshold
259 }
260
261 pub fn is_spinning_top(&self) -> bool {
267 let range = self.range();
268 if range.is_zero() {
269 return false;
270 }
271 let body = self.body_size();
272 let body_ratio = body / range;
273 let upper_ratio = self.upper_shadow() / range;
274 let lower_ratio = self.lower_shadow() / range;
275 let threshold_30 = Decimal::from_str_exact("0.30").unwrap_or(Decimal::ZERO);
276 let threshold_20 = Decimal::from_str_exact("0.20").unwrap_or(Decimal::ZERO);
277 body_ratio < threshold_30 && upper_ratio >= threshold_20 && lower_ratio >= threshold_20
278 }
279
280 pub fn is_shooting_star(&self) -> bool {
284 let body = self.body_size();
285 if body.is_zero() {
286 return false;
287 }
288 self.upper_shadow() >= body * Decimal::TWO && self.lower_shadow() <= body
289 }
290
291 pub fn body_pct(&self) -> Option<Decimal> {
295 let o = self.open.value();
296 if o.is_zero() {
297 return None;
298 }
299 Some(self.body_size() / o * Decimal::ONE_HUNDRED)
300 }
301
302 pub fn bar_return(&self) -> Option<Decimal> {
306 let o = self.open.value();
307 if o.is_zero() {
308 return None;
309 }
310 Some((self.close.value() - o) / o * Decimal::ONE_HUNDRED)
311 }
312
313 pub fn midpoint(&self) -> Decimal {
315 (self.high.value() + self.low.value()) / Decimal::TWO
316 }
317
318 pub fn body_size(&self) -> Decimal {
320 (self.close.value() - self.open.value()).abs()
321 }
322
323 pub fn body_to_range_ratio(&self) -> Option<Decimal> {
328 let r = self.range();
329 if r.is_zero() {
330 return None;
331 }
332 Some(self.body_size() / r)
333 }
334
335 pub fn is_long_candle(&self, factor: Decimal) -> bool {
340 let r = self.range();
341 if r == Decimal::ZERO {
342 return false;
343 }
344 self.body_size() / r >= factor
345 }
346
347 pub fn is_doji(&self, threshold: Decimal) -> bool {
352 let r = self.range();
353 if r == Decimal::ZERO {
354 return threshold > Decimal::ZERO;
355 }
356 self.body_size() / r < threshold
357 }
358
359 pub fn body_ratio(&self) -> Option<Decimal> {
365 let r = self.range();
366 if r == Decimal::ZERO {
367 return None;
368 }
369 Some(self.body_size() / r)
370 }
371
372 pub fn true_range(&self, prev: Option<&OhlcvBar>) -> Decimal {
382 let hl = self.high.value() - self.low.value();
383 match prev {
384 None => hl,
385 Some(p) => {
386 let pc = p.close.value();
387 let hc = (self.high.value() - pc).abs();
388 let lc = (self.low.value() - pc).abs();
389 hl.max(hc).max(lc)
390 }
391 }
392 }
393
394 pub fn shadow_ratio(&self) -> Option<Decimal> {
399 let r = self.range();
400 if r.is_zero() {
401 return None;
402 }
403 Some((self.upper_shadow() + self.lower_shadow()) / r)
404 }
405
406 pub fn gap_up_from(&self, prev: &OhlcvBar) -> bool {
408 self.low.value() > prev.high.value()
409 }
410
411 pub fn gap_down_from(&self, prev: &OhlcvBar) -> bool {
413 self.high.value() < prev.low.value()
414 }
415
416 pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
420 self.open.value() - prev.close.value()
421 }
422
423 pub fn upper_shadow(&self) -> Decimal {
425 let body_top = self.open.value().max(self.close.value());
426 self.high.value() - body_top
427 }
428
429 pub fn lower_shadow(&self) -> Decimal {
431 let body_bottom = self.open.value().min(self.close.value());
432 body_bottom - self.low.value()
433 }
434
435 pub fn duration_nanos(&self) -> i64 {
439 self.ts_close.nanos() - self.ts_open.nanos()
440 }
441
442 pub fn gap_pct(&self, prev: &OhlcvBar) -> Option<Decimal> {
449 let prev_close = prev.close.value();
450 if prev_close.is_zero() {
451 return None;
452 }
453 Some((self.open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
454 }
455
456 pub fn has_gap(&self, prev: &OhlcvBar, pct_threshold: Decimal) -> bool {
461 self.gap_pct(prev)
462 .map(|g| g.abs() >= pct_threshold)
463 .unwrap_or(false)
464 }
465
466 pub fn from_tick(tick: &Tick) -> Self {
471 Self {
472 symbol: tick.symbol.clone(),
473 open: tick.price,
474 high: tick.price,
475 low: tick.price,
476 close: tick.price,
477 volume: tick.quantity,
478 ts_open: tick.timestamp,
479 ts_close: tick.timestamp,
480 tick_count: 1,
481 }
482 }
483
484 pub fn merge(&self, other: &OhlcvBar) -> Result<OhlcvBar, FinError> {
496 let high = self.high.value().max(other.high.value());
497 let low = self.low.value().min(other.low.value());
498 let volume_sum = self.volume.value() + other.volume.value();
499 let bar = OhlcvBar {
500 symbol: self.symbol.clone(),
501 open: self.open,
502 high: Price::new(high)?,
503 low: Price::new(low)?,
504 close: other.close,
505 volume: Quantity::new(volume_sum)?,
506 ts_open: self.ts_open,
507 ts_close: other.ts_close,
508 tick_count: self.tick_count + other.tick_count,
509 };
510 bar.validate()?;
511 Ok(bar)
512 }
513
514 pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
522 let prev_bearish = prev.open.value() > prev.close.value();
523 let self_bullish = self.close.value() > self.open.value();
524 prev_bearish
525 && self_bullish
526 && self.open.value() <= prev.close.value()
527 && self.close.value() >= prev.open.value()
528 }
529
530 pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
538 let prev_bullish = prev.close.value() > prev.open.value();
539 let self_bearish = self.open.value() > self.close.value();
540 prev_bullish
541 && self_bearish
542 && self.open.value() >= prev.close.value()
543 && self.close.value() <= prev.open.value()
544 }
545
546}
547
548#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
550pub enum Timeframe {
551 Seconds(u32),
553 Minutes(u32),
555 Hours(u32),
557 Days(u32),
559 Weeks(u32),
561}
562
563impl Timeframe {
564 pub fn to_nanos(&self) -> Result<i64, FinError> {
569 let secs: u64 = match self {
570 Timeframe::Seconds(n) => u64::from(*n),
571 Timeframe::Minutes(n) => u64::from(*n) * 60,
572 Timeframe::Hours(n) => u64::from(*n) * 3_600,
573 Timeframe::Days(n) => u64::from(*n) * 86_400,
574 Timeframe::Weeks(n) => u64::from(*n) * 7 * 86_400,
575 };
576 if secs == 0 {
577 return Err(FinError::InvalidTimeframe);
578 }
579 #[allow(clippy::cast_possible_wrap)]
580 Ok((secs * 1_000_000_000) as i64)
581 }
582
583 pub fn bucket_start(&self, ts: NanoTimestamp) -> Result<NanoTimestamp, FinError> {
588 let nanos = self.to_nanos()?;
589 let bucket = (ts.nanos() / nanos) * nanos;
590 Ok(NanoTimestamp::new(bucket))
591 }
592}
593
594pub struct OhlcvAggregator {
601 symbol: Symbol,
602 timeframe: Timeframe,
603 current_bar: Option<OhlcvBar>,
604 current_bucket_start: Option<NanoTimestamp>,
605 last_close: Option<Price>,
607 bars_emitted: usize,
609}
610
611impl OhlcvAggregator {
612 pub fn new(symbol: Symbol, timeframe: Timeframe) -> Result<Self, FinError> {
617 timeframe.to_nanos()?;
618 Ok(Self {
619 symbol,
620 timeframe,
621 current_bar: None,
622 current_bucket_start: None,
623 last_close: None,
624 bars_emitted: 0,
625 })
626 }
627
628 pub fn push_tick(&mut self, tick: &Tick) -> Result<Vec<OhlcvBar>, FinError> {
641 if tick.symbol != self.symbol {
642 return Ok(vec![]);
643 }
644 let bucket = self.timeframe.bucket_start(tick.timestamp)?;
645 match self.current_bucket_start {
646 None => {
647 self.current_bucket_start = Some(bucket);
648 self.current_bar = Some(self.new_bar(tick));
649 Ok(vec![])
650 }
651 Some(current_bucket) if bucket == current_bucket => {
652 self.update_bar(tick);
653 Ok(vec![])
654 }
655 Some(_) => {
656 let completed = self.current_bar.take().expect("current bar must be Some here");
657 self.last_close = Some(completed.close);
658
659 let mut out = vec![completed];
661 let nanos = self.timeframe.to_nanos()?;
662 let prev_bucket = self.current_bucket_start.expect("set above");
663 let mut gap_bucket = NanoTimestamp::new(prev_bucket.nanos() + nanos);
664 while gap_bucket < bucket {
665 if let Some(close) = self.last_close {
666 out.push(OhlcvBar {
667 symbol: self.symbol.clone(),
668 open: close,
669 high: close,
670 low: close,
671 close,
672 volume: Quantity::zero(),
673 ts_open: gap_bucket,
674 ts_close: gap_bucket,
675 tick_count: 0,
676 });
677 }
678 gap_bucket = NanoTimestamp::new(gap_bucket.nanos() + nanos);
679 }
680
681 self.bars_emitted += out.len();
682 self.current_bucket_start = Some(bucket);
683 self.current_bar = Some(self.new_bar(tick));
684 Ok(out)
685 }
686 }
687 }
688
689 pub fn flush(&mut self) -> Option<OhlcvBar> {
691 self.current_bucket_start = None;
692 let bar = self.current_bar.take();
693 if let Some(ref b) = bar {
694 self.last_close = Some(b.close);
695 self.bars_emitted += 1;
696 }
697 bar
698 }
699
700 pub fn symbol(&self) -> &Symbol {
702 &self.symbol
703 }
704
705 pub fn timeframe(&self) -> Timeframe {
707 self.timeframe
708 }
709
710 pub fn reset(&mut self) {
715 self.current_bar = None;
716 self.current_bucket_start = None;
717 self.last_close = None;
718 self.bars_emitted = 0;
719 }
720
721 pub fn bar_count(&self) -> usize {
723 self.bars_emitted
724 }
725
726 pub fn current_bar(&self) -> Option<&OhlcvBar> {
728 self.current_bar.as_ref()
729 }
730
731 pub fn current_bar_open_ts(&self) -> Option<NanoTimestamp> {
736 self.current_bucket_start
737 }
738
739 fn new_bar(&self, tick: &Tick) -> OhlcvBar {
740 OhlcvBar {
741 symbol: self.symbol.clone(),
742 open: tick.price,
743 high: tick.price,
744 low: tick.price,
745 close: tick.price,
746 volume: tick.quantity,
747 ts_open: tick.timestamp,
748 ts_close: tick.timestamp,
749 tick_count: 1,
750 }
751 }
752
753 fn update_bar(&mut self, tick: &Tick) {
754 if let Some(ref mut bar) = self.current_bar {
755 if tick.price > bar.high {
756 bar.high = tick.price;
757 }
758 if tick.price < bar.low {
759 bar.low = tick.price;
760 }
761 bar.close = tick.price;
762 bar.volume =
763 Quantity::new(bar.volume.value() + tick.quantity.value()).unwrap_or(bar.volume);
764 bar.ts_close = tick.timestamp;
765 bar.tick_count += 1;
766 }
767 }
768}
769
770pub struct OhlcvSeries {
772 bars: Vec<OhlcvBar>,
773}
774
775impl OhlcvSeries {
776 pub fn new() -> Self {
778 Self { bars: Vec::new() }
779 }
780
781 pub fn from_bars(bars: Vec<OhlcvBar>) -> Result<Self, FinError> {
786 for bar in &bars {
787 bar.validate()?;
788 }
789 Ok(Self { bars })
790 }
791
792 pub fn with_capacity(capacity: usize) -> Self {
796 Self {
797 bars: Vec::with_capacity(capacity),
798 }
799 }
800
801 pub fn push(&mut self, bar: OhlcvBar) -> Result<(), FinError> {
806 bar.validate()?;
807 self.bars.push(bar);
808 Ok(())
809 }
810
811 pub fn len(&self) -> usize {
813 self.bars.len()
814 }
815
816 pub fn is_empty(&self) -> bool {
818 self.bars.is_empty()
819 }
820
821 pub fn clear(&mut self) {
823 self.bars.clear();
824 }
825
826 pub fn retain(&mut self, mut predicate: impl FnMut(&OhlcvBar) -> bool) {
830 self.bars.retain(|b| predicate(b));
831 }
832
833 pub fn get(&self, index: usize) -> Option<&OhlcvBar> {
835 self.bars.get(index)
836 }
837
838 pub fn first(&self) -> Option<&OhlcvBar> {
840 self.bars.first()
841 }
842
843 pub fn last(&self) -> Option<&OhlcvBar> {
845 self.bars.last()
846 }
847
848 pub fn n_bars_ago(&self, n: usize) -> Option<&OhlcvBar> {
853 let len = self.bars.len();
854 if n >= len {
855 return None;
856 }
857 self.bars.get(len - 1 - n)
858 }
859
860 pub fn window(&self, n: usize) -> &[OhlcvBar] {
862 let len = self.bars.len();
863 if n >= len {
864 &self.bars
865 } else {
866 &self.bars[len - n..]
867 }
868 }
869
870 pub fn iter(&self) -> std::slice::Iter<'_, OhlcvBar> {
872 self.bars.iter()
873 }
874
875 pub fn consecutive_ups(&self) -> usize {
879 self.bars
880 .iter()
881 .rev()
882 .take_while(|b| b.close.value() >= b.open.value())
883 .count()
884 }
885
886 pub fn consecutive_downs(&self) -> usize {
890 self.bars
891 .iter()
892 .rev()
893 .take_while(|b| b.close.value() < b.open.value())
894 .count()
895 }
896
897 pub fn opens(&self) -> Vec<Decimal> {
899 self.bars.iter().map(|b| b.open.value()).collect()
900 }
901
902 pub fn highs(&self) -> Vec<Decimal> {
904 self.bars.iter().map(|b| b.high.value()).collect()
905 }
906
907 pub fn lows(&self) -> Vec<Decimal> {
909 self.bars.iter().map(|b| b.low.value()).collect()
910 }
911
912 pub fn closes(&self) -> Vec<Decimal> {
914 self.bars.iter().map(|b| b.close.value()).collect()
915 }
916
917 pub fn volumes(&self) -> Vec<Decimal> {
919 self.bars.iter().map(|b| b.volume.value()).collect()
920 }
921
922 pub fn typical_prices(&self) -> Vec<Decimal> {
924 self.bars.iter().map(|b| b.typical_price()).collect()
925 }
926
927 pub fn bars(&self) -> &[OhlcvBar] {
929 &self.bars
930 }
931
932 pub fn max_high(&self) -> Option<Decimal> {
934 self.bars.iter().map(|b| b.high.value()).reduce(Decimal::max)
935 }
936
937 pub fn min_low(&self) -> Option<Decimal> {
939 self.bars.iter().map(|b| b.low.value()).reduce(Decimal::min)
940 }
941
942 pub fn highest_high(&self, n: usize) -> Option<Decimal> {
946 let start = self.bars.len().saturating_sub(n);
947 self.bars[start..].iter().map(|b| b.high.value()).reduce(Decimal::max)
948 }
949
950 pub fn lowest_low(&self, n: usize) -> Option<Decimal> {
954 let start = self.bars.len().saturating_sub(n);
955 self.bars[start..].iter().map(|b| b.low.value()).reduce(Decimal::min)
956 }
957
958 pub fn vwap(&self) -> Option<Decimal> {
963 if self.bars.is_empty() {
964 return None;
965 }
966 let total_vol: Decimal = self.bars.iter().map(|b| b.volume.value()).sum();
967 if total_vol == Decimal::ZERO {
968 return None;
969 }
970 let weighted_sum: Decimal = self
971 .bars
972 .iter()
973 .map(|b| b.typical_price() * b.volume.value())
974 .sum();
975 Some(weighted_sum / total_vol)
976 }
977
978 pub fn sum_volume(&self) -> Decimal {
980 self.bars.iter().map(|b| b.volume.value()).sum()
981 }
982
983 pub fn avg_volume(&self, n: usize) -> Option<Decimal> {
985 if n == 0 || self.bars.len() < n {
986 return None;
987 }
988 let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.volume.value()).sum();
989 #[allow(clippy::cast_possible_truncation)]
990 Some(sum / Decimal::from(n as u32))
991 }
992
993 pub fn price_range(&self, n: usize) -> Option<Decimal> {
996 if n == 0 || self.bars.len() < n {
997 return None;
998 }
999 let hh = self.highest_high(n)?;
1000 let ll = self.lowest_low(n)?;
1001 Some(hh - ll)
1002 }
1003
1004 pub fn close_location_value(&self, n: usize) -> Option<Decimal> {
1011 if n == 0 || self.bars.len() < n {
1012 return None;
1013 }
1014 let start = self.bars.len() - n;
1015 let sum: Decimal = self.bars[start..].iter().map(|b| {
1016 let h = b.high.value();
1017 let l = b.low.value();
1018 let c = b.close.value();
1019 let range = h - l;
1020 if range == Decimal::ZERO { Decimal::ZERO } else { ((c - l) - (h - c)) / range }
1021 }).sum();
1022 #[allow(clippy::cast_possible_truncation)]
1023 Some(sum / Decimal::from(n as u32))
1024 }
1025
1026 pub fn avg_dollar_volume(&self, n: usize) -> Option<Decimal> {
1032 if n == 0 || self.bars.len() < n {
1033 return None;
1034 }
1035 let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.dollar_volume()).sum();
1036 Some(sum / Decimal::from(n as u64))
1037 }
1038
1039 pub fn slice(&self, from: usize, to: usize) -> Option<&[OhlcvBar]> {
1041 if from > to || to > self.bars.len() {
1042 return None;
1043 }
1044 Some(&self.bars[from..to])
1045 }
1046
1047 pub fn truncate(&mut self, n: usize) {
1051 let len = self.bars.len();
1052 if n < len {
1053 self.bars.drain(0..len - n);
1054 }
1055 }
1056
1057 pub fn extend(&mut self, bars: impl IntoIterator<Item = OhlcvBar>) -> Result<(), FinError> {
1064 for bar in bars {
1065 self.push(bar)?;
1066 }
1067 Ok(())
1068 }
1069
1070 pub fn extend_from_series(&mut self, other: &OhlcvSeries) -> Result<(), FinError> {
1075 for bar in &other.bars {
1076 self.push(bar.clone())?;
1077 }
1078 Ok(())
1079 }
1080
1081 pub fn to_bar_inputs(&self) -> Vec<crate::signals::BarInput> {
1086 self.bars
1087 .iter()
1088 .map(crate::signals::BarInput::from)
1089 .collect()
1090 }
1091
1092 pub fn apply_signal(
1101 &self,
1102 signal: &mut dyn crate::signals::Signal,
1103 ) -> Result<Vec<crate::signals::SignalValue>, FinError> {
1104 self.bars.iter().map(|b| signal.update_bar(b)).collect()
1105 }
1106
1107 pub fn returns(&self) -> Vec<Decimal> {
1112 if self.bars.len() < 2 {
1113 return Vec::new();
1114 }
1115 self.bars
1116 .windows(2)
1117 .filter_map(|w| {
1118 let prev = w[0].close.value();
1119 if prev.is_zero() {
1120 return None;
1121 }
1122 Some((w[1].close.value() - prev) / prev)
1123 })
1124 .collect()
1125 }
1126
1127 pub fn highest_close(&self, n: usize) -> Option<Decimal> {
1131 let start = self.bars.len().saturating_sub(n);
1132 self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::max)
1133 }
1134
1135 pub fn lowest_close(&self, n: usize) -> Option<Decimal> {
1139 let start = self.bars.len().saturating_sub(n);
1140 self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::min)
1141 }
1142
1143 pub fn mean_close(&self, n: usize) -> Option<Decimal> {
1147 let start = self.bars.len().saturating_sub(n);
1148 let slice = &self.bars[start..];
1149 if slice.is_empty() {
1150 return None;
1151 }
1152 let sum: Decimal = slice.iter().map(|b| b.close.value()).sum();
1153 Some(sum / Decimal::from(slice.len() as u64))
1154 }
1155
1156 pub fn std_dev(&self, n: usize) -> Option<Decimal> {
1161 let start = self.bars.len().saturating_sub(n);
1162 let slice = &self.bars[start..];
1163 if slice.len() < 2 {
1164 return None;
1165 }
1166 let n_dec = Decimal::from(slice.len() as u64);
1167 let mean: Decimal = slice.iter().map(|b| b.close.value()).sum::<Decimal>() / n_dec;
1168 let variance: Decimal = slice
1169 .iter()
1170 .map(|b| { let d = b.close.value() - mean; d * d })
1171 .sum::<Decimal>()
1172 / n_dec;
1173 decimal_sqrt(variance).ok()
1174 }
1175
1176 pub fn median_close(&self, n: usize) -> Option<Decimal> {
1181 let start = self.bars.len().saturating_sub(n);
1182 let mut closes: Vec<Decimal> =
1183 self.bars[start..].iter().map(|b| b.close.value()).collect();
1184 if closes.is_empty() {
1185 return None;
1186 }
1187 closes.sort();
1188 let mid = closes.len() / 2;
1189 if closes.len() % 2 == 1 {
1190 Some(closes[mid])
1191 } else {
1192 Some((closes[mid - 1] + closes[mid]) / Decimal::TWO)
1193 }
1194 }
1195
1196 pub fn percentile_rank(&self, value: Decimal, n: usize) -> Option<Decimal> {
1202 let start = self.bars.len().saturating_sub(n);
1203 let slice = &self.bars[start..];
1204 if slice.is_empty() {
1205 return None;
1206 }
1207 let below = slice.iter().filter(|b| b.close.value() < value).count();
1208 Some(Decimal::from(below as u64) / Decimal::from(slice.len() as u64) * Decimal::ONE_HUNDRED)
1209 }
1210
1211 pub fn correlation(&self, other: &OhlcvSeries) -> Option<Decimal> {
1216 let n = self.bars.len().min(other.bars.len());
1217 if n < 2 {
1218 return None;
1219 }
1220 let xs: Vec<Decimal> = self.bars[self.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1221 let ys: Vec<Decimal> = other.bars[other.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1222 let n_dec = Decimal::from(n);
1223 let mean_x: Decimal = xs.iter().copied().sum::<Decimal>() / n_dec;
1224 let mean_y: Decimal = ys.iter().copied().sum::<Decimal>() / n_dec;
1225 let cov: Decimal = xs.iter().zip(ys.iter())
1226 .map(|(x, y)| (*x - mean_x) * (*y - mean_y))
1227 .sum::<Decimal>() / n_dec;
1228 let var_x: Decimal = xs.iter().map(|x| (*x - mean_x) * (*x - mean_x)).sum::<Decimal>() / n_dec;
1229 let var_y: Decimal = ys.iter().map(|y| (*y - mean_y) * (*y - mean_y)).sum::<Decimal>() / n_dec;
1230 if var_x.is_zero() || var_y.is_zero() {
1231 return None;
1232 }
1233 let std_x = decimal_sqrt(var_x).ok()?;
1235 let std_y = decimal_sqrt(var_y).ok()?;
1236 Some(cov / (std_x * std_y))
1237 }
1238
1239 pub fn rolling_sma(&self, period: usize) -> Vec<Option<Decimal>> {
1244 if period == 0 {
1245 return self.bars.iter().map(|_| None).collect();
1246 }
1247 let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1248 closes
1249 .windows(period)
1250 .enumerate()
1251 .fold(vec![None; closes.len()], |mut acc, (i, window)| {
1252 let sum: Decimal = window.iter().copied().sum();
1253 acc[i + period - 1] = Some(sum / Decimal::from(period as u64));
1254 acc
1255 })
1256 }
1257
1258 pub fn zscore(&self, period: usize) -> Vec<Option<Decimal>> {
1263 if period < 2 {
1264 return self.bars.iter().map(|_| None).collect();
1265 }
1266 let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1267 let n = closes.len();
1268 let mut result = vec![None; n];
1269 let period_dec = Decimal::from(period as u64);
1270 for i in (period - 1)..n {
1271 let window = &closes[(i + 1 - period)..=i];
1272 let mean: Decimal = window.iter().copied().sum::<Decimal>() / period_dec;
1273 let variance: Decimal = window
1274 .iter()
1275 .map(|x| (*x - mean) * (*x - mean))
1276 .sum::<Decimal>()
1277 / period_dec;
1278 if let Ok(std_dev) = decimal_sqrt(variance) {
1279 if !std_dev.is_zero() {
1280 result[i] = Some((closes[i] - mean) / std_dev);
1281 }
1282 }
1283 }
1284 result
1285 }
1286
1287 #[allow(clippy::cast_precision_loss)]
1294 pub fn log_returns(&self) -> Vec<f64> {
1295 if self.bars.len() < 2 {
1296 return Vec::new();
1297 }
1298 self.bars
1299 .windows(2)
1300 .filter_map(|w| {
1301 let prev = w[0].close.value();
1302 if prev.is_zero() {
1303 return None;
1304 }
1305 let ratio = w[1].close.value().checked_div(prev)?;
1306 use rust_decimal::prelude::ToPrimitive;
1307 let ratio_f64 = ratio.to_f64()?;
1308 if ratio_f64 > 0.0 {
1309 Some(ratio_f64.ln())
1310 } else {
1311 None
1312 }
1313 })
1314 .collect()
1315 }
1316
1317 pub fn cumulative_returns(&self) -> Vec<Decimal> {
1323 let first = match self.bars.first() {
1324 Some(b) => b.close.value(),
1325 None => return Vec::new(),
1326 };
1327 if first.is_zero() {
1328 return Vec::new();
1329 }
1330 self.bars
1331 .iter()
1332 .map(|b| b.close.value() / first - Decimal::ONE)
1333 .collect()
1334 }
1335
1336 pub fn resample(&self, n: usize) -> Result<Vec<OhlcvBar>, FinError> {
1344 if n == 0 || self.bars.is_empty() {
1345 return Ok(Vec::new());
1346 }
1347 let mut result = Vec::new();
1348 let mut chunks = self.bars.chunks(n);
1349 for chunk in &mut chunks {
1350 let mut merged = chunk[0].clone();
1351 for b in &chunk[1..] {
1352 merged = merged.merge(b)?;
1353 }
1354 result.push(merged);
1355 }
1356 Ok(result)
1357 }
1358
1359 pub fn max_drawdown(&self) -> Option<Decimal> {
1366 let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1367 if closes.is_empty() {
1368 return None;
1369 }
1370 let mut peak = closes[0];
1371 let mut max_dd = Decimal::ZERO;
1372 for &c in &closes[1..] {
1373 if c > peak {
1374 peak = c;
1375 } else if !peak.is_zero() {
1376 let dd = (peak - c) / peak;
1377 if dd > max_dd {
1378 max_dd = dd;
1379 }
1380 }
1381 }
1382 Some(max_dd)
1383 }
1384
1385 pub fn sharpe_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1394 let lr = self.log_returns();
1395 if lr.len() < 2 {
1396 return None;
1397 }
1398 let n = lr.len() as f64;
1399 let mean = lr.iter().sum::<f64>() / n;
1400 let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
1401 let std_dev = variance.sqrt();
1402 if std_dev == 0.0 {
1403 return None;
1404 }
1405 let bars_per_year = if bars_per_year <= 0.0 { 252.0 } else { bars_per_year };
1406 Some((mean - risk_free_rate) / std_dev * bars_per_year.sqrt())
1407 }
1408
1409 pub fn price_change_pct(&self, n: usize) -> Option<Decimal> {
1416 let len = self.bars.len();
1417 if len < n + 1 {
1418 return None;
1419 }
1420 let ref_close = self.bars[len - 1 - n].close.value();
1421 if ref_close.is_zero() {
1422 return None;
1423 }
1424 let last_close = self.bars[len - 1].close.value();
1425 Some((last_close - ref_close) / ref_close * Decimal::ONE_HUNDRED)
1426 }
1427
1428 pub fn count_bullish(&self, n: usize) -> usize {
1433 let start = self.bars.len().saturating_sub(n);
1434 self.bars[start..].iter().filter(|b| b.is_bullish()).count()
1435 }
1436
1437 pub fn count_bearish(&self, n: usize) -> usize {
1442 let start = self.bars.len().saturating_sub(n);
1443 self.bars[start..].iter().filter(|b| b.is_bearish()).count()
1444 }
1445
1446 pub fn count_inside_bars(&self) -> usize {
1451 self.bars
1452 .windows(2)
1453 .filter(|w| w[1].is_inside_bar(&w[0]))
1454 .count()
1455 }
1456
1457 pub fn count_outside_bars(&self) -> usize {
1462 self.bars
1463 .windows(2)
1464 .filter(|w| w[1].is_outside_bar(&w[0]))
1465 .count()
1466 }
1467
1468 pub fn pivot_highs(&self, n: usize) -> Vec<usize> {
1476 if n == 0 || self.bars.len() < 2 * n + 1 {
1477 return vec![];
1478 }
1479 let mut pivots = Vec::new();
1480 for i in n..self.bars.len() - n {
1481 let h = self.bars[i].high.value();
1482 let is_pivot = (1..=n).all(|j| {
1483 h > self.bars[i - j].high.value() && h > self.bars[i + j].high.value()
1484 });
1485 if is_pivot {
1486 pivots.push(i);
1487 }
1488 }
1489 pivots
1490 }
1491
1492 pub fn pivot_lows(&self, n: usize) -> Vec<usize> {
1500 if n == 0 || self.bars.len() < 2 * n + 1 {
1501 return vec![];
1502 }
1503 let mut pivots = Vec::new();
1504 for i in n..self.bars.len() - n {
1505 let l = self.bars[i].low.value();
1506 let is_pivot = (1..=n).all(|j| {
1507 l < self.bars[i - j].low.value() && l < self.bars[i + j].low.value()
1508 });
1509 if is_pivot {
1510 pivots.push(i);
1511 }
1512 }
1513 pivots
1514 }
1515
1516 #[allow(clippy::cast_possible_truncation)]
1521 pub fn above_sma(&self, period: usize, n: usize) -> usize {
1522 if self.bars.len() < period || period == 0 {
1523 return 0;
1524 }
1525 let start = self.bars.len().saturating_sub(n);
1526 let window_start = start.saturating_sub(period - 1);
1527 let mut count = 0usize;
1528 for i in start..self.bars.len() {
1529 if i + 1 < period {
1530 continue;
1531 }
1532 let sma_start = i + 1 - period;
1533 let sma: Decimal = self.bars[sma_start..=i]
1534 .iter()
1535 .map(|b| b.close.value())
1536 .sum::<Decimal>()
1537 / Decimal::from(period as u32);
1538 if self.bars[i].close.value() > sma {
1539 count += 1;
1540 }
1541 }
1542 let _ = window_start; count
1544 }
1545
1546 #[allow(clippy::cast_possible_truncation)]
1550 pub fn below_sma(&self, period: usize, n: usize) -> usize {
1551 if self.bars.len() < period || period == 0 {
1552 return 0;
1553 }
1554 let start = self.bars.len().saturating_sub(n);
1555 let mut count = 0usize;
1556 for i in start..self.bars.len() {
1557 if i + 1 < period {
1558 continue;
1559 }
1560 let sma_start = i + 1 - period;
1561 let sma: Decimal = self.bars[sma_start..=i]
1562 .iter()
1563 .map(|b| b.close.value())
1564 .sum::<Decimal>()
1565 / Decimal::from(period as u32);
1566 if self.bars[i].close.value() < sma {
1567 count += 1;
1568 }
1569 }
1570 count
1571 }
1572
1573 #[allow(clippy::cast_possible_truncation)]
1577 pub fn above_ema(&self, period: usize) -> bool {
1578 if period == 0 || self.bars.len() < period {
1579 return false;
1580 }
1581 let k = Decimal::TWO / Decimal::from((period + 1) as u32);
1582 let seed: Decimal = self.bars[..period].iter().map(|b| b.close.value()).sum::<Decimal>()
1583 / Decimal::from(period as u32);
1584 let mut ema = seed;
1585 for bar in &self.bars[period..] {
1586 ema = bar.close.value() * k + ema * (Decimal::ONE - k);
1587 }
1588 self.bars.last().map_or(false, |b| b.close.value() > ema)
1589 }
1590
1591 pub fn bullish_engulfing_count(&self, n: usize) -> usize {
1596 if self.bars.len() < 2 {
1597 return 0;
1598 }
1599 let start = self.bars.len().saturating_sub(n).max(1);
1600 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1601 let prev = &self.bars[start + i - 1];
1602 bar.is_bullish_engulfing(prev)
1603 }).count()
1604 }
1605
1606 pub fn range_expansion(&self, n: usize) -> Option<Decimal> {
1611 let last = self.bars.last()?;
1612 if n == 0 || self.bars.len() < n {
1613 return None;
1614 }
1615 let start = self.bars.len() - n;
1616 let avg_range: Decimal = self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>();
1617 #[allow(clippy::cast_possible_truncation)]
1618 let avg_range = avg_range / Decimal::from(n as u32);
1619 if avg_range == Decimal::ZERO {
1620 return None;
1621 }
1622 Some(last.range() / avg_range)
1623 }
1624
1625 pub fn bearish_engulfing_count(&self, n: usize) -> usize {
1629 if self.bars.len() < 2 {
1630 return 0;
1631 }
1632 let start = self.bars.len().saturating_sub(n).max(1);
1633 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1634 let prev = &self.bars[start + i - 1];
1635 let p_o = prev.open.value();
1637 let p_c = prev.close.value();
1638 let s_o = bar.open.value();
1639 let s_c = bar.close.value();
1640 p_c > p_o && s_c < s_o && s_o >= p_c && s_c <= p_o
1641 }).count()
1642 }
1643
1644 pub fn trend_strength(&self, n: usize) -> Option<Decimal> {
1651 if n < 2 || self.bars.len() < n {
1652 return None;
1653 }
1654 let start = self.bars.len() - n;
1655 let window = &self.bars[start..];
1656 let net = (window.last()?.close.value() - window[0].close.value()).abs();
1657 let total: Decimal = window.windows(2)
1658 .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1659 .sum();
1660 if total == Decimal::ZERO {
1661 return None;
1662 }
1663 Some(net / total)
1664 }
1665
1666 pub fn open_to_close_return(&self, n: usize) -> Option<Decimal> {
1672 if n == 0 || self.bars.len() < n {
1673 return None;
1674 }
1675 let start = self.bars.len() - n;
1676 let mut sum = Decimal::ZERO;
1677 for b in &self.bars[start..] {
1678 let o = b.open.value();
1679 if o == Decimal::ZERO {
1680 return None;
1681 }
1682 sum += (b.close.value() - o) / o;
1683 }
1684 #[allow(clippy::cast_possible_truncation)]
1685 Some(sum / Decimal::from(n as u32))
1686 }
1687
1688 pub fn gap_up_count(&self, n: usize) -> usize {
1690 if self.bars.len() < 2 {
1691 return 0;
1692 }
1693 let start = self.bars.len().saturating_sub(n).max(1);
1694 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1695 bar.open.value() > self.bars[start + i - 1].close.value()
1696 }).count()
1697 }
1698
1699 pub fn gap_down_count(&self, n: usize) -> usize {
1701 if self.bars.len() < 2 {
1702 return 0;
1703 }
1704 let start = self.bars.len().saturating_sub(n).max(1);
1705 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1706 bar.open.value() < self.bars[start + i - 1].close.value()
1707 }).count()
1708 }
1709
1710 pub fn overnight_gap_pct(&self, n: usize) -> Option<Decimal> {
1716 if n == 0 || self.bars.len() < 2 {
1717 return None;
1718 }
1719 let start = self.bars.len().saturating_sub(n).max(1);
1720 let window_len = self.bars.len() - start;
1721 if window_len == 0 {
1722 return None;
1723 }
1724 let mut sum = Decimal::ZERO;
1725 for i in start..self.bars.len() {
1726 let pc = self.bars[i - 1].close.value();
1727 if pc == Decimal::ZERO {
1728 return None;
1729 }
1730 sum += (self.bars[i].open.value() - pc) / pc * Decimal::ONE_HUNDRED;
1731 }
1732 #[allow(clippy::cast_possible_truncation)]
1733 Some(sum / Decimal::from(window_len as u32))
1734 }
1735
1736 pub fn close_rank(&self, n: usize) -> Option<Decimal> {
1742 if n < 2 || self.bars.len() < n {
1743 return None;
1744 }
1745 let start = self.bars.len() - n;
1746 let current = self.bars.last()?.close.value();
1747 let below = self.bars[start..self.bars.len() - 1]
1748 .iter()
1749 .filter(|b| b.close.value() < current)
1750 .count();
1751 #[allow(clippy::cast_possible_truncation)]
1752 Some(Decimal::from(below as u32) / Decimal::from((n - 1) as u32) * Decimal::ONE_HUNDRED)
1753 }
1754
1755 pub fn high_low_ratio(&self, n: usize) -> Option<Decimal> {
1759 if n == 0 || self.bars.len() < n {
1760 return None;
1761 }
1762 let hh = self.highest_high(n)?;
1763 let ll = self.lowest_low(n)?;
1764 if ll == Decimal::ZERO {
1765 return None;
1766 }
1767 Some(hh / ll)
1768 }
1769
1770 #[allow(clippy::cast_possible_truncation)]
1772 pub fn average_volume(&self, n: usize) -> Option<Decimal> {
1773 let start = self.bars.len().saturating_sub(n);
1774 let slice = &self.bars[start..];
1775 if slice.is_empty() {
1776 return None;
1777 }
1778 let sum: Decimal = slice.iter().map(|b| b.volume.value()).sum();
1779 Some(sum / Decimal::from(slice.len() as u32))
1780 }
1781
1782 pub fn last_n_closes(&self, n: usize) -> Vec<Decimal> {
1786 let start = self.bars.len().saturating_sub(n);
1787 self.bars[start..].iter().map(|b| b.close.value()).collect()
1788 }
1789
1790 pub fn volume_spike(&self, n: usize, multiplier: Decimal) -> bool {
1795 if self.bars.len() < 2 || multiplier.is_zero() {
1796 return false;
1797 }
1798 let last_vol = self.bars.last().unwrap().volume.value();
1799 let prior_count = self.bars.len() - 1;
1801 let start = prior_count.saturating_sub(n);
1802 let prior = &self.bars[start..prior_count];
1803 if prior.is_empty() {
1804 return false;
1805 }
1806 let avg: Decimal = prior.iter().map(|b| b.volume.value()).sum::<Decimal>()
1807 / Decimal::from(prior.len() as u32);
1808 last_vol > avg * multiplier
1809 }
1810
1811 #[allow(clippy::cast_possible_truncation)]
1815 pub fn average_range(&self, n: usize) -> Option<Decimal> {
1816 let start = self.bars.len().saturating_sub(n);
1817 let slice = &self.bars[start..];
1818 if slice.is_empty() {
1819 return None;
1820 }
1821 let sum: Decimal = slice.iter().map(|b| b.range()).sum();
1822 Some(sum / Decimal::from(slice.len() as u32))
1823 }
1824
1825 #[allow(clippy::cast_possible_truncation)]
1829 pub fn typical_price_mean(&self, n: usize) -> Option<Decimal> {
1830 let start = self.bars.len().saturating_sub(n);
1831 let slice = &self.bars[start..];
1832 if slice.is_empty() {
1833 return None;
1834 }
1835 let sum: Decimal = slice.iter().map(|b| b.typical_price()).sum();
1836 Some(sum / Decimal::from(slice.len() as u32))
1837 }
1838
1839 pub fn sortino_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1844 let log_rets = self.log_returns();
1845 if log_rets.len() < 2 {
1846 return None;
1847 }
1848 let mean_ret = log_rets.iter().copied().sum::<f64>() / log_rets.len() as f64;
1849 let downside: Vec<f64> = log_rets.iter().map(|&r| if r < 0.0 { r * r } else { 0.0 }).collect();
1850 let downside_var = downside.iter().copied().sum::<f64>() / downside.len() as f64;
1851 let downside_dev = downside_var.sqrt();
1852 if downside_dev == 0.0 {
1853 return None;
1854 }
1855 let rf_per_bar = risk_free_rate / bars_per_year;
1856 Some((mean_ret - rf_per_bar) / downside_dev * bars_per_year.sqrt())
1857 }
1858
1859 pub fn close_above_open_streak(&self) -> usize {
1863 self.bars
1864 .iter()
1865 .rev()
1866 .take_while(|b| b.close.value() > b.open.value())
1867 .count()
1868 }
1869
1870 pub fn max_drawdown_pct(&self, n: usize) -> Option<f64> {
1875 let window: Vec<f64> = self
1876 .bars
1877 .iter()
1878 .rev()
1879 .take(n)
1880 .map(|b| b.close.value().to_string().parse::<f64>().unwrap_or(0.0))
1881 .collect::<Vec<_>>()
1882 .into_iter()
1883 .rev()
1884 .collect();
1885 if window.len() < 2 {
1886 return None;
1887 }
1888 let mut max_dd = 0.0f64;
1889 let mut peak = window[0];
1890 for &price in &window[1..] {
1891 if price > peak {
1892 peak = price;
1893 }
1894 if peak > 0.0 {
1895 let dd = (peak - price) / peak * 100.0;
1896 if dd > max_dd {
1897 max_dd = dd;
1898 }
1899 }
1900 }
1901 Some(max_dd)
1902 }
1903
1904 #[allow(clippy::cast_possible_truncation)]
1909 pub fn atr_series(&self, period: usize) -> Vec<Option<Decimal>> {
1910 let n = self.bars.len();
1911 let mut result = vec![None; n];
1912 if period == 0 || n == 0 {
1913 return result;
1914 }
1915 let trs: Vec<Decimal> = self
1916 .bars
1917 .iter()
1918 .enumerate()
1919 .map(|(i, b)| {
1920 let prev = if i == 0 { None } else { Some(&self.bars[i - 1]) };
1921 b.true_range(prev)
1922 })
1923 .collect();
1924 for i in (period - 1)..n {
1925 let sum: Decimal = trs[i + 1 - period..=i].iter().copied().sum();
1926 result[i] = Some(sum / Decimal::from(period as u32));
1927 }
1928 result
1929 }
1930
1931 pub fn up_days(&self, n: usize) -> usize {
1936 if self.bars.len() < 2 {
1937 return 0;
1938 }
1939 let start = self.bars.len().saturating_sub(n).max(1);
1940 self.bars[start..]
1941 .iter()
1942 .enumerate()
1943 .filter(|(i, b)| b.close.value() > self.bars[start + i - 1].close.value())
1944 .count()
1945 }
1946
1947 pub fn down_days(&self, n: usize) -> usize {
1951 if self.bars.len() < 2 {
1952 return 0;
1953 }
1954 let start = self.bars.len().saturating_sub(n).max(1);
1955 self.bars[start..]
1956 .iter()
1957 .enumerate()
1958 .filter(|(i, b)| b.close.value() < self.bars[start + i - 1].close.value())
1959 .count()
1960 }
1961
1962 pub fn range_series(&self) -> Vec<Decimal> {
1966 self.bars.iter().map(|b| b.range()).collect()
1967 }
1968
1969 pub fn close_to_close_changes(&self) -> Vec<Decimal> {
1974 if self.bars.len() < 2 {
1975 return vec![];
1976 }
1977 self.bars
1978 .windows(2)
1979 .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1980 .collect()
1981 }
1982
1983 pub fn volatility_ratio(&self, short: usize, long: usize) -> Option<Decimal> {
1989 let n = self.bars.len();
1990 if short == 0 || long == 0 || n == 0 {
1991 return None;
1992 }
1993 let short_atr = *self.atr_series(short).last()?;
1994 let long_atr = *self.atr_series(long).last()?;
1995 let s = short_atr?;
1996 let l = long_atr?;
1997 if l.is_zero() {
1998 return None;
1999 }
2000 Some(s / l)
2001 }
2002
2003 pub fn streak(&self) -> i32 {
2025 let n = self.bars.len();
2026 if n < 2 {
2027 return 0;
2028 }
2029 let mut count: i32 = 0;
2030 for i in (1..n).rev() {
2031 let prev = self.bars[i - 1].close.value();
2032 let curr = self.bars[i].close.value();
2033 if curr > prev {
2034 if count < 0 {
2035 break;
2036 }
2037 count += 1;
2038 } else if curr < prev {
2039 if count > 0 {
2040 break;
2041 }
2042 count -= 1;
2043 } else {
2044 break;
2045 }
2046 }
2047 count
2048 }
2049
2050 pub fn calmar_ratio(&self, bars_per_year: f64) -> Option<f64> {
2057 let lr = self.log_returns();
2058 if lr.len() < 2 {
2059 return None;
2060 }
2061 let ann_return = (lr.iter().sum::<f64>() / lr.len() as f64) * bars_per_year;
2062 let dd = self.max_drawdown()?;
2063 use rust_decimal::prelude::ToPrimitive;
2064 let dd_f64 = dd.to_f64()?;
2065 if dd_f64 == 0.0_f64 {
2066 return None;
2067 }
2068 Some(ann_return / dd_f64)
2069 }
2070
2071 pub fn session_high_low(&self, n: usize) -> Option<(Decimal, Decimal)> {
2076 let start = self.bars.len().saturating_sub(n);
2077 let slice = &self.bars[start..];
2078 if slice.is_empty() {
2079 return None;
2080 }
2081 let h = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
2082 let l = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
2083 Some((h, l))
2084 }
2085
2086 pub fn percentage_change_series(&self) -> Vec<Option<Decimal>> {
2091 if self.bars.len() < 2 {
2092 return vec![];
2093 }
2094 self.bars
2095 .windows(2)
2096 .map(|w| {
2097 let prev_c = w[0].close.value();
2098 if prev_c.is_zero() {
2099 None
2100 } else {
2101 Some((w[1].close.value() - prev_c) / prev_c * Decimal::ONE_HUNDRED)
2102 }
2103 })
2104 .collect()
2105 }
2106
2107 pub fn realized_volatility(&self, n: usize, bars_per_year: f64) -> Option<f64> {
2112 if n == 0 || self.bars.len() < n + 1 {
2113 return None;
2114 }
2115 let start = self.bars.len() - n - 1;
2116 let lr: Vec<f64> = self.bars[start..]
2117 .windows(2)
2118 .filter_map(|w| {
2119 let prev = w[0].close.value();
2120 if prev.is_zero() {
2121 return None;
2122 }
2123 use rust_decimal::prelude::ToPrimitive;
2124 let ratio = (w[1].close.value() / prev).to_f64()?;
2125 Some(ratio.ln())
2126 })
2127 .collect();
2128 if lr.len() < 2 {
2129 return None;
2130 }
2131 let mean = lr.iter().sum::<f64>() / lr.len() as f64;
2132 let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / lr.len() as f64;
2133 Some(variance.sqrt() * bars_per_year.sqrt())
2134 }
2135
2136 pub fn rolling_correlation(&self, other: &OhlcvSeries, n: usize) -> Option<f64> {
2141 if n < 2 || self.bars.len() < n || other.bars.len() < n {
2142 return None;
2143 }
2144 use rust_decimal::prelude::ToPrimitive;
2145 let xs: Vec<f64> = self.bars[self.bars.len() - n..]
2146 .iter()
2147 .filter_map(|b| b.close.value().to_f64())
2148 .collect();
2149 let ys: Vec<f64> = other.bars[other.bars.len() - n..]
2150 .iter()
2151 .filter_map(|b| b.close.value().to_f64())
2152 .collect();
2153 if xs.len() != n || ys.len() != n {
2154 return None;
2155 }
2156 let n_f = n as f64;
2157 let mx = xs.iter().sum::<f64>() / n_f;
2158 let my = ys.iter().sum::<f64>() / n_f;
2159 let cov = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum::<f64>() / n_f;
2160 let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2161 let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2162 if sx == 0.0 || sy == 0.0 {
2163 return None;
2164 }
2165 Some(cov / (sx * sy))
2166 }
2167
2168 pub fn beta(&self, market: &OhlcvSeries, n: usize) -> Option<f64> {
2173 if n < 2 || self.bars.len() < n + 1 || market.bars.len() < n + 1 {
2174 return None;
2175 }
2176 use rust_decimal::prelude::ToPrimitive;
2177 let asset_lr: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2178 .windows(2)
2179 .filter_map(|w| {
2180 let prev = w[0].close.value();
2181 if prev.is_zero() { return None; }
2182 (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2183 })
2184 .collect();
2185 let mkt_lr: Vec<f64> = market.bars[market.bars.len() - n - 1..]
2186 .windows(2)
2187 .filter_map(|w| {
2188 let prev = w[0].close.value();
2189 if prev.is_zero() { return None; }
2190 (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2191 })
2192 .collect();
2193 let len = asset_lr.len().min(mkt_lr.len());
2194 if len < 2 {
2195 return None;
2196 }
2197 let n_f = len as f64;
2198 let ma = asset_lr[..len].iter().sum::<f64>() / n_f;
2199 let mm = mkt_lr[..len].iter().sum::<f64>() / n_f;
2200 let cov = asset_lr[..len].iter().zip(mkt_lr[..len].iter())
2201 .map(|(a, m)| (a - ma) * (m - mm))
2202 .sum::<f64>() / n_f;
2203 let var_m = mkt_lr[..len].iter().map(|m| (m - mm).powi(2)).sum::<f64>() / n_f;
2204 if var_m == 0.0 { return None; }
2205 Some(cov / var_m)
2206 }
2207
2208 pub fn information_ratio(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
2213 if n < 2 || self.bars.len() < n + 1 || benchmark.bars.len() < n + 1 {
2214 return None;
2215 }
2216 use rust_decimal::prelude::ToPrimitive;
2217 let excess: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2218 .windows(2)
2219 .zip(benchmark.bars[benchmark.bars.len() - n - 1..].windows(2))
2220 .filter_map(|(aw, bw)| {
2221 let ap = aw[0].close.value();
2222 let bp = bw[0].close.value();
2223 if ap.is_zero() || bp.is_zero() { return None; }
2224 let ar = (aw[1].close.value() / ap).to_f64()?.ln();
2225 let br = (bw[1].close.value() / bp).to_f64()?.ln();
2226 Some(ar - br)
2227 })
2228 .collect();
2229 if excess.len() < 2 { return None; }
2230 let n_f = excess.len() as f64;
2231 let mean = excess.iter().sum::<f64>() / n_f;
2232 let te = (excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / n_f).sqrt();
2233 if te == 0.0 { return None; }
2234 Some(mean / te)
2235 }
2236
2237 pub fn drawdown_series(&self) -> Vec<Decimal> {
2242 if self.bars.is_empty() {
2243 return vec![];
2244 }
2245 let mut peak = Decimal::MIN;
2246 self.bars
2247 .iter()
2248 .map(|b| {
2249 let close = b.close.value();
2250 if close > peak {
2251 peak = close;
2252 }
2253 if peak.is_zero() {
2254 Decimal::ZERO
2255 } else {
2256 (peak - close) / peak
2257 }
2258 })
2259 .collect()
2260 }
2261
2262 pub fn above_moving_average(&self, period: usize) -> Option<bool> {
2266 if period == 0 || self.bars.len() < period {
2267 return None;
2268 }
2269 let start = self.bars.len() - period;
2270 #[allow(clippy::cast_possible_truncation)]
2271 let sma: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2272 / Decimal::from(period as u32);
2273 Some(self.bars.last()?.close.value() > sma)
2274 }
2275
2276 pub fn consecutive_higher_highs(&self, n: usize) -> usize {
2280 if n == 0 || self.bars.len() < 2 {
2281 return 0;
2282 }
2283 let start = self.bars.len().saturating_sub(n).max(1);
2284 self.bars[start..]
2285 .iter()
2286 .enumerate()
2287 .filter(|(i, b)| b.high.value() > self.bars[start + i - 1].high.value())
2288 .count()
2289 }
2290
2291 pub fn consecutive_lower_lows(&self, n: usize) -> usize {
2296 if n == 0 || self.bars.len() < 2 {
2297 return 0;
2298 }
2299 let start = self.bars.len().saturating_sub(n).max(1);
2300 self.bars[start..]
2301 .iter()
2302 .enumerate()
2303 .filter(|(i, b)| b.low.value() < self.bars[start + i - 1].low.value())
2304 .count()
2305 }
2306
2307 pub fn vwap_deviation(&self, n: usize) -> Option<Decimal> {
2313 if n == 0 || self.bars.len() < n {
2314 return None;
2315 }
2316 let start = self.bars.len().saturating_sub(n);
2317 let slice = &self.bars[start..];
2318 let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
2319 if total_vol.is_zero() {
2320 return None;
2321 }
2322 let vwap: Decimal = slice.iter()
2323 .map(|b| {
2324 let tp = (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3u32);
2325 tp * b.volume.value()
2326 })
2327 .sum::<Decimal>() / total_vol;
2328 if vwap.is_zero() {
2329 return None;
2330 }
2331 let last_close = self.bars.last()?.close.value();
2332 Some((last_close - vwap) / vwap * Decimal::ONE_HUNDRED)
2333 }
2334
2335 pub fn average_true_range_pct(&self, n: usize) -> Option<f64> {
2340 use rust_decimal::prelude::ToPrimitive;
2341 if n == 0 || self.bars.len() < n {
2342 return None;
2343 }
2344 let atrs = self.atr_series(n);
2345 let last_close = self.bars.last()?.close.value();
2346 if last_close.is_zero() {
2347 return None;
2348 }
2349 let atr = (*atrs.last()?.as_ref()?).to_f64()?;
2350 let close_f64 = last_close.to_f64()?;
2351 Some(atr / close_f64 * 100.0)
2352 }
2353
2354 pub fn count_doji(&self, n: usize, threshold: Decimal) -> usize {
2358 if n == 0 {
2359 return 0;
2360 }
2361 let start = self.bars.len().saturating_sub(n);
2362 self.bars[start..].iter().filter(|b| b.is_doji(threshold)).count()
2363 }
2364
2365 pub fn gap_up_bars(&self, n: usize) -> usize {
2369 if n == 0 || self.bars.len() < 2 {
2370 return 0;
2371 }
2372 let start = self.bars.len().saturating_sub(n + 1);
2373 self.bars[start..].windows(2).filter(|w| w[1].gap_up_from(&w[0])).count()
2374 }
2375
2376 pub fn gap_down_bars(&self, n: usize) -> usize {
2380 if n == 0 || self.bars.len() < 2 {
2381 return 0;
2382 }
2383 let start = self.bars.len().saturating_sub(n + 1);
2384 self.bars[start..].windows(2).filter(|w| w[1].gap_down_from(&w[0])).count()
2385 }
2386
2387 pub fn cum_volume(&self, n: usize) -> Decimal {
2391 if n == 0 {
2392 return Decimal::ZERO;
2393 }
2394 let start = self.bars.len().saturating_sub(n);
2395 self.bars[start..].iter().map(|b| b.volume.value()).sum()
2396 }
2397
2398 pub fn momentum_score(&self, short: usize, long: usize) -> Option<f64> {
2403 use rust_decimal::prelude::ToPrimitive;
2404 if short == 0 || long == 0 || short >= long || self.bars.len() < long {
2405 return None;
2406 }
2407 #[allow(clippy::cast_possible_truncation)]
2408 let sma = |n: usize| -> Option<Decimal> {
2409 let start = self.bars.len().saturating_sub(n);
2410 let s: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
2411 Some(s / Decimal::from(n as u32))
2412 };
2413 let sma_s = sma(short)?;
2414 let sma_l = sma(long)?;
2415 if sma_l.is_zero() {
2416 return None;
2417 }
2418 ((sma_s - sma_l) / sma_l * Decimal::ONE_HUNDRED).to_f64()
2419 }
2420
2421 pub fn first_bar(&self) -> Option<&OhlcvBar> {
2423 self.bars.first()
2424 }
2425
2426 pub fn volume_weighted_close(&self, n: usize) -> Option<Decimal> {
2430 if n == 0 || self.bars.len() < n {
2431 return None;
2432 }
2433 let start = self.bars.len() - n;
2434 let vol_sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
2435 if vol_sum.is_zero() {
2436 return None;
2437 }
2438 let pv_sum: Decimal = self.bars[start..]
2439 .iter()
2440 .map(|b| b.close.value() * b.volume.value())
2441 .sum();
2442 Some(pv_sum / vol_sum)
2443 }
2444
2445 pub fn range_expansion_ratio(&self, n: usize) -> Option<f64> {
2450 use rust_decimal::prelude::ToPrimitive;
2451 if n == 0 || self.bars.len() < n {
2452 return None;
2453 }
2454 let last_range = self.bars.last()?.range();
2455 let start = self.bars.len() - n;
2456 let avg_range = self.bars[start..]
2457 .iter()
2458 .map(|b| b.range())
2459 .sum::<Decimal>();
2460 #[allow(clippy::cast_possible_truncation)]
2461 let avg = avg_range / Decimal::from(n as u32);
2462 if avg.is_zero() {
2463 return None;
2464 }
2465 (last_range / avg).to_f64()
2466 }
2467
2468 pub fn efficiency_ratio(&self, n: usize) -> Option<Decimal> {
2473 if n == 0 || self.bars.len() <= n {
2474 return None;
2475 }
2476 let start = self.bars.len() - n - 1;
2477 let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2478 let direction = (closes[n] - closes[0]).abs();
2479 let path: Decimal = closes.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
2480 if path.is_zero() {
2481 return None;
2482 }
2483 Some(direction / path)
2484 }
2485
2486 pub fn body_pct_series(&self, n: usize) -> Vec<Option<Decimal>> {
2491 let start = self.bars.len().saturating_sub(n);
2492 self.bars[start..]
2493 .iter()
2494 .map(|b| {
2495 let range = b.high.value() - b.low.value();
2496 if range.is_zero() {
2497 None
2498 } else {
2499 let body = (b.close.value() - b.open.value()).abs();
2500 Some(body / range * Decimal::ONE_HUNDRED)
2501 }
2502 })
2503 .collect()
2504 }
2505
2506 pub fn candle_color_changes(&self, n: usize) -> usize {
2511 let start = self.bars.len().saturating_sub(n);
2512 let slice = &self.bars[start..];
2513 if slice.len() < 2 {
2514 return 0;
2515 }
2516 slice.windows(2)
2517 .filter(|w| {
2518 let prev_bull = w[0].close.value() >= w[0].open.value();
2519 let curr_bull = w[1].close.value() >= w[1].open.value();
2520 prev_bull != curr_bull
2521 })
2522 .count()
2523 }
2524
2525 pub fn typical_price_series(&self, n: usize) -> Vec<Decimal> {
2527 let start = self.bars.len().saturating_sub(n);
2528 self.bars[start..]
2529 .iter()
2530 .map(|b| (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3))
2531 .collect()
2532 }
2533
2534 pub fn open_gap_series(&self) -> Vec<Decimal> {
2540 if self.bars.len() < 2 {
2541 return Vec::new();
2542 }
2543 self.bars
2544 .windows(2)
2545 .filter_map(|w| {
2546 let prev_close = w[0].close.value();
2547 if prev_close.is_zero() {
2548 return None;
2549 }
2550 Some((w[1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
2551 })
2552 .collect()
2553 }
2554
2555 pub fn intraday_range_pct(&self, n: usize) -> Option<Decimal> {
2559 if n == 0 || self.bars.is_empty() {
2560 return None;
2561 }
2562 let start = self.bars.len().saturating_sub(n);
2563 let slice = &self.bars[start..];
2564 let count = slice.len();
2565 if count == 0 {
2566 return None;
2567 }
2568 let sum: Option<Decimal> = slice.iter().try_fold(Decimal::ZERO, |acc, b| {
2569 let o = b.open.value();
2570 if o.is_zero() { return None; }
2571 Some(acc + (b.high.value() - b.low.value()) / o * Decimal::ONE_HUNDRED)
2572 });
2573 #[allow(clippy::cast_possible_truncation)]
2574 Some(sum? / Decimal::from(count as u32))
2575 }
2576
2577 pub fn close_above_prior_high(&self, n: usize) -> usize {
2581 if n == 0 || self.bars.len() < 2 {
2582 return 0;
2583 }
2584 let start = self.bars.len().saturating_sub(n + 1);
2585 self.bars[start..].windows(2).filter(|w| w[1].close.value() > w[0].high.value()).count()
2586 }
2587
2588 pub fn skewness(&self, n: usize) -> Option<f64> {
2592 use rust_decimal::prelude::ToPrimitive;
2593 if n < 3 || self.bars.len() < n {
2594 return None;
2595 }
2596 let start = self.bars.len().saturating_sub(n);
2597 let vals: Vec<f64> = self.bars[start..]
2598 .iter()
2599 .filter_map(|b| b.close.value().to_f64())
2600 .collect();
2601 if vals.len() < 3 {
2602 return None;
2603 }
2604 let n_f = vals.len() as f64;
2605 let mean = vals.iter().sum::<f64>() / n_f;
2606 let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2607 let std_dev = variance.sqrt();
2608 if std_dev == 0.0 {
2609 return None;
2610 }
2611 let skew = vals.iter().map(|x| ((x - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
2612 Some(skew)
2613 }
2614
2615 pub fn kurtosis(&self, n: usize) -> Option<f64> {
2620 use rust_decimal::prelude::ToPrimitive;
2621 if n < 4 || self.bars.len() < n {
2622 return None;
2623 }
2624 let start = self.bars.len().saturating_sub(n);
2625 let vals: Vec<f64> = self.bars[start..]
2626 .iter()
2627 .filter_map(|b| b.close.value().to_f64())
2628 .collect();
2629 if vals.len() < 4 {
2630 return None;
2631 }
2632 let n_f = vals.len() as f64;
2633 let mean = vals.iter().sum::<f64>() / n_f;
2634 let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2635 if variance == 0.0 {
2636 return None;
2637 }
2638 let kurt = vals.iter().map(|x| ((x - mean) / variance.sqrt()).powi(4)).sum::<f64>() / n_f - 3.0;
2639 Some(kurt)
2640 }
2641
2642 pub fn sma_crossover(&self, fast_period: usize, slow_period: usize) -> bool {
2647 if fast_period == 0 || slow_period == 0 || fast_period >= slow_period {
2648 return false;
2649 }
2650 if self.bars.len() < slow_period {
2651 return false;
2652 }
2653 let fast_start = self.bars.len() - fast_period;
2654 let slow_start = self.bars.len() - slow_period;
2655 let fast_avg: Decimal = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2656 / Decimal::from(fast_period as u32);
2657 let slow_avg: Decimal = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2658 / Decimal::from(slow_period as u32);
2659 fast_avg > slow_avg
2660 }
2661
2662 pub fn price_percentile(&self, price: Decimal, n: usize) -> Option<f64> {
2666 if n == 0 || self.bars.is_empty() {
2667 return None;
2668 }
2669 let start = self.bars.len().saturating_sub(n);
2670 let slice = &self.bars[start..];
2671 let count = slice.iter().filter(|b| b.close.value() <= price).count();
2672 Some(count as f64 / slice.len() as f64)
2673 }
2674
2675 pub fn intraday_range_mean(&self, n: usize) -> Option<Decimal> {
2679 if n == 0 || self.bars.len() < n {
2680 return None;
2681 }
2682 let start = self.bars.len() - n;
2683 let sum: Decimal = self.bars[start..].iter().map(|b| b.high.value() - b.low.value()).sum();
2684 #[allow(clippy::cast_possible_truncation)]
2685 Some(sum / Decimal::from(n as u32))
2686 }
2687
2688 pub fn range_to_atr_ratio(&self, n: usize) -> Option<Decimal> {
2693 if n == 0 || self.bars.len() < n + 1 {
2694 return None;
2695 }
2696 let start = self.bars.len() - n - 1;
2697 let slice = &self.bars[start..];
2698 let mut tr_sum = Decimal::ZERO;
2699 for w in slice.windows(2) {
2700 let prev_close = w[0].close.value();
2701 let high = w[1].high.value();
2702 let low = w[1].low.value();
2703 let tr = (high - low)
2704 .max((high - prev_close).abs())
2705 .max((low - prev_close).abs());
2706 tr_sum += tr;
2707 }
2708 #[allow(clippy::cast_possible_truncation)]
2709 let atr = tr_sum / Decimal::from(n as u32);
2710 if atr.is_zero() {
2711 return None;
2712 }
2713 let last = self.bars.last()?;
2714 let current_range = last.high.value() - last.low.value();
2715 Some(current_range / atr * Decimal::ONE_HUNDRED)
2716 }
2717
2718 pub fn close_momentum(&self, n: usize) -> Option<Decimal> {
2723 if n == 0 || self.bars.len() < n + 1 {
2724 return None;
2725 }
2726 let ref_close = self.bars[self.bars.len() - n - 1].close.value();
2727 if ref_close.is_zero() {
2728 return None;
2729 }
2730 let current = self.bars.last()?.close.value();
2731 Some((current - ref_close) / ref_close * Decimal::ONE_HUNDRED)
2732 }
2733
2734 pub fn average_gap_pct(&self, n: usize) -> Option<Decimal> {
2739 if n == 0 || self.bars.len() <= n {
2740 return None;
2741 }
2742 let start = self.bars.len() - n - 1;
2743 let slice = &self.bars[start..];
2744 let mut count = 0;
2745 let mut sum = Decimal::ZERO;
2746 for pair in slice.windows(2) {
2747 let pc = pair[0].close.value();
2748 if pc.is_zero() {
2749 continue;
2750 }
2751 sum += (pair[1].open.value() - pc).abs() / pc * Decimal::ONE_HUNDRED;
2752 count += 1;
2753 }
2754 if count == 0 {
2755 None
2756 } else {
2757 #[allow(clippy::cast_possible_truncation)]
2758 Some(sum / Decimal::from(count as u32))
2759 }
2760 }
2761
2762 pub fn returns_series(&self, n: usize) -> Vec<Decimal> {
2767 if n == 0 || self.bars.len() < 2 {
2768 return vec![];
2769 }
2770 use rust_decimal::prelude::ToPrimitive;
2771 let start = self.bars.len().saturating_sub(n + 1);
2772 let slice = &self.bars[start..];
2773 slice
2774 .windows(2)
2775 .map(|w| {
2776 let prev = w[0].close.value();
2777 let curr = w[1].close.value();
2778 if prev.is_zero() {
2779 Decimal::ZERO
2780 } else {
2781 let ratio = (curr / prev).to_f64().unwrap_or(1.0);
2782 Decimal::try_from(ratio.ln()).unwrap_or(Decimal::ZERO)
2783 }
2784 })
2785 .collect()
2786 }
2787
2788 pub fn max_consecutive_up(&self) -> usize {
2793 if self.bars.len() < 2 {
2794 return 0;
2795 }
2796 let mut max_run = 0usize;
2797 let mut current = 0usize;
2798 for w in self.bars.windows(2) {
2799 if w[1].close.value() > w[0].close.value() {
2800 current += 1;
2801 if current > max_run {
2802 max_run = current;
2803 }
2804 } else {
2805 current = 0;
2806 }
2807 }
2808 max_run
2809 }
2810
2811 pub fn max_consecutive_down(&self) -> usize {
2816 if self.bars.len() < 2 {
2817 return 0;
2818 }
2819 let mut max_run = 0usize;
2820 let mut current = 0usize;
2821 for w in self.bars.windows(2) {
2822 if w[1].close.value() < w[0].close.value() {
2823 current += 1;
2824 if current > max_run {
2825 max_run = current;
2826 }
2827 } else {
2828 current = 0;
2829 }
2830 }
2831 max_run
2832 }
2833
2834 pub fn typical_price_sma(&self, period: usize) -> Option<Decimal> {
2839 if period == 0 || self.bars.len() < period {
2840 return None;
2841 }
2842 let start = self.bars.len() - period;
2843 let sum: Decimal = self.bars[start..]
2844 .iter()
2845 .map(|b| (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3u32))
2846 .sum();
2847 #[allow(clippy::cast_possible_truncation)]
2848 Some(sum / Decimal::from(period as u32))
2849 }
2850
2851 pub fn bar_at_index(&self, i: usize) -> Option<&OhlcvBar> {
2853 self.bars.get(i)
2854 }
2855
2856 #[allow(clippy::cast_possible_truncation)]
2860 pub fn rolling_close_std(&self, n: usize) -> Option<Decimal> {
2861 if n < 2 || self.bars.len() < n {
2862 return None;
2863 }
2864 let start = self.bars.len() - n;
2865 let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2866 let mean = closes.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
2867 let variance = closes
2868 .iter()
2869 .map(|c| { let d = *c - mean; d * d })
2870 .sum::<Decimal>()
2871 / Decimal::from((n - 1) as u32);
2872 use rust_decimal::prelude::ToPrimitive;
2873 let std = variance.to_f64()?.sqrt();
2874 Decimal::try_from(std).ok()
2875 }
2876
2877 pub fn gap_direction_series(&self, n: usize) -> Vec<i8> {
2883 if n < 2 || self.bars.len() < 2 {
2884 return vec![];
2885 }
2886 let start = self.bars.len().saturating_sub(n);
2887 self.bars[start..]
2888 .windows(2)
2889 .map(|w| {
2890 let gap = w[1].open.value() - w[0].close.value();
2891 if gap > Decimal::ZERO {
2892 1i8
2893 } else if gap < Decimal::ZERO {
2894 -1i8
2895 } else {
2896 0i8
2897 }
2898 })
2899 .collect()
2900 }
2901
2902 pub fn volume_trend(&self, n: usize) -> Option<f64> {
2907 use rust_decimal::prelude::ToPrimitive;
2908 if n < 2 || self.bars.len() < n {
2909 return None;
2910 }
2911 let start = self.bars.len() - n;
2912 let vols: Vec<f64> = self.bars[start..]
2913 .iter()
2914 .filter_map(|b| b.volume.value().to_f64())
2915 .collect();
2916 if vols.len() < 2 {
2917 return None;
2918 }
2919 let n_f = vols.len() as f64;
2920 let sum_x: f64 = (0..vols.len()).map(|i| i as f64).sum();
2921 let sum_y: f64 = vols.iter().sum();
2922 let sum_xy: f64 = vols.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
2923 let sum_xx: f64 = (0..vols.len()).map(|i| (i as f64).powi(2)).sum();
2924 let denom = n_f * sum_xx - sum_x * sum_x;
2925 if denom == 0.0 { return None; }
2926 Some((n_f * sum_xy - sum_x * sum_y) / denom)
2927 }
2928
2929 pub fn wick_body_ratio(&self, n: usize) -> Option<f64> {
2934 use rust_decimal::prelude::ToPrimitive;
2935 if n == 0 || self.bars.len() < n {
2936 return None;
2937 }
2938 let start = self.bars.len() - n;
2939 let mut sum = 0.0f64;
2940 let mut count = 0usize;
2941 for b in &self.bars[start..] {
2942 let body = (b.close.value() - b.open.value()).abs().to_f64()?;
2943 if body == 0.0 { continue; }
2944 let range = (b.high.value() - b.low.value()).to_f64()?;
2945 let wick = (range - body).max(0.0);
2946 sum += wick / body;
2947 count += 1;
2948 }
2949 if count == 0 { return None; }
2950 Some(sum / count as f64)
2951 }
2952
2953 pub fn volume_price_correlation(&self, n: usize) -> Option<f64> {
2957 use rust_decimal::prelude::ToPrimitive;
2958 if n < 2 || self.bars.len() < n {
2959 return None;
2960 }
2961 let start = self.bars.len() - n;
2962 let xs: Vec<f64> = self.bars[start..]
2963 .iter()
2964 .filter_map(|b| b.volume.value().to_f64())
2965 .collect();
2966 let ys: Vec<f64> = self.bars[start..]
2967 .iter()
2968 .filter_map(|b| b.close.value().to_f64())
2969 .collect();
2970 if xs.len() < 2 { return None; }
2971 let n_f = xs.len() as f64;
2972 let mx = xs.iter().sum::<f64>() / n_f;
2973 let my = ys.iter().sum::<f64>() / n_f;
2974 let num: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum();
2975 let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2976 let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2977 if sx == 0.0 || sy == 0.0 { return None; }
2978 Some(num / (n_f * sx * sy))
2979 }
2980
2981 pub fn bar_range_pct(&self, n: usize) -> Option<Decimal> {
2985 if n == 0 || self.bars.len() < n {
2986 return None;
2987 }
2988 let start = self.bars.len() - n;
2989 let mut sum = Decimal::ZERO;
2990 let mut count = 0u32;
2991 for b in &self.bars[start..] {
2992 let c = b.close.value();
2993 if c.is_zero() { continue; }
2994 sum += (b.high.value() - b.low.value()) / c * Decimal::ONE_HUNDRED;
2995 count += 1;
2996 }
2997 if count == 0 { return None; }
2998 Some(sum / Decimal::from(count))
2999 }
3000
3001 pub fn close_vs_prior_range_count(&self, n: usize) -> usize {
3005 if n < 2 || self.bars.len() < 2 {
3006 return 0;
3007 }
3008 let start = self.bars.len().saturating_sub(n);
3009 let slice = &self.bars[start..];
3010 slice.windows(2)
3011 .filter(|w| {
3012 let mid = (w[0].high.value() + w[0].low.value()) / Decimal::TWO;
3013 w[1].close.value() > mid
3014 })
3015 .count()
3016 }
3017
3018 pub fn rolling_sharpe(&self, n: usize, risk_free_rate: Decimal) -> Option<Decimal> {
3023 if n == 0 || self.bars.len() < 2 {
3024 return None;
3025 }
3026 use rust_decimal::prelude::ToPrimitive;
3027 let returns = self.returns_series(n);
3028 if returns.len() < 2 {
3029 return None;
3030 }
3031 #[allow(clippy::cast_possible_truncation)]
3032 let len_d = Decimal::from(returns.len() as u32);
3033 let mean: Decimal = returns.iter().copied().sum::<Decimal>() / len_d;
3034 let rf_daily = risk_free_rate / Decimal::from(252u32);
3035 let excess_mean = mean - rf_daily;
3036 let variance = returns
3037 .iter()
3038 .map(|r| { let d = *r - mean; d * d })
3039 .sum::<Decimal>()
3040 / len_d;
3041 let std_f64 = variance.to_f64()?.sqrt();
3042 if std_f64 == 0.0 {
3043 return None;
3044 }
3045 let sharpe = excess_mean.to_f64()? / std_f64 * 252.0f64.sqrt();
3046 Decimal::try_from(sharpe).ok()
3047 }
3048
3049 pub fn close_range_position(&self, n: usize) -> Option<Decimal> {
3055 if n == 0 || self.bars.len() < n {
3056 return None;
3057 }
3058 let start = self.bars.len() - n;
3059 let slice = &self.bars[start..];
3060 let highest = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
3061 let lowest = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
3062 let range = highest - lowest;
3063 if range.is_zero() {
3064 return None;
3065 }
3066 let close = self.bars.last()?.close.value();
3067 Some((close - lowest) / range * Decimal::ONE_HUNDRED)
3068 }
3069
3070 pub fn bar_count_since_high(&self, n: usize) -> usize {
3075 if n == 0 || self.bars.len() < n {
3076 return 0;
3077 }
3078 let start = self.bars.len() - n;
3079 let slice = &self.bars[start..];
3080 let mut max_val = Decimal::MIN;
3081 let mut max_idx = 0;
3082 for (i, b) in slice.iter().enumerate() {
3083 let c = b.close.value();
3084 if c > max_val {
3085 max_val = c;
3086 max_idx = i;
3087 }
3088 }
3089 slice.len() - 1 - max_idx
3090 }
3091
3092 pub fn close_to_open_ratio(&self, n: usize) -> Option<Decimal> {
3096 if n == 0 || self.bars.len() < n {
3097 return None;
3098 }
3099 let start = self.bars.len() - n;
3100 let mut sum = Decimal::ZERO;
3101 let mut count = 0usize;
3102 for b in &self.bars[start..] {
3103 let o = b.open.value();
3104 if o.is_zero() {
3105 continue;
3106 }
3107 sum += (b.close.value() / o - Decimal::ONE) * Decimal::ONE_HUNDRED;
3108 count += 1;
3109 }
3110 if count == 0 {
3111 return None;
3112 }
3113 #[allow(clippy::cast_possible_truncation)]
3114 Some(sum / Decimal::from(count as u32))
3115 }
3116
3117 pub fn autocorrelation(&self, n: usize, lag: usize) -> Option<f64> {
3123 if n == 0 || lag == 0 || self.bars.len() < n + lag + 1 {
3124 return None;
3125 }
3126 use rust_decimal::prelude::ToPrimitive;
3127 let returns = self.returns_series(n + lag);
3128 if returns.len() <= lag {
3129 return None;
3130 }
3131 let x: Vec<f64> = returns[..returns.len() - lag].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3132 let y: Vec<f64> = returns[lag..].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3133 let n_f = x.len() as f64;
3134 let mean_x = x.iter().sum::<f64>() / n_f;
3135 let mean_y = y.iter().sum::<f64>() / n_f;
3136 let cov: f64 = x.iter().zip(y.iter()).map(|(xi, yi)| (xi - mean_x) * (yi - mean_y)).sum::<f64>() / n_f;
3137 let std_x = (x.iter().map(|xi| (xi - mean_x).powi(2)).sum::<f64>() / n_f).sqrt();
3138 let std_y = (y.iter().map(|yi| (yi - mean_y).powi(2)).sum::<f64>() / n_f).sqrt();
3139 if std_x == 0.0 || std_y == 0.0 {
3140 return None;
3141 }
3142 Some(cov / (std_x * std_y))
3143 }
3144
3145 pub fn hurst_exponent(&self, n: usize) -> Option<f64> {
3150 if n < 8 || self.bars.len() < n + 1 {
3151 return None;
3152 }
3153 use rust_decimal::prelude::ToPrimitive;
3154 let returns: Vec<f64> = self
3155 .returns_series(n)
3156 .iter()
3157 .map(|r| r.to_f64().unwrap_or(0.0))
3158 .collect();
3159 if returns.is_empty() {
3160 return None;
3161 }
3162 let mean = returns.iter().sum::<f64>() / returns.len() as f64;
3163 let cum: Vec<f64> = returns.iter().scan(0.0f64, |acc, &r| { *acc += r - mean; Some(*acc) }).collect();
3164 let r = cum.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
3165 - cum.iter().cloned().fold(f64::INFINITY, f64::min);
3166 let s = (returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64).sqrt();
3167 if s == 0.0 || r <= 0.0 {
3168 return None;
3169 }
3170 Some((r / s).ln() / (returns.len() as f64).ln())
3171 }
3172
3173 pub fn ulcer_index(&self, n: usize) -> Option<Decimal> {
3178 if n == 0 || self.bars.len() < n {
3179 return None;
3180 }
3181 use rust_decimal::prelude::ToPrimitive;
3182 let start = self.bars.len() - n;
3183 let slice = &self.bars[start..];
3184 let mut peak = Decimal::ZERO;
3185 let mut sum_sq = 0.0f64;
3186 for b in slice {
3187 let c = b.close.value();
3188 if c > peak { peak = c; }
3189 if peak.is_zero() { continue; }
3190 let dd_pct = ((c - peak) / peak * Decimal::ONE_HUNDRED).to_f64().unwrap_or(0.0);
3191 sum_sq += dd_pct * dd_pct;
3192 }
3193 let ui = (sum_sq / n as f64).sqrt();
3194 Decimal::try_from(ui).ok()
3195 }
3196
3197 pub fn cvar(&self, n: usize, confidence_pct: Decimal) -> Option<Decimal> {
3203 use rust_decimal::prelude::ToPrimitive;
3204 if n < 2 || confidence_pct <= Decimal::ZERO || confidence_pct >= Decimal::ONE_HUNDRED {
3205 return None;
3206 }
3207 let mut returns = self.returns_series(n);
3208 if returns.len() < 2 {
3209 return None;
3210 }
3211 returns.sort_unstable_by(|a, b| a.cmp(b));
3212 let cutoff = ((Decimal::ONE - confidence_pct / Decimal::ONE_HUNDRED)
3213 .to_f64()
3214 .unwrap_or(0.05)
3215 * returns.len() as f64)
3216 .ceil() as usize;
3217 let tail = &returns[..cutoff.min(returns.len())];
3218 if tail.is_empty() {
3219 return None;
3220 }
3221 #[allow(clippy::cast_possible_truncation)]
3222 let avg = tail.iter().copied().sum::<Decimal>() / Decimal::from(tail.len() as u32);
3223 Some(avg)
3224 }
3225
3226 pub fn close_change_pct(&self, n: usize) -> Option<Decimal> {
3231 if n == 0 || self.bars.len() <= n {
3232 return None;
3233 }
3234 let recent = self.bars.last()?.close.value();
3235 let earlier = self.bars[self.bars.len() - 1 - n].close.value();
3236 if earlier.is_zero() {
3237 return None;
3238 }
3239 Some((recent - earlier) / earlier * Decimal::ONE_HUNDRED)
3240 }
3241
3242 pub fn close_above_vwap_pct(&self, n: usize) -> Option<f64> {
3245 if n == 0 || self.bars.len() < n { return None; }
3246 let start = self.bars.len() - n;
3247 let window = &self.bars[start..];
3248 let total_vol: Decimal = window.iter().map(|b| b.volume.value()).sum();
3249 if total_vol.is_zero() { return None; }
3250 let vwap = window.iter()
3251 .map(|b| b.typical_price() * b.volume.value())
3252 .sum::<Decimal>() / total_vol;
3253 let above = window.iter().filter(|b| b.close.value() > vwap).count();
3254 Some(above as f64 / n as f64 * 100.0)
3255 }
3256
3257 pub fn reversal_count(&self, n: usize) -> usize {
3260 if n < 2 || self.bars.len() < n { return 0; }
3261 let start = self.bars.len() - n;
3262 self.bars[start..].windows(3)
3263 .filter(|w| {
3264 let prev_dir = w[1].close.value() > w[0].close.value();
3265 let curr_dir = w[2].close.value() > w[1].close.value();
3266 prev_dir != curr_dir
3267 })
3268 .count()
3269 }
3270
3271 pub fn open_gap_fill_rate(&self, n: usize) -> Option<f64> {
3275 if n == 0 || self.bars.len() < n + 1 { return None; }
3276 let start = self.bars.len() - n;
3277 let mut gap_count = 0usize;
3278 let mut filled = 0usize;
3279 for i in start..self.bars.len() {
3280 let prior_close = self.bars[i - 1].close.value();
3281 let bar = &self.bars[i];
3282 let open = bar.open.value();
3283 if open == prior_close { continue; }
3284 gap_count += 1;
3285 let gap_up = open > prior_close;
3286 if gap_up && bar.low.value() <= prior_close {
3287 filled += 1;
3288 } else if !gap_up && bar.high.value() >= prior_close {
3289 filled += 1;
3290 }
3291 }
3292 if gap_count == 0 { return None; }
3293 Some(filled as f64 / gap_count as f64 * 100.0)
3294 }
3295
3296 pub fn candle_symmetry(&self, n: usize) -> Option<f64> {
3300 if n == 0 || self.bars.len() < n { return None; }
3301 let start = self.bars.len() - n;
3302 let mut ratios = Vec::new();
3303 for bar in &self.bars[start..] {
3304 let body_top = bar.close.value().max(bar.open.value());
3305 let body_bot = bar.close.value().min(bar.open.value());
3306 let upper = bar.high.value() - body_top;
3307 let lower = body_bot - bar.low.value();
3308 if upper.is_zero() && lower.is_zero() { continue; }
3309 let total = upper + lower;
3310 if total.is_zero() { continue; }
3311 let ratio: f64 = lower.to_string().parse::<f64>().unwrap_or(0.0)
3312 / total.to_string().parse::<f64>().unwrap_or(1.0);
3313 ratios.push(ratio);
3314 }
3315 if ratios.is_empty() { return None; }
3316 Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
3317 }
3318
3319}
3320
3321impl Default for OhlcvSeries {
3322 fn default() -> Self {
3323 Self::new()
3324 }
3325}
3326
3327impl<'a> IntoIterator for &'a OhlcvSeries {
3328 type Item = &'a OhlcvBar;
3329 type IntoIter = std::slice::Iter<'a, OhlcvBar>;
3330
3331 fn into_iter(self) -> Self::IntoIter {
3332 self.bars.iter()
3333 }
3334}
3335
3336fn decimal_sqrt(n: Decimal) -> Result<Decimal, FinError> {
3337 if n.is_zero() {
3338 return Ok(Decimal::ZERO);
3339 }
3340 if n.is_sign_negative() {
3341 return Err(FinError::ArithmeticOverflow);
3342 }
3343 let mut x = n;
3344 for _ in 0..20 {
3345 let next = (x + n / x) / Decimal::TWO;
3346 let diff = if next > x { next - x } else { x - next };
3347 x = next;
3348 if diff < Decimal::new(1, 10) {
3349 break;
3350 }
3351 }
3352 Ok(x)
3353}
3354
3355impl OhlcvSeries {
3356 pub fn max_drawdown_duration(&self) -> usize {
3361 if self.bars.len() < 2 {
3362 return 0;
3363 }
3364 let mut max_run = 0usize;
3365 let mut current = 0usize;
3366 for i in 1..self.bars.len() {
3367 if self.bars[i].close.value() < self.bars[i - 1].close.value() {
3368 current += 1;
3369 if current > max_run {
3370 max_run = current;
3371 }
3372 } else {
3373 current = 0;
3374 }
3375 }
3376 max_run
3377 }
3378
3379 pub fn close_above_open_pct(&self, n: usize) -> Option<f64> {
3384 if n == 0 || self.bars.len() < n {
3385 return None;
3386 }
3387 let start = self.bars.len() - n;
3388 let count = self.bars[start..]
3389 .iter()
3390 .filter(|b| b.close.value() > b.open.value())
3391 .count();
3392 Some(count as f64 / n as f64 * 100.0)
3393 }
3394
3395 pub fn avg_wick_ratio(&self, n: usize) -> Option<f64> {
3403 use rust_decimal::prelude::ToPrimitive;
3404 if n == 0 || self.bars.len() < n {
3405 return None;
3406 }
3407 let start = self.bars.len() - n;
3408 let mut sum = 0.0f64;
3409 let mut count = 0usize;
3410 for b in &self.bars[start..] {
3411 let range = b.range();
3412 if !range.is_zero() {
3413 let wick = b.upper_shadow() + b.lower_shadow();
3414 if let Some(ratio) = (wick / range).to_f64() {
3415 sum += ratio;
3416 count += 1;
3417 }
3418 }
3419 }
3420 if count == 0 {
3421 return None;
3422 }
3423 Some(sum / count as f64)
3424 }
3425
3426 pub fn gain_loss_ratio(&self, n: usize) -> Option<f64> {
3432 if n == 0 || self.bars.len() < n + 1 {
3433 return None;
3434 }
3435 use rust_decimal::prelude::ToPrimitive;
3436 let start = self.bars.len() - n - 1;
3437 let slice = &self.bars[start..];
3438 let mut gains = 0.0f64;
3439 let mut losses = 0.0f64;
3440 let mut gain_count = 0usize;
3441 let mut loss_count = 0usize;
3442 for w in slice.windows(2) {
3443 let pc = w[0].close.value().to_f64()?;
3444 let cc = w[1].close.value().to_f64()?;
3445 if pc <= 0.0 { continue; }
3446 let r = (cc / pc).ln();
3447 if r > 0.0 {
3448 gains += r;
3449 gain_count += 1;
3450 } else if r < 0.0 {
3451 losses += r.abs();
3452 loss_count += 1;
3453 }
3454 }
3455 if loss_count == 0 || losses == 0.0 {
3456 return None;
3457 }
3458 let avg_gain = gains / gain_count.max(1) as f64;
3459 let avg_loss = losses / loss_count as f64;
3460 Some(avg_gain / avg_loss)
3461 }
3462
3463 pub fn bars_above_sma(&self, n: usize, sma_period: usize) -> Option<usize> {
3469 if n == 0 || sma_period == 0 || self.bars.len() < n {
3470 return None;
3471 }
3472 let start = self.bars.len() - n;
3473 let mut count = 0usize;
3474 for i in start..self.bars.len() {
3475 if i + 1 < sma_period {
3476 continue;
3477 }
3478 let sma_start = i + 1 - sma_period;
3479 let sum: Decimal = self.bars[sma_start..=i]
3480 .iter()
3481 .map(|b| b.close.value())
3482 .sum();
3483 let sma = sum / Decimal::from(sma_period as u32);
3484 if self.bars[i].close.value() > sma {
3485 count += 1;
3486 }
3487 }
3488 Some(count)
3489 }
3490
3491 pub fn close_distance_from_low(&self, n: usize) -> Option<Decimal> {
3496 if n == 0 || self.bars.len() < n {
3497 return None;
3498 }
3499 let start = self.bars.len() - n;
3500 let min_low = self.bars[start..]
3501 .iter()
3502 .map(|b| b.low.value())
3503 .reduce(Decimal::min)?;
3504 let last_close = self.bars.last()?.close.value();
3505 Some(last_close - min_low)
3506 }
3507
3508 pub fn volume_ratio(&self, n: usize) -> Option<Decimal> {
3513 if n == 0 || self.bars.len() < n {
3514 return None;
3515 }
3516 let start = self.bars.len() - n;
3517 let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
3518 let avg = sum.checked_div(Decimal::from(n as u32))?;
3519 if avg.is_zero() {
3520 return None;
3521 }
3522 let last_vol = self.bars.last()?.volume.value();
3523 last_vol.checked_div(avg)
3524 }
3525
3526 pub fn momentum_quality(&self, n: usize) -> Option<f64> {
3532 if n == 0 || self.bars.len() < n {
3533 return None;
3534 }
3535 let start = self.bars.len() - n;
3536 let slice = &self.bars[start..];
3537 let avg_vol: Decimal = {
3538 let s: Decimal = slice.iter().map(|b| b.volume.value()).sum();
3539 s.checked_div(Decimal::from(n as u32))?
3540 };
3541 let mut high_vol_bars = 0usize;
3542 let mut high_vol_up = 0usize;
3543 for b in slice {
3544 if b.volume.value() > avg_vol {
3545 high_vol_bars += 1;
3546 if b.close > b.open {
3547 high_vol_up += 1;
3548 }
3549 }
3550 }
3551 if high_vol_bars == 0 {
3552 return None;
3553 }
3554 Some(high_vol_up as f64 / high_vol_bars as f64)
3555 }
3556
3557 pub fn bullish_candle_pct(&self, n: usize) -> Option<f64> {
3561 if n == 0 || self.bars.len() < n {
3562 return None;
3563 }
3564 let start = self.bars.len() - n;
3565 let bullish = self.bars[start..].iter().filter(|b| b.close > b.open).count();
3566 Some(bullish as f64 / n as f64)
3567 }
3568
3569 pub fn price_above_ma_pct(&self, n: usize, period: usize) -> Option<f64> {
3575 if n == 0 || period == 0 || self.bars.len() < n + period - 1 {
3576 return None;
3577 }
3578 let total = self.bars.len();
3579 let mut above = 0usize;
3580 for i in (total - n)..total {
3581 let sma_start = i + 1 - period;
3582 let sma: Decimal = self.bars[sma_start..=i]
3583 .iter()
3584 .map(|b| b.close.value())
3585 .sum::<Decimal>()
3586 / Decimal::from(period as u32);
3587 if self.bars[i].close.value() > sma {
3588 above += 1;
3589 }
3590 }
3591 Some(above as f64 / n as f64)
3592 }
3593
3594 pub fn true_range_series(&self, n: usize) -> Option<Vec<Decimal>> {
3600 if n == 0 || self.bars.len() < n {
3601 return None;
3602 }
3603 let start = self.bars.len() - n;
3604 let trs: Vec<Decimal> = self.bars[start..]
3605 .iter()
3606 .enumerate()
3607 .map(|(i, bar)| {
3608 let abs_i = start + i;
3609 if abs_i == 0 {
3610 bar.high.value() - bar.low.value()
3611 } else {
3612 let prev_close = self.bars[abs_i - 1].close.value();
3613 let high = bar.high.value().max(prev_close);
3614 let low = bar.low.value().min(prev_close);
3615 high - low
3616 }
3617 })
3618 .collect();
3619 Some(trs)
3620 }
3621
3622 pub fn intraday_return_pct(&self) -> Option<Decimal> {
3627 if self.bars.is_empty() {
3628 return None;
3629 }
3630 let first_open = self.bars.first()?.open.value();
3631 if first_open.is_zero() {
3632 return None;
3633 }
3634 let last_close = self.bars.last()?.close.value();
3635 Some((last_close - first_open) / first_open * Decimal::ONE_HUNDRED)
3636 }
3637
3638 pub fn bearish_bar_count(&self, n: usize) -> Option<usize> {
3642 if n == 0 || self.bars.len() < n {
3643 return None;
3644 }
3645 let start = self.bars.len() - n;
3646 Some(self.bars[start..].iter().filter(|b| b.close < b.open).count())
3647 }
3648
3649 pub fn avg_body_size(&self, n: usize) -> Option<Decimal> {
3653 if n == 0 || self.bars.len() < n {
3654 return None;
3655 }
3656 let start = self.bars.len() - n;
3657 let sum: Decimal = self.bars[start..]
3658 .iter()
3659 .map(|b| (b.close.value() - b.open.value()).abs())
3660 .sum();
3661 Some(sum / Decimal::from(n as u32))
3662 }
3663
3664 pub fn hl_midpoint(&self, n: usize) -> Option<Decimal> {
3668 if n == 0 || self.bars.len() < n {
3669 return None;
3670 }
3671 let start = self.bars.len() - n;
3672 let sum: Decimal = self.bars[start..]
3673 .iter()
3674 .map(|b| (b.high.value() + b.low.value()) / Decimal::TWO)
3675 .sum();
3676 #[allow(clippy::cast_possible_truncation)]
3677 Some(sum / Decimal::from(n as u32))
3678 }
3679
3680 pub fn up_volume_ratio(&self, n: usize) -> Option<Decimal> {
3684 if n == 0 || self.bars.len() < n {
3685 return None;
3686 }
3687 let start = self.bars.len() - n;
3688 let total_vol: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
3689 if total_vol.is_zero() {
3690 return None;
3691 }
3692 let up_vol: Decimal = self.bars[start..]
3693 .iter()
3694 .filter(|b| b.close > b.open)
3695 .map(|b| b.volume.value())
3696 .sum();
3697 up_vol.checked_div(total_vol)
3698 }
3699
3700 pub fn price_efficiency(&self, n: usize) -> Option<Decimal> {
3709 if n < 2 || self.bars.len() < n {
3710 return None;
3711 }
3712 let start = self.bars.len() - n;
3713 let net = (self.bars.last()?.close.value() - self.bars[start].close.value()).abs();
3714 let path: Decimal = self.bars[start..]
3715 .windows(2)
3716 .map(|w| (w[1].close.value() - w[0].close.value()).abs())
3717 .sum();
3718 if path.is_zero() {
3719 return None;
3720 }
3721 net.checked_div(path)
3722 }
3723
3724 pub fn avg_gap(&self, n: usize) -> Option<Decimal> {
3730 if n == 0 || self.bars.len() < n + 1 {
3731 return None;
3732 }
3733 let start = self.bars.len() - n;
3734 let sum: Decimal = (start..self.bars.len())
3735 .map(|i| (self.bars[i].open.value() - self.bars[i - 1].close.value()).abs())
3736 .sum();
3737 #[allow(clippy::cast_possible_truncation)]
3738 Some(sum / Decimal::from(n as u32))
3739 }
3740
3741 pub fn realized_variance(&self, n: usize) -> Option<f64> {
3747 if n < 2 || self.bars.len() < n + 1 {
3748 return None;
3749 }
3750 let start = self.bars.len() - (n + 1);
3751 let mut rets = Vec::with_capacity(n);
3752 for i in (start + 1)..=(start + n) {
3753 let prev = self.bars[i - 1].close.value();
3754 let curr = self.bars[i].close.value();
3755 use rust_decimal::prelude::ToPrimitive;
3756 let r = prev.to_f64()?;
3757 let c = curr.to_f64()?;
3758 if r <= 0.0 { return None; }
3759 rets.push((c / r).ln());
3760 }
3761 let mean = rets.iter().sum::<f64>() / rets.len() as f64;
3762 let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
3763 Some(var)
3764 }
3765
3766 pub fn close_velocity(&self, n: usize) -> Option<Decimal> {
3772 if n < 2 || self.bars.len() < n {
3773 return None;
3774 }
3775 let start = self.bars.len() - n;
3776 let delta = self.bars.last()?.close.value() - self.bars[start].close.value();
3777 #[allow(clippy::cast_possible_truncation)]
3778 delta.checked_div(Decimal::from(n as u32))
3779 }
3780
3781 pub fn avg_upper_wick(&self, n: usize) -> Option<Decimal> {
3785 if n == 0 || self.bars.len() < n {
3786 return None;
3787 }
3788 let start = self.bars.len() - n;
3789 let sum: Decimal = self.bars[start..]
3790 .iter()
3791 .map(|b| {
3792 let body_top = b.open.value().max(b.close.value());
3793 b.high.value() - body_top
3794 })
3795 .sum();
3796 #[allow(clippy::cast_possible_truncation)]
3797 Some(sum / Decimal::from(n as u32))
3798 }
3799
3800 pub fn median_price(&self, n: usize) -> Option<Decimal> {
3804 if n == 0 || self.bars.len() < n {
3805 return None;
3806 }
3807 let start = self.bars.len() - n;
3808 let mut mids: Vec<Decimal> = self.bars[start..]
3809 .iter()
3810 .map(|b| (b.high.value() + b.low.value()) / Decimal::TWO)
3811 .collect();
3812 mids.sort();
3813 let mid = n / 2;
3814 if n % 2 == 0 {
3815 Some((mids[mid - 1] + mids[mid]) / Decimal::TWO)
3816 } else {
3817 Some(mids[mid])
3818 }
3819 }
3820
3821 pub fn upper_shadow_ratio(&self, n: usize) -> Option<Decimal> {
3826 if n == 0 || self.bars.len() < n {
3827 return None;
3828 }
3829 let start = self.bars.len() - n;
3830 let sum: Decimal = self.bars[start..]
3831 .iter()
3832 .map(|b| {
3833 let range = b.high.value() - b.low.value();
3834 if range.is_zero() {
3835 Decimal::ZERO
3836 } else {
3837 (b.high.value() - b.open.value().max(b.close.value())) / range
3838 }
3839 })
3840 .sum();
3841 #[allow(clippy::cast_possible_truncation)]
3842 Some(sum / Decimal::from(n as u32))
3843 }
3844
3845 pub fn percent_gap_up_bars(&self, n: usize) -> Option<Decimal> {
3849 if n == 0 || self.bars.len() < n + 1 {
3850 return None;
3851 }
3852 let start = self.bars.len() - n;
3853 let count = (start..self.bars.len())
3854 .filter(|&i| self.bars[i].open > self.bars[i - 1].close)
3855 .count();
3856 #[allow(clippy::cast_possible_truncation)]
3857 Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
3858 }
3859
3860 pub fn consecutive_higher_closes(&self, n: usize) -> Option<usize> {
3867 if n < 2 || self.bars.len() < n {
3868 return None;
3869 }
3870 let start = self.bars.len() - n;
3871 let mut max_run = 0usize;
3872 let mut cur_run = 0usize;
3873 for i in (start + 1)..self.bars.len() {
3874 if self.bars[i].close > self.bars[i - 1].close {
3875 cur_run += 1;
3876 if cur_run > max_run { max_run = cur_run; }
3877 } else {
3878 cur_run = 0;
3879 }
3880 }
3881 Some(max_run)
3882 }
3883
3884 pub fn volume_weighted_return(&self, n: usize) -> Option<Decimal> {
3891 if n < 2 || self.bars.len() < n {
3892 return None;
3893 }
3894 let start = self.bars.len() - n;
3895 let mut vol_return_sum = Decimal::ZERO;
3896 let mut vol_sum = Decimal::ZERO;
3897 for i in (start + 1)..self.bars.len() {
3898 let prev_close = self.bars[i - 1].close.value();
3899 if prev_close.is_zero() { continue; }
3900 let ret = (self.bars[i].close.value() - prev_close) / prev_close;
3901 let vol = self.bars[i].volume.value();
3902 vol_return_sum += ret * vol;
3903 vol_sum += vol;
3904 }
3905 if vol_sum.is_zero() {
3906 return None;
3907 }
3908 Some(vol_return_sum / vol_sum)
3909 }
3910
3911 pub fn close_returns(&self, n: usize) -> Option<Vec<Decimal>> {
3916 if n < 2 || self.bars.len() < n {
3917 return None;
3918 }
3919 let start = self.bars.len() - n;
3920 let mut returns = Vec::with_capacity(n - 1);
3921 for i in (start + 1)..self.bars.len() {
3922 let prev = self.bars[i - 1].close.value();
3923 if prev.is_zero() {
3924 returns.push(Decimal::ZERO);
3925 } else {
3926 returns.push((self.bars[i].close.value() - prev) / prev);
3927 }
3928 }
3929 Some(returns)
3930 }
3931
3932 pub fn volatility_regime(&self, atr_period: usize, lookback: usize) -> Option<&'static str> {
3942 if atr_period == 0 || lookback == 0 {
3943 return None;
3944 }
3945 let needed = lookback + atr_period;
3946 if self.bars.len() < needed {
3947 return None;
3948 }
3949 let atr_series = self.atr_series(atr_period);
3950 let recent_atrs: Vec<Decimal> = atr_series
3951 .iter()
3952 .rev()
3953 .take(lookback)
3954 .filter_map(|v| *v)
3955 .collect();
3956 if recent_atrs.is_empty() {
3957 return None;
3958 }
3959 let mean: Decimal = recent_atrs.iter().copied().sum::<Decimal>()
3960 / Decimal::from(recent_atrs.len() as u32);
3961 if mean.is_zero() {
3962 return Some("medium");
3963 }
3964 let latest = *recent_atrs.first()?;
3965 let ratio = latest / mean;
3966 if ratio < Decimal::new(80, 2) {
3967 Some("low")
3968 } else if ratio > Decimal::new(120, 2) {
3969 Some("high")
3970 } else {
3971 Some("medium")
3972 }
3973 }
3974
3975 pub fn up_down_volume_ratio(&self, n: usize) -> Option<Decimal> {
3981 if n == 0 || self.bars.len() < n {
3982 return None;
3983 }
3984 let start = self.bars.len() - n;
3985 let mut up_vol = Decimal::ZERO;
3986 let mut dn_vol = Decimal::ZERO;
3987 for b in &self.bars[start..] {
3988 let vol = b.volume.value();
3989 if b.close > b.open { up_vol += vol; }
3990 else if b.close < b.open { dn_vol += vol; }
3991 }
3992 if dn_vol.is_zero() { return None; }
3993 Some(up_vol / dn_vol)
3994 }
3995
3996 pub fn avg_range_pct(&self, n: usize) -> Option<Decimal> {
4002 if n == 0 || self.bars.len() < n {
4003 return None;
4004 }
4005 let start = self.bars.len() - n;
4006 let mut sum = Decimal::ZERO;
4007 let mut count = 0usize;
4008 let hundred = Decimal::from(100u32);
4009 let three = Decimal::from(3u32);
4010 for b in &self.bars[start..] {
4011 let tp = (b.high.value() + b.low.value() + b.close.value()) / three;
4012 if tp.is_zero() { continue; }
4013 sum += (b.high.value() - b.low.value()) / tp * hundred;
4014 count += 1;
4015 }
4016 if count == 0 { return None; }
4017 Some(sum / Decimal::from(count as u32))
4018 }
4019
4020 pub fn bar_efficiency(&self, n: usize) -> Option<f64> {
4028 use rust_decimal::prelude::ToPrimitive;
4029 if n < 2 || self.bars.len() < n {
4030 return None;
4031 }
4032 let start = self.bars.len() - n;
4033 let net = (self.bars.last().unwrap().close.value()
4034 - self.bars[start].close.value())
4035 .abs()
4036 .to_f64()
4037 .unwrap_or(0.0);
4038 let path: f64 = (start + 1..self.bars.len())
4039 .map(|i| {
4040 (self.bars[i].close.value() - self.bars[i - 1].close.value())
4041 .abs()
4042 .to_f64()
4043 .unwrap_or(0.0)
4044 })
4045 .sum();
4046 if path == 0.0 { return None; }
4047 Some(net / path)
4048 }
4049
4050 pub fn avg_bars_between_highs(&self, n: usize, m: usize) -> Option<f64> {
4056 if n == 0 || m <= n || self.bars.len() < m {
4057 return None;
4058 }
4059 let start = self.bars.len() - m;
4060 let mut high_indices: Vec<usize> = Vec::new();
4061 for i in (start + n)..self.bars.len() {
4062 let prev_max = self.bars[(i - n)..i]
4063 .iter()
4064 .map(|b| b.close.value())
4065 .max()
4066 .unwrap_or(Decimal::ZERO);
4067 if self.bars[i].close.value() > prev_max {
4068 high_indices.push(i);
4069 }
4070 }
4071 if high_indices.len() < 2 { return None; }
4072 let gaps: Vec<usize> = high_indices.windows(2).map(|w| w[1] - w[0]).collect();
4073 Some(gaps.iter().sum::<usize>() as f64 / gaps.len() as f64)
4074 }
4075
4076 pub fn breakout_bars(&self, n: usize) -> Option<usize> {
4084 if n == 0 || self.bars.len() <= n {
4085 return None;
4086 }
4087 let mut streak = 0usize;
4088 for i in (n..self.bars.len()).rev() {
4089 let prior_max = self.bars[(i - n)..i]
4090 .iter()
4091 .map(|b| b.close.value())
4092 .max()
4093 .unwrap_or(Decimal::ZERO);
4094 if self.bars[i].close.value() > prior_max {
4095 streak += 1;
4096 } else {
4097 break;
4098 }
4099 }
4100 Some(streak)
4101 }
4102
4103 pub fn doji_count(&self, n: usize, threshold: f64) -> Option<usize> {
4110 if n == 0 || self.bars.len() < n {
4111 return None;
4112 }
4113 let start = self.bars.len() - n;
4114 use rust_decimal::prelude::ToPrimitive;
4115 let count = self.bars[start..]
4116 .iter()
4117 .filter(|b| {
4118 let range = (b.high.value() - b.low.value()).to_f64().unwrap_or(0.0);
4119 if range == 0.0 {
4120 return true; }
4122 let body = (b.close.value() - b.open.value())
4123 .abs()
4124 .to_f64()
4125 .unwrap_or(0.0);
4126 body / range < threshold
4127 })
4128 .count();
4129 Some(count)
4130 }
4131
4132 pub fn close_dispersion(&self, n: usize) -> Option<f64> {
4136 use rust_decimal::prelude::ToPrimitive;
4137 if n < 2 || self.bars.len() < n {
4138 return None;
4139 }
4140 let start = self.bars.len() - n;
4141 let vals: Vec<f64> = self.bars[start..]
4142 .iter()
4143 .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4144 .collect();
4145 let mean = vals.iter().sum::<f64>() / n as f64;
4146 if mean == 0.0 { return None; }
4147 let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
4148 Some(variance.sqrt() / mean)
4149 }
4150
4151 pub fn relative_volume(&self, n: usize) -> Option<Decimal> {
4155 if n == 0 || self.bars.len() < n {
4156 return None;
4157 }
4158 let start = self.bars.len() - n;
4159 let avg_vol: Decimal = self.bars[start..]
4160 .iter()
4161 .map(|b| b.volume.value())
4162 .sum::<Decimal>()
4163 / Decimal::from(n as u32);
4164 if avg_vol.is_zero() { return None; }
4165 let last_vol = self.bars.last()?.volume.value();
4166 Some(last_vol / avg_vol * Decimal::from(100u32))
4167 }
4168
4169 pub fn avg_oc_midpoint(&self, n: usize) -> Option<Decimal> {
4175 if n == 0 || self.bars.len() < n {
4176 return None;
4177 }
4178 let start = self.bars.len() - n;
4179 let sum: Decimal = self.bars[start..]
4180 .iter()
4181 .map(|b| (b.open.value() + b.close.value()) / Decimal::TWO)
4182 .sum();
4183 Some(sum / Decimal::from(n as u32))
4184 }
4185
4186 pub fn volume_spike_count(&self, n: usize, threshold: Decimal) -> Option<usize> {
4192 if n == 0 || self.bars.len() < n {
4193 return None;
4194 }
4195 let start = self.bars.len() - n;
4196 let avg_vol: Decimal = self.bars[start..]
4197 .iter()
4198 .map(|b| b.volume.value())
4199 .sum::<Decimal>()
4200 / Decimal::from(n as u32);
4201 if avg_vol.is_zero() { return None; }
4202 let limit = avg_vol * threshold;
4203 let count = self.bars[start..].iter().filter(|b| b.volume.value() > limit).count();
4204 Some(count)
4205 }
4206
4207 pub fn close_acceleration(&self, n: usize) -> Option<Decimal> {
4214 if n == 0 || self.bars.len() < n + 2 {
4215 return None;
4216 }
4217 let total = self.bars.len();
4218 let last_mom = self.bars[total - 1].close.value() - self.bars[total - 2].close.value();
4219 let first_idx = total - n - 1;
4220 let first_mom = self.bars[first_idx + 1].close.value() - self.bars[first_idx].close.value();
4221 Some(last_mom - first_mom)
4222 }
4223
4224 pub fn up_down_ratio(&self, n: usize) -> Option<Decimal> {
4230 if n == 0 || self.bars.len() < n {
4231 return None;
4232 }
4233 let start = self.bars.len() - n;
4234 let ups = self.bars[start..].iter().filter(|b| b.close.value() > b.open.value()).count();
4235 let downs = self.bars[start..].iter().filter(|b| b.close.value() < b.open.value()).count();
4236 if downs == 0 {
4237 return None;
4238 }
4239 Some(Decimal::from(ups as u32) / Decimal::from(downs as u32))
4240 }
4241
4242 pub fn consecutive_up_bars(&self, n: usize) -> Option<usize> {
4247 if n == 0 || self.bars.is_empty() {
4248 return None;
4249 }
4250 let window_start = self.bars.len().saturating_sub(n);
4251 let count = self.bars[window_start..]
4252 .iter()
4253 .rev()
4254 .take_while(|b| b.close.value() > b.open.value())
4255 .count();
4256 Some(count)
4257 }
4258
4259 pub fn normalized_close(&self, n: usize) -> Option<f64> {
4265 use rust_decimal::prelude::ToPrimitive;
4266 if n < 2 || self.bars.len() < n {
4267 return None;
4268 }
4269 let start = self.bars.len() - n;
4270 let vals: Vec<f64> = self.bars[start..]
4271 .iter()
4272 .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4273 .collect();
4274 let mean = vals.iter().sum::<f64>() / n as f64;
4275 let std = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64).sqrt();
4276 if std == 0.0 { return None; }
4277 let last = *vals.last()?;
4278 Some((last - mean) / std)
4279 }
4280
4281 pub fn gap_counts(&self, n: usize) -> Option<(usize, usize)> {
4288 if n < 2 || self.bars.len() < n {
4289 return None;
4290 }
4291 let start = self.bars.len() - n;
4292 let mut ups = 0usize;
4293 let mut downs = 0usize;
4294 for i in (start + 1)..self.bars.len() {
4295 let prior_close = self.bars[i - 1].close.value();
4296 let cur_open = self.bars[i].open.value();
4297 if cur_open > prior_close { ups += 1; }
4298 else if cur_open < prior_close { downs += 1; }
4299 }
4300 Some((ups, downs))
4301 }
4302
4303 pub fn consecutive_volume_surge(&self, period: usize, factor: f64) -> Option<usize> {
4310 use rust_decimal::prelude::ToPrimitive;
4311 if period == 0 || factor <= 0.0 || self.bars.len() <= period {
4312 return None;
4313 }
4314 let mut streak = 0usize;
4315 let last = self.bars.len() - 1;
4317 let mut i = last;
4318 loop {
4319 if i < period {
4320 break;
4321 }
4322 let avg_vol: f64 = self.bars[(i - period)..i]
4323 .iter()
4324 .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4325 .sum::<f64>()
4326 / period as f64;
4327 let bar_vol = self.bars[i].volume.value().to_f64().unwrap_or(0.0);
4328 if avg_vol > 0.0 && bar_vol >= avg_vol * factor {
4329 streak += 1;
4330 } else {
4331 break;
4332 }
4333 if i == 0 { break; }
4334 i -= 1;
4335 }
4336 Some(streak)
4337 }
4338
4339 pub fn intrabar_range_expansion(&self, n: usize) -> Option<f64> {
4346 use rust_decimal::prelude::ToPrimitive;
4347 if n == 0 || self.bars.len() < n {
4348 return None;
4349 }
4350 let start = self.bars.len() - n;
4351 let avg_range: f64 = self.bars[start..]
4352 .iter()
4353 .map(|b| (b.high.value() - b.low.value()).to_f64().unwrap_or(0.0))
4354 .sum::<f64>()
4355 / n as f64;
4356 if avg_range == 0.0 {
4357 return None;
4358 }
4359 let current = self.bars.last()?;
4360 let cur_range = (current.high.value() - current.low.value())
4361 .to_f64()
4362 .unwrap_or(0.0);
4363 Some(cur_range / avg_range)
4364 }
4365
4366 pub fn price_range_ratio(&self, n: usize) -> Option<Decimal> {
4372 if n < 2 || self.bars.len() < n {
4373 return None;
4374 }
4375 let start = self.bars.len() - n;
4376 let closes: Vec<Decimal> = self.bars[start..]
4377 .iter()
4378 .map(|b| b.close.value())
4379 .collect();
4380 let hi = closes.iter().copied().max()?;
4381 let lo = closes.iter().copied().min()?;
4382 let avg = closes.iter().sum::<Decimal>() / Decimal::from(n as u32);
4383 if avg.is_zero() { return None; }
4384 Some((hi - lo) / avg)
4385 }
4386
4387 pub fn close_volume_correlation(&self, n: usize) -> Option<f64> {
4391 use rust_decimal::prelude::ToPrimitive;
4392 if n < 2 || self.bars.len() < n {
4393 return None;
4394 }
4395 let start = self.bars.len() - n;
4396 let closes: Vec<f64> = self.bars[start..].iter()
4397 .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4398 .collect();
4399 let vols: Vec<f64> = self.bars[start..].iter()
4400 .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4401 .collect();
4402 let n_f = n as f64;
4403 let mean_c = closes.iter().sum::<f64>() / n_f;
4404 let mean_v = vols.iter().sum::<f64>() / n_f;
4405 let cov: f64 = closes.iter().zip(vols.iter())
4406 .map(|(c, v)| (c - mean_c) * (v - mean_v))
4407 .sum::<f64>() / n_f;
4408 let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / n_f).sqrt();
4409 let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / n_f).sqrt();
4410 if std_c == 0.0 || std_v == 0.0 { return None; }
4411 Some(cov / (std_c * std_v))
4412 }
4413
4414 pub fn close_relative_to_range(&self, n: usize) -> Option<Decimal> {
4420 if n == 0 || self.bars.len() < n {
4421 return None;
4422 }
4423 let start = self.bars.len() - n;
4424 let slice = &self.bars[start..];
4425 let high = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
4426 let low = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
4427 let range = high - low;
4428 if range.is_zero() {
4429 return None;
4430 }
4431 let close = self.bars.last()?.close.value();
4432 Some((close - low) / range)
4433 }
4434
4435 pub fn volume_sma(&self, n: usize) -> Option<Decimal> {
4439 if n == 0 || self.bars.len() < n {
4440 return None;
4441 }
4442 let start = self.bars.len() - n;
4443 #[allow(clippy::cast_possible_truncation)]
4444 let avg = self.bars[start..].iter().map(|b| b.volume.value()).sum::<Decimal>()
4445 / Decimal::from(n as u32);
4446 Some(avg)
4447 }
4448
4449 pub fn compression_ratio(&self, fast: usize, slow: usize) -> Option<Decimal> {
4458 if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow + 1 {
4459 return None;
4460 }
4461 let atr_avg = |n: usize| -> Option<Decimal> {
4462 let start = self.bars.len() - n;
4463 let trs: Decimal = self.bars[start..].iter().enumerate().map(|(i, b)| {
4464 let prev = if i == 0 { &self.bars[start - 1] } else { &self.bars[start + i - 1] };
4465 let hl = b.high.value() - b.low.value();
4466 let hpc = (b.high.value() - prev.close.value()).abs();
4467 let lpc = (b.low.value() - prev.close.value()).abs();
4468 hl.max(hpc).max(lpc)
4469 }).sum();
4470 #[allow(clippy::cast_possible_truncation)]
4471 Some(trs / Decimal::from(n as u32))
4472 };
4473 let atr_fast = atr_avg(fast)?;
4474 let atr_slow = atr_avg(slow)?;
4475 if atr_slow.is_zero() { return None; }
4476 atr_fast.checked_div(atr_slow)
4477 }
4478
4479 pub fn typical_price_avg(&self, n: usize) -> Option<Decimal> {
4483 if n == 0 || self.bars.len() < n {
4484 return None;
4485 }
4486 let start = self.bars.len() - n;
4487 #[allow(clippy::cast_possible_truncation)]
4488 let avg = self.bars[start..]
4489 .iter()
4490 .map(|b| (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3u32))
4491 .sum::<Decimal>()
4492 / Decimal::from(n as u32);
4493 Some(avg)
4494 }
4495
4496 pub fn avg_body_to_range(&self, n: usize) -> Option<Decimal> {
4503 if n == 0 || self.bars.len() < n {
4504 return None;
4505 }
4506 let start = self.bars.len() - n;
4507 let mut sum = Decimal::ZERO;
4508 let mut count = 0u32;
4509 for b in &self.bars[start..] {
4510 let range = b.high.value() - b.low.value();
4511 if range.is_zero() { continue; }
4512 let body = (b.close.value() - b.open.value()).abs();
4513 sum += body
4514 .checked_div(range)
4515 .unwrap_or(Decimal::ZERO);
4516 count += 1;
4517 }
4518 if count == 0 { return None; }
4519 Some(sum / Decimal::from(count))
4520 }
4521
4522 pub fn avg_tick_count(&self, n: usize) -> Option<Decimal> {
4526 if n == 0 || self.bars.len() < n {
4527 return None;
4528 }
4529 let start = self.bars.len() - n;
4530 let sum: u64 = self.bars[start..].iter().map(|b| b.tick_count).sum();
4531 Some(Decimal::from(sum) / Decimal::from(n as u32))
4532 }
4533
4534 pub fn range_compression(&self, n: usize) -> Option<Decimal> {
4543 if n == 0 || self.bars.len() < n {
4544 return None;
4545 }
4546 let start = self.bars.len() - n;
4547 let max_range = self.bars[start..]
4548 .iter()
4549 .map(|b| b.high.value() - b.low.value())
4550 .max()?;
4551 if max_range.is_zero() {
4552 return None;
4553 }
4554 let last = self.bars.last()?;
4555 let last_range = last.high.value() - last.low.value();
4556 last_range.checked_div(max_range)
4557 }
4558
4559 pub fn largest_gap_pct(&self, n: usize) -> Option<Decimal> {
4566 if n < 2 || self.bars.len() < n {
4567 return None;
4568 }
4569 let start = self.bars.len() - n;
4570 let mut max_gap = Decimal::ZERO;
4571 for i in start + 1..self.bars.len() {
4572 let prev_close = self.bars[i - 1].close.value();
4573 if prev_close.is_zero() { return None; }
4574 let gap = (self.bars[i].open.value() - prev_close).abs()
4575 / prev_close
4576 * Decimal::from(100u32);
4577 if gap > max_gap { max_gap = gap; }
4578 }
4579 Some(max_gap)
4580 }
4581
4582 pub fn close_sma_crossover(&self, n: usize) -> Option<i8> {
4589 if n == 0 || self.bars.len() < n + 1 {
4590 return None;
4591 }
4592 let total = self.bars.len();
4593 #[allow(clippy::cast_possible_truncation)]
4594 let sma_now: Decimal = self.bars[total - n..]
4595 .iter()
4596 .map(|b| b.close.value())
4597 .sum::<Decimal>() / Decimal::from(n as u32);
4598 let sma_prev: Decimal = self.bars[total - n - 1..total - 1]
4599 .iter()
4600 .map(|b| b.close.value())
4601 .sum::<Decimal>() / Decimal::from(n as u32);
4602 let close_now = self.bars[total - 1].close.value();
4603 let close_prev = self.bars[total - 2].close.value();
4604 if close_prev <= sma_prev && close_now > sma_now {
4605 Some(1)
4606 } else if close_prev >= sma_prev && close_now < sma_now {
4607 Some(-1)
4608 } else {
4609 Some(0)
4610 }
4611 }
4612
4613 pub fn avg_true_range(&self, n: usize) -> Option<Decimal> {
4619 if n == 0 || self.bars.len() < n + 1 {
4620 return None;
4621 }
4622 let total = self.bars.len();
4623 let start = total - n;
4624 #[allow(clippy::cast_possible_truncation)]
4625 let atr = self.bars[start..].iter().enumerate().map(|(i, b)| {
4626 let prev_close = if i == 0 {
4627 self.bars[start - 1].close.value()
4628 } else {
4629 self.bars[start + i - 1].close.value()
4630 };
4631 let hl = b.high.value() - b.low.value();
4632 let hpc = (b.high.value() - prev_close).abs();
4633 let lpc = (b.low.value() - prev_close).abs();
4634 hl.max(hpc).max(lpc)
4635 }).sum::<Decimal>() / Decimal::from(n as u32);
4636 Some(atr)
4637 }
4638
4639 pub fn max_volume_bar_idx(&self, n: usize) -> Option<usize> {
4643 if n == 0 || self.bars.len() < n {
4644 return None;
4645 }
4646 let start = self.bars.len() - n;
4647 self.bars[start..]
4648 .iter()
4649 .enumerate()
4650 .max_by(|a, b| a.1.volume.value().cmp(&b.1.volume.value()))
4651 .map(|(i, _)| i)
4652 }
4653
4654 pub fn range_pct_of_atr(&self, n: usize) -> Option<Decimal> {
4660 let atr = self.avg_true_range(n)?;
4661 if atr.is_zero() { return None; }
4662 let last = self.bars.last()?;
4663 let range = last.high.value() - last.low.value();
4664 range.checked_div(atr).map(|r| r * Decimal::ONE_HUNDRED)
4665 }
4666
4667 pub fn max_close_drawdown(&self, n: usize) -> Option<Decimal> {
4674 if n == 0 || self.bars.len() < n {
4675 return None;
4676 }
4677 let start = self.bars.len() - n;
4678 let mut peak = self.bars[start].close.value();
4679 let mut max_dd = Decimal::ZERO;
4680 for b in &self.bars[start..] {
4681 let c = b.close.value();
4682 if c > peak { peak = c; }
4683 if !peak.is_zero() {
4684 let dd = (c - peak) / peak * Decimal::ONE_HUNDRED;
4685 if dd < max_dd { max_dd = dd; }
4686 }
4687 }
4688 Some(max_dd)
4689 }
4690
4691 pub fn close_above_sma_pct(&self, n: usize, sma_period: usize) -> Option<Decimal> {
4698 if n == 0 || sma_period == 0 || self.bars.len() < n + sma_period - 1 {
4699 return None;
4700 }
4701 let window_start = self.bars.len() - n;
4702 let mut above = 0u32;
4703 for (offset, b) in self.bars[window_start..].iter().enumerate() {
4704 let abs_idx = window_start + offset;
4705 if abs_idx + 1 < sma_period { continue; }
4706 let sma_start = abs_idx + 1 - sma_period;
4707 let sma = self.bars[sma_start..=abs_idx]
4708 .iter()
4709 .map(|x| x.close.value())
4710 .sum::<Decimal>()
4711 / Decimal::from(sma_period as u32);
4712 if b.close.value() > sma { above += 1; }
4713 }
4714 Some(Decimal::from(above) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
4715 }
4716
4717 pub fn swing_high_count(&self, n: usize, lookback: usize) -> Option<usize> {
4724 if n == 0 || lookback == 0 || self.bars.len() < n { return None; }
4725 let start = self.bars.len() - n;
4726 let slice = &self.bars[start..];
4727 let len = slice.len();
4728 let mut count = 0usize;
4729 for i in lookback..len.saturating_sub(lookback) {
4730 let peak = slice[i].high.value();
4731 let is_high = (0..lookback).all(|k| peak > slice[i - 1 - k].high.value())
4732 && (0..lookback).all(|k| peak > slice[i + 1 + k].high.value());
4733 if is_high { count += 1; }
4734 }
4735 Some(count)
4736 }
4737
4738 pub fn open_gap_pct(&self, n: usize) -> Option<Decimal> {
4745 if n < 2 || self.bars.len() < n {
4746 return None;
4747 }
4748 let start = self.bars.len() - n;
4749 let mut sum = Decimal::ZERO;
4750 for i in start..self.bars.len() {
4751 let prev_close = self.bars[i - 1].close.value();
4752 if prev_close.is_zero() { return None; }
4753 let gap = (self.bars[i].open.value() - prev_close).abs();
4754 sum += gap / prev_close * Decimal::ONE_HUNDRED;
4755 }
4756 Some(sum / Decimal::from((n - 1) as u32))
4757 }
4758
4759 pub fn volume_trend_ratio(&self, n: usize) -> Option<Decimal> {
4765 if n == 0 || self.bars.len() < n {
4766 return None;
4767 }
4768 let start = self.bars.len() - n;
4769 let mut up_sum = Decimal::ZERO;
4770 let mut up_count = 0u32;
4771 let mut down_sum = Decimal::ZERO;
4772 let mut down_count = 0u32;
4773 for b in &self.bars[start..] {
4774 let v = b.volume.value();
4775 if b.close.value() > b.open.value() {
4776 up_sum += v;
4777 up_count += 1;
4778 } else if b.close.value() < b.open.value() {
4779 down_sum += v;
4780 down_count += 1;
4781 }
4782 }
4783 if up_count == 0 || down_count == 0 { return None; }
4784 let avg_up = up_sum / Decimal::from(up_count);
4785 let avg_down = down_sum / Decimal::from(down_count);
4786 if avg_down.is_zero() { return None; }
4787 avg_up.checked_div(avg_down)
4788 }
4789
4790 pub fn avg_wick_pct(&self, n: usize) -> Option<Decimal> {
4797 if n == 0 || self.bars.len() < n { return None; }
4798 let start = self.bars.len() - n;
4799 let mut sum = Decimal::ZERO;
4800 for b in &self.bars[start..] {
4801 let range = b.high.value() - b.low.value();
4802 if range.is_zero() { return None; }
4803 let upper_wick = b.high.value() - b.close.value().max(b.open.value());
4804 let lower_wick = b.close.value().min(b.open.value()) - b.low.value();
4805 sum += (upper_wick + lower_wick) / range * Decimal::from(100u32);
4806 }
4807 #[allow(clippy::cast_possible_truncation)]
4808 Some(sum / Decimal::from(n as u32))
4809 }
4810
4811 pub fn trend_continuation_pct(&self, n: usize) -> Option<Decimal> {
4817 if n == 0 || self.bars.len() < n + 1 { return None; }
4818 let start = self.bars.len() - n - 1;
4819 let mut continuing = 0u32;
4820 for i in 0..n {
4821 let prev_dir = self.bars[start + i].close.value()
4822 .cmp(&self.bars[start + i].open.value());
4823 let curr_dir = self.bars[start + i + 1].close.value()
4824 .cmp(&self.bars[start + i + 1].open.value());
4825 if prev_dir == curr_dir && prev_dir != std::cmp::Ordering::Equal {
4826 continuing += 1;
4827 }
4828 }
4829 #[allow(clippy::cast_possible_truncation)]
4830 Some(Decimal::from(continuing) / Decimal::from(n as u32) * Decimal::from(100u32))
4831 }
4832
4833 pub fn inside_bar_count(&self, n: usize) -> Option<usize> {
4835 if n == 0 || self.bars.len() < n { return None; }
4836 let start = self.bars.len() - n;
4837 let mut count = 0usize;
4838 for i in start..self.bars.len() {
4839 if i == 0 { continue; }
4840 let prev = &self.bars[i - 1];
4841 let cur = &self.bars[i];
4842 if cur.high <= prev.high && cur.low >= prev.low { count += 1; }
4843 }
4844 Some(count)
4845 }
4846
4847 pub fn outside_bar_count(&self, n: usize) -> Option<usize> {
4849 if n == 0 || self.bars.len() < n { return None; }
4850 let start = self.bars.len() - n;
4851 let mut count = 0usize;
4852 for i in start..self.bars.len() {
4853 if i == 0 { continue; }
4854 let prev = &self.bars[i - 1];
4855 let cur = &self.bars[i];
4856 if cur.high > prev.high && cur.low < prev.low { count += 1; }
4857 }
4858 Some(count)
4859 }
4860
4861 pub fn high_volume_price(&self, n: usize) -> Option<Decimal> {
4865 if n == 0 || self.bars.len() < n { return None; }
4866 let start = self.bars.len() - n;
4867 self.bars[start..].iter()
4868 .max_by_key(|b| b.volume.value())
4869 .map(|b| b.close.value())
4870 }
4871
4872 pub fn avg_close_minus_open(&self, n: usize) -> Option<Decimal> {
4878 if n == 0 || self.bars.len() < n { return None; }
4879 let start = self.bars.len() - n;
4880 let sum: Decimal = self.bars[start..]
4881 .iter()
4882 .map(|b| b.close.value() - b.open.value())
4883 .sum();
4884 #[allow(clippy::cast_possible_truncation)]
4885 Some(sum / Decimal::from(n as u32))
4886 }
4887
4888 pub fn avg_upper_shadow_pct(&self, n: usize) -> Option<Decimal> {
4895 if n == 0 || self.bars.len() < n { return None; }
4896 let start = self.bars.len() - n;
4897 let sum: Decimal = self.bars[start..].iter().map(|b| {
4898 let body_top = b.open.value().max(b.close.value());
4899 let shadow = b.high.value() - body_top;
4900 let close = b.close.value();
4901 if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4902 }).sum();
4903 #[allow(clippy::cast_possible_truncation)]
4904 Some(sum / Decimal::from(n as u32))
4905 }
4906
4907 pub fn avg_lower_shadow_pct(&self, n: usize) -> Option<Decimal> {
4914 if n == 0 || self.bars.len() < n { return None; }
4915 let start = self.bars.len() - n;
4916 let sum: Decimal = self.bars[start..].iter().map(|b| {
4917 let body_bottom = b.open.value().min(b.close.value());
4918 let shadow = body_bottom - b.low.value();
4919 let close = b.close.value();
4920 if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4921 }).sum();
4922 #[allow(clippy::cast_possible_truncation)]
4923 Some(sum / Decimal::from(n as u32))
4924 }
4925
4926 pub fn percent_doji(&self, n: usize) -> Option<Decimal> {
4932 if n == 0 || self.bars.len() < n { return None; }
4933 let start = self.bars.len() - n;
4934 let threshold = rust_decimal_macros::dec!(0.1);
4935 let mut doji_count = 0u32;
4936 for b in &self.bars[start..] {
4937 let range = b.high.value() - b.low.value();
4938 let body = (b.close.value() - b.open.value()).abs();
4939 if range.is_zero() || body / range < threshold {
4940 doji_count += 1;
4941 }
4942 }
4943 #[allow(clippy::cast_possible_truncation)]
4944 Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::from(100u32))
4945 }
4946
4947 pub fn avg_close_range_pct(&self, n: usize) -> Option<Decimal> {
4954 if n == 0 || self.bars.len() < n { return None; }
4955 let start = self.bars.len() - n;
4956 let mut sum = Decimal::ZERO;
4957 for b in &self.bars[start..] {
4958 let range = b.high.value() - b.low.value();
4959 if range.is_zero() { return None; }
4960 sum += (b.close.value() - b.low.value()) / range * Decimal::from(100u32);
4961 }
4962 #[allow(clippy::cast_possible_truncation)]
4963 Some(sum / Decimal::from(n as u32))
4964 }
4965
4966 pub fn price_channel_width(&self, n: usize) -> Option<Decimal> {
4972 if n == 0 || self.bars.len() < n { return None; }
4973 let start = self.bars.len() - n;
4974 let slice = &self.bars[start..];
4975 let max_high = slice.iter().map(|b| b.high.value()).max()?;
4976 let min_low = slice.iter().map(|b| b.low.value()).min()?;
4977 if min_low.is_zero() { return None; }
4978 Some((max_high - min_low) / min_low * Decimal::ONE_HUNDRED)
4979 }
4980
4981 pub fn avg_candle_efficiency(&self, n: usize) -> Option<Decimal> {
4987 if n == 0 || self.bars.len() < n { return None; }
4988 let start = self.bars.len() - n;
4989 let mut sum = Decimal::ZERO;
4990 for b in &self.bars[start..] {
4991 let range = b.high.value() - b.low.value();
4992 if range.is_zero() { return None; }
4993 sum += (b.close.value() - b.open.value()).abs() / range;
4994 }
4995 #[allow(clippy::cast_possible_truncation)]
4996 Some(sum / Decimal::from(n as u32))
4997 }
4998
4999 pub fn volume_at_high(&self, n: usize) -> Option<Decimal> {
5004 if n == 0 || self.bars.len() < n { return None; }
5005 let start = self.bars.len() - n;
5006 let slice = &self.bars[start..];
5007 let mut running_high = slice[0].high.value();
5008 let mut total = slice[0].volume.value();
5009 for b in &slice[1..] {
5010 if b.high.value() > running_high {
5011 running_high = b.high.value();
5012 total += b.volume.value();
5013 }
5014 }
5015 Some(total)
5016 }
5017
5018 pub fn close_momentum_consistency(&self, n: usize) -> Option<Decimal> {
5022 if n == 0 || self.bars.len() < n + 1 { return None; }
5023 let start = self.bars.len() - n - 1;
5024 let mut up = 0u32;
5025 for i in 0..n {
5026 if self.bars[start + i + 1].close > self.bars[start + i].close {
5027 up += 1;
5028 }
5029 }
5030 #[allow(clippy::cast_possible_truncation)]
5031 Some(Decimal::from(up) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5032 }
5033
5034 pub fn price_gap_pct(&self) -> Option<Decimal> {
5038 let n = self.bars.len();
5039 if n < 2 { return None; }
5040 let prev_close = self.bars[n - 2].close.value();
5041 if prev_close.is_zero() { return None; }
5042 Some((self.bars[n - 1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
5043 }
5044
5045 pub fn longest_winning_streak(&self) -> usize {
5049 if self.bars.len() < 2 { return 0; }
5050 let mut max_streak = 0usize;
5051 let mut streak = 0usize;
5052 for i in 1..self.bars.len() {
5053 if self.bars[i].close > self.bars[i - 1].close {
5054 streak += 1;
5055 if streak > max_streak { max_streak = streak; }
5056 } else {
5057 streak = 0;
5058 }
5059 }
5060 max_streak
5061 }
5062
5063 pub fn avg_gap_pct(&self, n: usize) -> Option<Decimal> {
5068 if n == 0 || self.bars.len() < n + 1 { return None; }
5069 let start = self.bars.len() - n;
5070 let mut sum = Decimal::ZERO;
5071 for i in start..self.bars.len() {
5072 let prev_close = self.bars[i - 1].close.value();
5073 if prev_close.is_zero() { continue; }
5074 sum += (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5075 }
5076 #[allow(clippy::cast_possible_truncation)]
5077 Some(sum / Decimal::from(n as u32))
5078 }
5079
5080 pub fn intrabar_momentum(&self, n: usize) -> Option<Decimal> {
5085 if n == 0 || self.bars.len() < n { return None; }
5086 let start = self.bars.len() - n;
5087 let mut sum = Decimal::ZERO;
5088 let mut count = 0u32;
5089 for bar in &self.bars[start..] {
5090 let range = bar.high.value() - bar.low.value();
5091 if range.is_zero() { continue; }
5092 sum += (bar.close.value() - bar.open.value()) / range;
5093 count += 1;
5094 }
5095 if count == 0 { return None; }
5096 Some(sum / Decimal::from(count))
5097 }
5098
5099 pub fn volume_per_bar(&self, n: usize) -> Option<Decimal> {
5103 if n == 0 || self.bars.len() < n { return None; }
5104 let start = self.bars.len() - n;
5105 let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
5106 #[allow(clippy::cast_possible_truncation)]
5107 Some(sum / Decimal::from(n as u32))
5108 }
5109
5110 pub fn pct_bars_near_high(&self, n: usize, threshold_pct: Decimal) -> Option<Decimal> {
5115 if n == 0 || self.bars.len() < n { return None; }
5116 let start = self.bars.len() - n;
5117 let mut near = 0u32;
5118 for bar in &self.bars[start..] {
5119 let high = bar.high.value();
5120 if high.is_zero() { continue; }
5121 let dist_pct = (high - bar.close.value()) / high * Decimal::ONE_HUNDRED;
5122 if dist_pct <= threshold_pct {
5123 near += 1;
5124 }
5125 }
5126 #[allow(clippy::cast_possible_truncation)]
5127 Some(Decimal::from(near) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5128 }
5129
5130 pub fn avg_body_pct(&self, n: usize) -> Option<Decimal> {
5135 if n == 0 || self.bars.len() < n { return None; }
5136 let start = self.bars.len() - n;
5137 let mut sum = Decimal::ZERO;
5138 let mut count = 0u32;
5139 for bar in &self.bars[start..] {
5140 let range = bar.high.value() - bar.low.value();
5141 if range.is_zero() { continue; }
5142 sum += (bar.close.value() - bar.open.value()).abs() / range * Decimal::ONE_HUNDRED;
5143 count += 1;
5144 }
5145 if count == 0 { return None; }
5146 Some(sum / Decimal::from(count))
5147 }
5148
5149 pub fn tail_ratio(&self, n: usize) -> Option<Decimal> {
5155 if n == 0 || self.bars.len() < n { return None; }
5156 let start = self.bars.len() - n;
5157 let mut sum = Decimal::ZERO;
5158 let mut count = 0u32;
5159 for bar in &self.bars[start..] {
5160 let body_top = bar.open.value().max(bar.close.value());
5161 let body_bot = bar.open.value().min(bar.close.value());
5162 let upper = bar.high.value() - body_top;
5163 let lower = body_bot - bar.low.value();
5164 if lower.is_zero() { continue; }
5165 sum += upper / lower;
5166 count += 1;
5167 }
5168 if count == 0 { return None; }
5169 Some(sum / Decimal::from(count))
5170 }
5171
5172 pub fn avg_volume_ratio(&self, n: usize, m: usize) -> Option<Decimal> {
5177 let len = self.bars.len();
5178 if n == 0 || m == 0 || len < n.max(m) { return None; }
5179 #[allow(clippy::cast_possible_truncation)]
5180 let avg_n: Decimal = self.bars[len - n..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5181 / Decimal::from(n as u32);
5182 #[allow(clippy::cast_possible_truncation)]
5183 let avg_m: Decimal = self.bars[len - m..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5184 / Decimal::from(m as u32);
5185 if avg_m.is_zero() { return None; }
5186 Some(avg_n / avg_m)
5187 }
5188
5189 pub fn open_close_correlation(&self, n: usize) -> Option<f64> {
5193 use rust_decimal::prelude::ToPrimitive;
5194 if n < 2 || self.bars.len() < n { return None; }
5195 let start = self.bars.len() - n;
5196 let opens: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.open.value().to_f64()).collect();
5197 let closes: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.close.value().to_f64()).collect();
5198 if opens.len() < 2 { return None; }
5199 let nf = opens.len() as f64;
5200 let mean_o = opens.iter().sum::<f64>() / nf;
5201 let mean_c = closes.iter().sum::<f64>() / nf;
5202 let cov: f64 = opens.iter().zip(closes.iter()).map(|(o, c)| (o - mean_o) * (c - mean_c)).sum::<f64>() / nf;
5203 let std_o = (opens.iter().map(|o| (o - mean_o).powi(2)).sum::<f64>() / nf).sqrt();
5204 let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
5205 if std_o == 0.0 || std_c == 0.0 { return None; }
5206 Some(cov / (std_o * std_c))
5207 }
5208
5209 pub fn price_acceleration(&self, n: usize) -> Option<Decimal> {
5215 if n < 4 || self.bars.len() < n + 1 { return None; }
5216 let start = self.bars.len() - n - 1;
5217 let half = n / 2;
5218 let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5219 .map(|i| self.bars[i + 1].close.value() - self.bars[i].close.value())
5220 .collect();
5221 #[allow(clippy::cast_possible_truncation)]
5222 let avg_first = changes[..half].iter().sum::<Decimal>() / Decimal::from(half as u32);
5223 #[allow(clippy::cast_possible_truncation)]
5224 let avg_second = changes[half..].iter().sum::<Decimal>() / Decimal::from((changes.len() - half) as u32);
5225 Some(avg_second - avg_first)
5226 }
5227
5228 pub fn returns_skewness(&self, n: usize) -> Option<f64> {
5233 use rust_decimal::prelude::ToPrimitive;
5234 if n == 0 || self.bars.len() < n + 1 { return None; }
5235 let start = self.bars.len() - n - 1;
5236 let returns: Vec<f64> = (start..self.bars.len() - 1)
5237 .filter_map(|i| {
5238 let prev = self.bars[i].close.value().to_f64()?;
5239 let curr = self.bars[i + 1].close.value().to_f64()?;
5240 if prev == 0.0 { return None; }
5241 Some((curr / prev).ln())
5242 })
5243 .collect();
5244 if returns.len() < 3 { return None; }
5245 let m = returns.len() as f64;
5246 let mean = returns.iter().sum::<f64>() / m;
5247 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m;
5248 let std = variance.sqrt();
5249 if std == 0.0 { return None; }
5250 Some(returns.iter().map(|r| ((r - mean) / std).powi(3)).sum::<f64>() / m)
5251 }
5252
5253 pub fn volume_zscore(&self, n: usize) -> Option<f64> {
5257 use rust_decimal::prelude::ToPrimitive;
5258 if n < 2 || self.bars.len() < n { return None; }
5259 let start = self.bars.len() - n;
5260 let vols: Vec<f64> = self.bars[start..].iter()
5261 .filter_map(|b| b.volume.value().to_f64())
5262 .collect();
5263 if vols.len() < 2 { return None; }
5264 let m = vols.len() as f64;
5265 let mean = vols.iter().sum::<f64>() / m;
5266 let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
5267 let std = variance.sqrt();
5268 if std == 0.0 { return None; }
5269 let last_vol = self.bars.last()?.volume.value().to_f64()?;
5270 Some((last_vol - mean) / std)
5271 }
5272
5273 pub fn upper_lower_shadow_ratio(&self) -> Option<Decimal> {
5279 let bar = self.bars.last()?;
5280 let body_top = bar.open.value().max(bar.close.value());
5281 let body_bot = bar.open.value().min(bar.close.value());
5282 let upper = bar.high.value() - body_top;
5283 let lower = body_bot - bar.low.value();
5284 if lower.is_zero() { return None; }
5285 Some(upper / lower)
5286 }
5287
5288 fn sma(&self, n: usize) -> Option<Decimal> {
5290 if n == 0 || self.bars.len() < n { return None; }
5291 let start = self.bars.len() - n;
5292 let sum: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
5293 Some(sum / Decimal::from(n as u32))
5294 }
5295
5296 fn atr(&self, n: usize) -> Option<Decimal> {
5298 if n == 0 || self.bars.len() < n + 1 { return None; }
5299 let start = self.bars.len() - n - 1;
5300 let mut sum = Decimal::ZERO;
5301 for i in start..self.bars.len() - 1 {
5302 let pc = self.bars[i].close.value();
5303 let h = self.bars[i + 1].high.value();
5304 let l = self.bars[i + 1].low.value();
5305 let tr = (h - l).max((h - pc).abs()).max((l - pc).abs());
5306 sum += tr;
5307 }
5308 Some(sum / Decimal::from(n as u32))
5309 }
5310
5311 fn ema(&self, n: usize) -> Option<Decimal> {
5313 if n == 0 || self.bars.len() < n { return None; }
5314 let start = self.bars.len() - n;
5315 let seed: Decimal = self.bars[start..start + n.min(self.bars.len() - start)]
5316 .iter().map(|b| b.close.value()).sum::<Decimal>()
5317 / Decimal::from(n as u32);
5318 let k = Decimal::from(2u32) / Decimal::from((n + 1) as u32);
5319 let mut e = seed;
5320 for bar in &self.bars[start + n..] {
5322 e = e * (Decimal::ONE - k) + bar.close.value() * k;
5323 }
5324 Some(e)
5325 }
5326
5327 pub fn mean_reversion_score(&self, n: usize) -> Option<Decimal> {
5332 let close = self.bars.last()?.close.value();
5333 let sma = self.sma(n)?;
5334 let atr = self.atr(n)?;
5335 if atr.is_zero() { return None; }
5336 Some((close - sma).abs() / atr)
5337 }
5338
5339 pub fn volume_price_trend(&self, n: usize) -> Option<Decimal> {
5344 if n == 0 || self.bars.len() < n + 1 { return None; }
5345 let start = self.bars.len() - n - 1;
5346 let mut vpt = Decimal::ZERO;
5347 for i in start..self.bars.len() - 1 {
5348 let prev_close = self.bars[i].close.value();
5349 if prev_close.is_zero() { continue; }
5350 let pct_chg = (self.bars[i + 1].close.value() - prev_close) / prev_close;
5351 vpt += pct_chg * self.bars[i + 1].volume.value();
5352 }
5353 Some(vpt)
5354 }
5355
5356 pub fn bear_run_length(&self) -> usize {
5360 let n = self.bars.len();
5361 if n < 2 { return 0; }
5362 let mut count = 0;
5363 let mut i = n - 1;
5364 while i > 0 && self.bars[i].close.value() < self.bars[i - 1].close.value() {
5365 count += 1;
5366 i -= 1;
5367 }
5368 count
5369 }
5370
5371 pub fn avg_true_range_pct(&self, n: usize) -> Option<Decimal> {
5376 let atr = self.atr(n)?;
5377 let close = self.bars.last()?.close.value();
5378 if close.is_zero() { return None; }
5379 Some(atr / close * Decimal::ONE_HUNDRED)
5380 }
5381
5382 pub fn close_vs_ema(&self, n: usize) -> Option<Decimal> {
5387 let ema = self.ema(n)?;
5388 let close = self.bars.last()?.close.value();
5389 if close.is_zero() { return None; }
5390 Some((close - ema) / close * Decimal::ONE_HUNDRED)
5391 }
5392
5393 pub fn volume_momentum(&self, n: usize) -> Option<Decimal> {
5398 if n < 2 || self.bars.len() < n { return None; }
5399 let start = self.bars.len() - n;
5400 let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5401 .map(|i| self.bars[i + 1].volume.value() - self.bars[i].volume.value())
5402 .collect();
5403 if changes.is_empty() { return None; }
5404 #[allow(clippy::cast_possible_truncation)]
5405 Some(changes.iter().sum::<Decimal>() / Decimal::from(changes.len() as u32))
5406 }
5407
5408 pub fn max_volume_bar(&self, n: usize) -> Option<usize> {
5413 if n == 0 || self.bars.len() < n { return None; }
5414 let start = self.bars.len() - n;
5415 let (rel_idx, _) = self.bars[start..]
5416 .iter()
5417 .enumerate()
5418 .max_by_key(|(_, b)| b.volume.value())?;
5419 Some(n - 1 - rel_idx)
5420 }
5421
5422 pub fn gap_count(&self, n: usize, min_pct: Decimal) -> Option<usize> {
5427 if n == 0 || self.bars.len() < n + 1 { return None; }
5428 let start = self.bars.len() - n;
5429 let count = (start..self.bars.len()).filter(|&i| {
5430 let prev_close = self.bars[i - 1].close.value();
5431 if prev_close.is_zero() { return false; }
5432 let gap = (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5433 gap >= min_pct
5434 }).count();
5435 Some(count)
5436 }
5437
5438 pub fn avg_close_pct_change(&self, n: usize) -> Option<Decimal> {
5443 if n == 0 || self.bars.len() < n + 1 { return None; }
5444 let start = self.bars.len() - n - 1;
5445 let mut sum = Decimal::ZERO;
5446 for i in start..self.bars.len() - 1 {
5447 let prev = self.bars[i].close.value();
5448 if prev.is_zero() { return None; }
5449 sum += (self.bars[i + 1].close.value() - prev) / prev * Decimal::ONE_HUNDRED;
5450 }
5451 #[allow(clippy::cast_possible_truncation)]
5452 Some(sum / Decimal::from(n as u32))
5453 }
5454
5455 pub fn bollinger_width(&self, n: usize, multiplier: Decimal) -> Option<Decimal> {
5460 let sma = self.sma(n)?;
5461 if sma.is_zero() { return None; }
5462 let std = self.std_dev(n)?;
5463 let upper = sma + multiplier * std;
5464 let lower = sma - multiplier * std;
5465 Some((upper - lower) / sma)
5466 }
5467
5468 pub fn close_above_ma_streak(&self, period: usize) -> usize {
5473 if self.bars.len() < period { return 0; }
5474 let mut streak = 0usize;
5475 for i in (period - 1..self.bars.len()).rev() {
5477 let sum: Decimal = (0..period).map(|j| self.bars[i + 1 - period + j].close.value()).sum();
5478 #[allow(clippy::cast_possible_truncation)]
5479 let sma = sum / Decimal::from(period as u32);
5480 if self.bars[i].close.value() >= sma {
5481 streak += 1;
5482 } else {
5483 break;
5484 }
5485 }
5486 streak
5487 }
5488
5489 pub fn avg_body_to_range_ratio(&self, n: usize) -> Option<Decimal> {
5493 if n == 0 || self.bars.len() < n { return None; }
5494 let start = self.bars.len() - n;
5495 let mut sum = Decimal::ZERO;
5496 let mut count = 0u32;
5497 for bar in &self.bars[start..] {
5498 let range = bar.high.value() - bar.low.value();
5499 if range.is_zero() { continue; }
5500 sum += (bar.close.value() - bar.open.value()).abs() / range;
5501 count += 1;
5502 }
5503 if count == 0 { return None; }
5504 Some(sum / Decimal::from(count))
5505 }
5506
5507 pub fn net_volume(&self, n: usize) -> Option<Decimal> {
5513 if n == 0 || self.bars.len() < n { return None; }
5514 let start = self.bars.len() - n;
5515 let mut net = Decimal::ZERO;
5516 for bar in &self.bars[start..] {
5517 let range = bar.high.value() - bar.low.value();
5518 let vol = bar.volume.value();
5519 if range.is_zero() { continue; }
5520 let buy_frac = (bar.close.value() - bar.low.value()) / range;
5521 let buy_vol = vol * buy_frac;
5522 let sell_vol = vol - buy_vol;
5523 net += buy_vol - sell_vol;
5524 }
5525 Some(net)
5526 }
5527
5528 pub fn avg_high_minus_open(&self, n: usize) -> Option<Decimal> {
5533 if n == 0 || self.bars.len() < n { return None; }
5534 let start = self.bars.len() - n;
5535 #[allow(clippy::cast_possible_truncation)]
5536 let sum: Decimal = self.bars[start..].iter()
5537 .map(|b| b.high.value() - b.open.value())
5538 .sum();
5539 Some(sum / Decimal::from(n as u32))
5540 }
5541
5542 pub fn close_consistency(&self, n: usize) -> Option<Decimal> {
5547 if n == 0 || self.bars.len() < n { return None; }
5548 let start = self.bars.len() - n;
5549 let upper = self.bars[start..].iter().filter(|b| {
5550 let mid = (b.high.value() + b.low.value()) / Decimal::TWO;
5551 b.close.value() >= mid
5552 }).count();
5553 #[allow(clippy::cast_possible_truncation)]
5554 Some(Decimal::from(upper as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5555 }
5556
5557 pub fn momentum_divergence(&self, fast: usize, slow: usize) -> Option<Decimal> {
5563 if fast == 0 || slow == 0 || fast >= slow { return None; }
5564 if self.bars.len() <= slow { return None; }
5565 let n = self.bars.len();
5566 let current = self.bars[n - 1].close.value();
5567 let fast_prev = self.bars[n - 1 - fast].close.value();
5568 let slow_prev = self.bars[n - 1 - slow].close.value();
5569 Some((current - fast_prev) - (current - slow_prev))
5570 }
5571
5572 pub fn price_range_pct(&self, n: usize) -> Option<Decimal> {
5576 if n == 0 || self.bars.len() < n { return None; }
5577 let start = self.bars.len() - n;
5578 let high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5579 let low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5580 if low.is_zero() { return None; }
5581 Some((high - low) / low * Decimal::ONE_HUNDRED)
5582 }
5583
5584 pub fn avg_open_to_close(&self, n: usize) -> Option<Decimal> {
5589 if n == 0 || self.bars.len() < n { return None; }
5590 let start = self.bars.len() - n;
5591 #[allow(clippy::cast_possible_truncation)]
5592 let sum: Decimal = self.bars[start..].iter()
5593 .map(|b| b.close.value() - b.open.value())
5594 .sum();
5595 Some(sum / Decimal::from(n as u32))
5596 }
5597
5598 pub fn price_range_expansion(&self, n: usize) -> Option<Decimal> {
5603 if n == 0 || self.bars.len() < 2 * n { return None; }
5604 let len = self.bars.len();
5605 #[allow(clippy::cast_possible_truncation)]
5606 let n_dec = Decimal::from(n as u32);
5607 let recent_sum: Decimal = self.bars[len - n..].iter()
5608 .map(|b| b.high.value() - b.low.value())
5609 .sum();
5610 let prior_sum: Decimal = self.bars[len - 2 * n..len - n].iter()
5611 .map(|b| b.high.value() - b.low.value())
5612 .sum();
5613 Some((recent_sum - prior_sum) / n_dec)
5614 }
5615
5616 pub fn up_volume_fraction(&self, n: usize) -> Option<Decimal> {
5620 if n == 0 || self.bars.len() < n { return None; }
5621 let start = self.bars.len() - n;
5622 let mut up_vol = Decimal::ZERO;
5623 let mut total_vol = Decimal::ZERO;
5624 for bar in &self.bars[start..] {
5625 let v = bar.volume.value();
5626 total_vol += v;
5627 if bar.close.value() > bar.open.value() {
5628 up_vol += v;
5629 }
5630 }
5631 if total_vol.is_zero() { return None; }
5632 Some(up_vol / total_vol)
5633 }
5634
5635 pub fn std_volume(&self, n: usize) -> Option<f64> {
5639 use rust_decimal::prelude::ToPrimitive;
5640 if n < 2 || self.bars.len() < n { return None; }
5641 let start = self.bars.len() - n;
5642 let vols: Vec<f64> = self.bars[start..].iter()
5643 .filter_map(|b| b.volume.value().to_f64())
5644 .collect();
5645 if vols.len() < 2 { return None; }
5646 let nf = vols.len() as f64;
5647 let mean = vols.iter().sum::<f64>() / nf;
5648 let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0);
5649 Some(var.sqrt())
5650 }
5651
5652 pub fn longest_losing_streak(&self) -> usize {
5656 if self.bars.len() < 2 { return 0; }
5657 let mut max_streak = 0usize;
5658 let mut current = 0usize;
5659 for i in 1..self.bars.len() {
5660 if self.bars[i].close.value() < self.bars[i - 1].close.value() {
5661 current += 1;
5662 if current > max_streak { max_streak = current; }
5663 } else {
5664 current = 0;
5665 }
5666 }
5667 max_streak
5668 }
5669
5670 pub fn recent_max_close(&self, n: usize) -> Option<Decimal> {
5674 if n == 0 || self.bars.len() < n { return None; }
5675 let start = self.bars.len() - n;
5676 self.bars[start..].iter().map(|b| b.close.value()).max()
5677 }
5678
5679 pub fn recent_min_close(&self, n: usize) -> Option<Decimal> {
5683 if n == 0 || self.bars.len() < n { return None; }
5684 let start = self.bars.len() - n;
5685 self.bars[start..].iter().map(|b| b.close.value()).min()
5686 }
5687
5688 pub fn chaikin_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
5693 if fast == 0 || slow == 0 || fast >= slow || self.bars.len() <= slow { return None; }
5694 let n = self.bars.len();
5695 let alpha_fast = Decimal::TWO / Decimal::from(fast + 1);
5698 let alpha_slow = Decimal::TWO / Decimal::from(slow + 1);
5699 let start = n - slow;
5700 let mut ema_fast = self.bars[start].close.value() * self.bars[start].volume.value();
5701 let mut ema_slow = ema_fast;
5702 for bar in &self.bars[start + 1..] {
5703 let adv = bar.close.value() * bar.volume.value();
5704 ema_fast = alpha_fast * adv + (Decimal::ONE - alpha_fast) * ema_fast;
5705 ema_slow = alpha_slow * adv + (Decimal::ONE - alpha_slow) * ema_slow;
5706 }
5707 Some(ema_fast - ema_slow)
5708 }
5709
5710 pub fn candle_body_trend(&self, n: usize) -> Option<i64> {
5716 if n == 0 || self.bars.len() < n { return None; }
5717 let start = self.bars.len() - n;
5718 let bull = self.bars[start..].iter()
5719 .filter(|b| b.close.value() > b.open.value()).count() as i64;
5720 let bear = self.bars[start..].iter()
5721 .filter(|b| b.close.value() < b.open.value()).count() as i64;
5722 Some(bull - bear)
5723 }
5724
5725 pub fn pct_doji(&self, n: usize) -> Option<Decimal> {
5727 if n == 0 || self.bars.len() < n { return None; }
5728 let start = self.bars.len() - n;
5729 let doji_count = self.bars[start..].iter().filter(|b| {
5730 let range = b.high.value() - b.low.value();
5731 if range.is_zero() { return true; }
5732 let body = (b.close.value() - b.open.value()).abs();
5733 body / range <= Decimal::new(1, 1)
5734 }).count() as u32;
5735 Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5736 }
5737
5738 pub fn recent_close_trend(&self, n: usize) -> Option<i64> {
5741 if n < 2 || self.bars.len() < n { return None; }
5742 let start = self.bars.len() - n;
5743 let closes: Vec<f64> = self.bars[start..]
5744 .iter()
5745 .map(|b| b.close.value().to_string().parse::<f64>().unwrap_or(0.0))
5746 .collect();
5747 let m = closes.len() as f64;
5748 let x_mean = (m - 1.0) / 2.0;
5749 let y_mean: f64 = closes.iter().sum::<f64>() / m;
5750 let mut num = 0.0f64;
5751 let mut den = 0.0f64;
5752 for (i, &y) in closes.iter().enumerate() {
5753 let dx = i as f64 - x_mean;
5754 num += dx * (y - y_mean);
5755 den += dx * dx;
5756 }
5757 if den == 0.0 { return Some(0); }
5758 let slope = num / den;
5759 if slope > 1e-10 { Some(1) } else if slope < -1e-10 { Some(-1) } else { Some(0) }
5760 }
5761
5762 pub fn high_low_range(&self, n: usize) -> Option<Decimal> {
5764 if n == 0 || self.bars.len() < n { return None; }
5765 let start = self.bars.len() - n;
5766 let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5767 let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5768 Some(max_high - min_low)
5769 }
5770
5771 pub fn volume_above_avg_count(&self, n: usize) -> Option<usize> {
5775 if n == 0 || self.bars.len() < n { return None; }
5776 let start = self.bars.len() - n;
5777 let vols: Vec<Decimal> = self.bars[start..].iter().map(|b| b.volume.value()).collect();
5778 let avg = vols.iter().sum::<Decimal>() / Decimal::from(n);
5779 Some(vols.iter().filter(|&&v| v > avg).count())
5780 }
5781
5782 pub fn range_vs_atr_ratio(&self, n: usize) -> Option<Decimal> {
5786 if n == 0 || self.bars.len() < n { return None; }
5787 let start = self.bars.len() - n;
5788 let avg_range = self.bars[start..].iter()
5789 .map(|b| b.high.value() - b.low.value())
5790 .sum::<Decimal>() / Decimal::from(n);
5791 if avg_range.is_zero() { return None; }
5792 let last = self.bars.last()?;
5793 Some((last.high.value() - last.low.value()) / avg_range)
5794 }
5795
5796 pub fn avg_volume_on_up_bars(&self, n: usize) -> Option<Decimal> {
5798 if n == 0 || self.bars.len() < n { return None; }
5799 let start = self.bars.len() - n;
5800 let up_vols: Vec<Decimal> = self.bars[start..].iter()
5801 .filter(|b| b.close.value() > b.open.value())
5802 .map(|b| b.volume.value())
5803 .collect();
5804 if up_vols.is_empty() { return None; }
5805 Some(up_vols.iter().sum::<Decimal>() / Decimal::from(up_vols.len() as u32))
5806 }
5807
5808 pub fn avg_volume_on_down_bars(&self, n: usize) -> Option<Decimal> {
5810 if n == 0 || self.bars.len() < n { return None; }
5811 let start = self.bars.len() - n;
5812 let down_vols: Vec<Decimal> = self.bars[start..].iter()
5813 .filter(|b| b.close.value() < b.open.value())
5814 .map(|b| b.volume.value())
5815 .collect();
5816 if down_vols.is_empty() { return None; }
5817 Some(down_vols.iter().sum::<Decimal>() / Decimal::from(down_vols.len() as u32))
5818 }
5819
5820 pub fn pct_bars_close_above_open(&self, n: usize) -> Option<Decimal> {
5822 if n == 0 || self.bars.len() < n { return None; }
5823 let start = self.bars.len() - n;
5824 let bull = self.bars[start..].iter()
5825 .filter(|b| b.close.value() > b.open.value())
5826 .count() as u32;
5827 Some(Decimal::from(bull) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5828 }
5829
5830 pub fn open_range_position(&self, n: usize) -> Option<Decimal> {
5834 if n == 0 || self.bars.len() < n { return None; }
5835 let start = self.bars.len() - n;
5836 let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5837 let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5838 let range = max_high - min_low;
5839 if range.is_zero() { return None; }
5840 let last_open = self.bars.last()?.open.value();
5841 Some((last_open - min_low) / range)
5842 }
5843
5844 pub fn overnight_gap_count(&self, n: usize, threshold_pct: Decimal) -> Option<usize> {
5848 if n < 2 || self.bars.len() <= n { return None; }
5849 let start = self.bars.len() - n;
5850 let threshold = threshold_pct / Decimal::ONE_HUNDRED;
5851 let count = self.bars[start..].iter().enumerate().filter(|(i, b)| {
5852 let prev_close = self.bars[start + i - 1].close.value();
5853 if prev_close.is_zero() { return false; }
5854 let gap = (b.open.value() - prev_close).abs() / prev_close;
5855 gap >= threshold
5856 }).count();
5857 Some(count)
5858 }
5859
5860 pub fn trend_consistency(&self, n: usize) -> Option<Decimal> {
5865 if n < 2 || self.bars.len() < n { return None; }
5866 let start = self.bars.len() - n;
5867 let first_close = self.bars[start].close.value();
5868 let last_close = self.bars.last()?.close.value();
5869 if first_close == last_close { return Some(Decimal::ZERO); }
5870 let up_trend = last_close > first_close;
5871 let consistent: usize = self.bars[start + 1..].iter().enumerate()
5872 .filter(|(i, b)| {
5873 let prev = self.bars[start + i].close.value();
5874 if up_trend { b.close.value() > prev } else { b.close.value() < prev }
5875 })
5876 .count();
5877 Some(Decimal::from(consistent) / Decimal::from(n - 1))
5878 }
5879
5880 pub fn last_close(&self) -> Option<Decimal> {
5882 self.bars.last().map(|b| b.close.value())
5883 }
5884
5885 pub fn first_close(&self) -> Option<Decimal> {
5887 self.bars.first().map(|b| b.close.value())
5888 }
5889
5890 pub fn close_change_n(&self, n: usize) -> Option<Decimal> {
5894 if n == 0 || self.bars.len() <= n { return None; }
5895 let prev = self.bars[self.bars.len() - 1 - n].close.value();
5896 let last = self.bars.last()?.close.value();
5897 Some(last - prev)
5898 }
5899
5900 pub fn pct_change_n(&self, n: usize) -> Option<Decimal> {
5904 if n == 0 || self.bars.len() <= n { return None; }
5905 let prev = self.bars[self.bars.len() - 1 - n].close.value();
5906 if prev.is_zero() { return None; }
5907 let last = self.bars.last()?.close.value();
5908 Some((last - prev) / prev * Decimal::ONE_HUNDRED)
5909 }
5910
5911 pub fn close_to_high_ratio(&self, n: usize) -> Option<Decimal> {
5915 if n == 0 || self.bars.len() < n { return None; }
5916 let start = self.bars.len() - n;
5917 let mut sum = Decimal::ZERO;
5918 let mut count = 0u32;
5919 for b in &self.bars[start..] {
5920 let range = b.high.value() - b.low.value();
5921 if range.is_zero() { continue; }
5922 sum += (b.high.value() - b.close.value()) / range;
5923 count += 1;
5924 }
5925 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5926 }
5927
5928 pub fn close_to_low_ratio(&self, n: usize) -> Option<Decimal> {
5932 if n == 0 || self.bars.len() < n { return None; }
5933 let start = self.bars.len() - n;
5934 let mut sum = Decimal::ZERO;
5935 let mut count = 0u32;
5936 for b in &self.bars[start..] {
5937 let range = b.high.value() - b.low.value();
5938 if range.is_zero() { continue; }
5939 sum += (b.close.value() - b.low.value()) / range;
5940 count += 1;
5941 }
5942 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5943 }
5944
5945 pub fn volume_coefficient_of_variation(&self, n: usize) -> Option<f64> {
5949 if n < 2 || self.bars.len() < n { return None; }
5950 let start = self.bars.len() - n;
5951 let vols: Vec<f64> = self.bars[start..]
5952 .iter()
5953 .map(|b| b.volume.value().to_string().parse::<f64>().unwrap_or(0.0))
5954 .collect();
5955 let mean = vols.iter().sum::<f64>() / vols.len() as f64;
5956 if mean == 0.0 { return None; }
5957 let variance = vols.iter().map(|&v| { let d = v - mean; d * d }).sum::<f64>() / vols.len() as f64;
5958 Some(variance.sqrt() / mean)
5959 }
5960
5961 pub fn close_wick_ratio(&self, n: usize) -> Option<Decimal> {
5965 if n == 0 || self.bars.len() < n { return None; }
5966 let start = self.bars.len() - n;
5967 let mut sum = Decimal::ZERO;
5968 let mut count = 0u32;
5969 for b in &self.bars[start..] {
5970 let range = b.high.value() - b.low.value();
5971 if range.is_zero() { continue; }
5972 let body_top = b.open.value().max(b.close.value());
5973 let upper_wick = b.high.value() - body_top;
5974 sum += upper_wick / range;
5975 count += 1;
5976 }
5977 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5978 }
5979
5980 pub fn wick_imbalance(&self, n: usize) -> Option<Decimal> {
5985 if n == 0 || self.bars.len() < n { return None; }
5986 let start = self.bars.len() - n;
5987 let mut upper_sum = Decimal::ZERO;
5988 let mut lower_sum = Decimal::ZERO;
5989 let mut range_sum = Decimal::ZERO;
5990 for b in &self.bars[start..] {
5991 let range = b.high.value() - b.low.value();
5992 if range.is_zero() { continue; }
5993 let body_top = b.open.value().max(b.close.value());
5994 let body_bot = b.open.value().min(b.close.value());
5995 upper_sum += b.high.value() - body_top;
5996 lower_sum += body_bot - b.low.value();
5997 range_sum += range;
5998 }
5999 if range_sum.is_zero() { return None; }
6000 Some((upper_sum - lower_sum) / range_sum)
6001 }
6002
6003 pub fn avg_candle_size(&self, n: usize) -> Option<Decimal> {
6007 if n == 0 || self.bars.len() < n { return None; }
6008 let start = self.bars.len() - n;
6009 Some(self.bars[start..].iter().map(|b| b.high.value() - b.low.value()).sum::<Decimal>()
6010 / Decimal::from(n))
6011 }
6012
6013 pub fn bull_strength(&self, n: usize) -> Option<Decimal> {
6017 if n == 0 || self.bars.len() < n { return None; }
6018 let start = self.bars.len() - n;
6019 let mut sum = Decimal::ZERO;
6020 let mut count = 0u32;
6021 for b in &self.bars[start..] {
6022 if b.close.value() <= b.open.value() { continue; }
6023 let range = b.high.value() - b.low.value();
6024 if range.is_zero() { continue; }
6025 sum += (b.close.value() - b.open.value()) / range;
6026 count += 1;
6027 }
6028 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6029 }
6030
6031 pub fn bear_strength(&self, n: usize) -> Option<Decimal> {
6033 if n == 0 || self.bars.len() < n { return None; }
6034 let start = self.bars.len() - n;
6035 let mut sum = Decimal::ZERO;
6036 let mut count = 0u32;
6037 for b in &self.bars[start..] {
6038 if b.close.value() >= b.open.value() { continue; }
6039 let range = b.high.value() - b.low.value();
6040 if range.is_zero() { continue; }
6041 sum += (b.open.value() - b.close.value()) / range;
6042 count += 1;
6043 }
6044 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6045 }
6046
6047 pub fn last_open(&self) -> Option<Decimal> {
6049 self.bars.last().map(|b| b.open.value())
6050 }
6051
6052 pub fn last_high(&self) -> Option<Decimal> {
6054 self.bars.last().map(|b| b.high.value())
6055 }
6056
6057 pub fn last_low(&self) -> Option<Decimal> {
6059 self.bars.last().map(|b| b.low.value())
6060 }
6061
6062 pub fn last_volume(&self) -> Option<Decimal> {
6064 self.bars.last().map(|b| b.volume.value())
6065 }
6066
6067 pub fn close_above_prev_high(&self, n: usize) -> Option<usize> {
6071 if n == 0 || self.bars.len() <= n { return None; }
6072 let start = self.bars.len() - n;
6073 let count = self.bars[start..].iter().enumerate()
6075 .filter(|(i, b)| b.close.value() > self.bars[start - 1 + i].high.value())
6076 .count();
6077 Some(count)
6078 }
6079
6080 pub fn price_entropy(&self, n: usize) -> Option<f64> {
6084 if n < 2 || self.bars.len() < n { return None; }
6085 let start = self.bars.len() - n;
6086 let mut ups = 0usize;
6087 for i in start + 1..self.bars.len() {
6088 if self.bars[i].close.value() > self.bars[i - 1].close.value() { ups += 1; }
6089 }
6090 let total = n - 1;
6091 if ups == 0 || ups == total { return None; }
6092 let p = ups as f64 / total as f64;
6093 let q = 1.0 - p;
6094 Some(-(p * p.log2() + q * q.log2()))
6095 }
6096
6097 pub fn avg_spread_pct(&self, n: usize) -> Option<Decimal> {
6101 if n == 0 || self.bars.len() < n { return None; }
6102 let start = self.bars.len() - n;
6103 let mut sum = Decimal::ZERO;
6104 for b in &self.bars[start..] {
6105 let close = b.close.value();
6106 if close.is_zero() { return None; }
6107 sum += (b.high.value() - b.low.value()) / close * Decimal::ONE_HUNDRED;
6108 }
6109 Some(sum / Decimal::from(n))
6110 }
6111
6112 pub fn close_momentum_ratio(&self, n: usize) -> Option<Decimal> {
6116 if n == 0 || self.bars.len() <= n { return None; }
6117 let prev = self.bars[self.bars.len() - 1 - n].close.value();
6118 if prev.is_zero() { return None; }
6119 Some(self.bars.last()?.close.value() / prev)
6120 }
6121
6122 pub fn price_velocity(&self, fast: usize, slow: usize) -> Option<Decimal> {
6126 let fast_chg = self.pct_change_n(fast)?;
6127 let slow_chg = self.pct_change_n(slow)?;
6128 Some(fast_chg - slow_chg)
6129 }
6130
6131 pub fn longest_flat_streak(&self) -> usize {
6133 let mut max_run = 0usize;
6134 let mut run = 0usize;
6135 for b in &self.bars {
6136 if b.close.value() == b.open.value() {
6137 run += 1;
6138 max_run = max_run.max(run);
6139 } else {
6140 run = 0;
6141 }
6142 }
6143 max_run
6144 }
6145
6146 pub fn bars_since_new_high(&self) -> Option<usize> {
6150 if self.bars.is_empty() { return None; }
6151 let mut last_high_idx = 0;
6152 let mut peak = self.bars[0].close.value();
6153 for (i, b) in self.bars.iter().enumerate() {
6154 if b.close.value() >= peak {
6155 peak = b.close.value();
6156 last_high_idx = i;
6157 }
6158 }
6159 Some(self.bars.len() - 1 - last_high_idx)
6160 }
6161
6162 pub fn drawdown_from_peak(&self, n: usize) -> Option<Decimal> {
6167 if n == 0 || self.bars.len() < n { return None; }
6168 let start = self.bars.len() - n;
6169 let peak = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6170 if peak.is_zero() { return None; }
6171 let current = self.bars.last()?.close.value();
6172 Some((current - peak) / peak * Decimal::ONE_HUNDRED)
6173 }
6174
6175 pub fn price_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
6180 if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow { return None; }
6181 let n = self.bars.len();
6182 let fast_start = n - fast;
6183 let slow_start = n - slow;
6184 let fast_sma = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6185 / Decimal::from(fast);
6186 let slow_sma = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6187 / Decimal::from(slow);
6188 if slow_sma.is_zero() { return None; }
6189 Some((fast_sma - slow_sma) / slow_sma * Decimal::ONE_HUNDRED)
6190 }
6191
6192 pub fn close_below_prev_low(&self, n: usize) -> Option<usize> {
6196 if n == 0 || self.bars.len() <= n { return None; }
6197 let start = self.bars.len() - n;
6198 let count = self.bars[start..].iter().enumerate()
6199 .filter(|(i, b)| b.close.value() < self.bars[start - 1 + i].low.value())
6200 .count();
6201 Some(count)
6202 }
6203
6204 pub fn bars_above_ma(&self, n: usize, period: usize) -> Option<usize> {
6208 if n == 0 || period == 0 || self.bars.len() < n.max(period) { return None; }
6209 let sma_start = self.bars.len() - period;
6210 let sma = self.bars[sma_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6211 / Decimal::from(period);
6212 let bar_start = self.bars.len() - n;
6213 let count = self.bars[bar_start..].iter()
6214 .filter(|b| b.close.value() > sma)
6215 .count();
6216 Some(count)
6217 }
6218
6219 pub fn price_contraction(&self, n: usize) -> Option<Decimal> {
6223 if n == 0 || self.bars.len() < 2 * n { return None; }
6224 let len = self.bars.len();
6225 let recent_high = self.bars[len - n..].iter().map(|b| b.high.value()).max()?;
6226 let recent_low = self.bars[len - n..].iter().map(|b| b.low.value()).min()?;
6227 let prior_high = self.bars[len - 2 * n..len - n].iter().map(|b| b.high.value()).max()?;
6228 let prior_low = self.bars[len - 2 * n..len - n].iter().map(|b| b.low.value()).min()?;
6229 let recent_range = recent_high - recent_low;
6230 let prior_range = prior_high - prior_low;
6231 if prior_range.is_zero() { return None; }
6232 Some(recent_range / prior_range)
6233 }
6234
6235 pub fn bars_since_new_low(&self) -> Option<usize> {
6239 if self.bars.is_empty() { return None; }
6240 let mut last_low_idx = 0;
6241 let mut trough = self.bars[0].close.value();
6242 for (i, b) in self.bars.iter().enumerate() {
6243 if b.close.value() <= trough {
6244 trough = b.close.value();
6245 last_low_idx = i;
6246 }
6247 }
6248 Some(self.bars.len() - 1 - last_low_idx)
6249 }
6250
6251 pub fn volume_per_range(&self, n: usize) -> Option<Decimal> {
6256 if n == 0 || self.bars.len() < n { return None; }
6257 let start = self.bars.len() - n;
6258 let mut sum = Decimal::ZERO;
6259 let mut count = 0u32;
6260 for b in &self.bars[start..] {
6261 let range = b.high.value() - b.low.value();
6262 if range.is_zero() { continue; }
6263 sum += b.volume.value() / range;
6264 count += 1;
6265 }
6266 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6267 }
6268
6269 pub fn price_volatility_ratio(&self, fast: usize, slow: usize) -> Option<f64> {
6275 use rust_decimal::prelude::ToPrimitive;
6276 if fast < 2 || slow < 2 || fast >= slow || self.bars.len() < slow { return None; }
6277 let n = self.bars.len();
6278 let std_dev = |bars: &[crate::ohlcv::OhlcvBar]| -> Option<f64> {
6279 let m = bars.len() as f64;
6280 let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.value().to_f64()).collect();
6281 if vals.len() < 2 { return None; }
6282 let mean = vals.iter().sum::<f64>() / m;
6283 let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
6284 Some(var.sqrt())
6285 };
6286 let fast_vol = std_dev(&self.bars[n - fast..])?;
6287 let slow_vol = std_dev(&self.bars[n - slow..])?;
6288 if slow_vol == 0.0 { return None; }
6289 Some(fast_vol / slow_vol)
6290 }
6291
6292 pub fn last_bar(&self) -> Option<&OhlcvBar> {
6294 self.bars.last()
6295 }
6296
6297 pub fn close_distance_from_high(&self, n: usize) -> Option<Decimal> {
6301 if n == 0 || self.bars.len() < n { return None; }
6302 let start = self.bars.len() - n;
6303 let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6304 Some((max_high - self.bars.last()?.close.value()).abs())
6305 }
6306
6307 pub fn pct_from_low(&self, n: usize) -> Option<Decimal> {
6311 if n == 0 || self.bars.len() < n { return None; }
6312 let start = self.bars.len() - n;
6313 let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
6314 if min_low.is_zero() { return None; }
6315 Some((self.bars.last()?.close.value() - min_low) / min_low * Decimal::ONE_HUNDRED)
6316 }
6317
6318 pub fn is_breakout_up(&self, n: usize) -> bool {
6320 if n == 0 || self.bars.len() <= n { return false; }
6321 let len = self.bars.len();
6322 let prior_high = self.bars[len - 1 - n..len - 1].iter().map(|b| b.close.value()).max();
6323 match (prior_high, self.bars.last()) {
6324 (Some(ph), Some(last)) => last.close.value() > ph,
6325 _ => false,
6326 }
6327 }
6328
6329 pub fn consecutive_closes_above(&self, price: Decimal) -> usize {
6334 self.bars.iter().rev().take_while(|b| b.close.value() > price).count()
6335 }
6336
6337 pub fn open_range_pct(&self, n: usize) -> Option<f64> {
6342 use rust_decimal::prelude::ToPrimitive;
6343 if n == 0 || self.bars.len() < n { return None; }
6344 let start = self.bars.len() - n;
6345 let vals: Vec<f64> = self.bars[start..].iter().filter_map(|b| {
6346 let range = b.high.value() - b.low.value();
6347 if range.is_zero() { return None; }
6348 let num = (b.open.value() - b.low.value()).to_f64()?;
6349 let den = range.to_f64()?;
6350 Some(num / den * 100.0)
6351 }).collect();
6352 if vals.is_empty() { return None; }
6353 Some(vals.iter().sum::<f64>() / vals.len() as f64)
6354 }
6355}
6356
6357#[cfg(test)]
6358mod tests {
6359 use super::*;
6360 use crate::types::Side;
6361 use rust_decimal_macros::dec;
6362
6363 fn make_price(s: &str) -> Price {
6364 Price::new(s.parse().unwrap()).unwrap()
6365 }
6366
6367 fn make_qty(s: &str) -> Quantity {
6368 Quantity::new(s.parse().unwrap()).unwrap()
6369 }
6370
6371 fn make_bar(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
6372 OhlcvBar {
6373 symbol: Symbol::new("X").unwrap(),
6374 open: make_price(o),
6375 high: make_price(h),
6376 low: make_price(l),
6377 close: make_price(c),
6378 volume: make_qty("100"),
6379 ts_open: NanoTimestamp::new(0),
6380 ts_close: NanoTimestamp::new(1),
6381 tick_count: 1,
6382 }
6383 }
6384
6385 fn bar(close: &str) -> OhlcvBar {
6387 make_bar(close, close, close, close)
6388 }
6389
6390 fn make_tick(sym: &str, price: &str, qty: &str, ts: i64) -> Tick {
6391 Tick::new(
6392 Symbol::new(sym).unwrap(),
6393 make_price(price),
6394 make_qty(qty),
6395 Side::Ask,
6396 NanoTimestamp::new(ts),
6397 )
6398 }
6399
6400 #[test]
6403 fn test_ohlcv_bar_validate_ok() {
6404 let bar = make_bar("100", "110", "90", "105");
6405 assert!(bar.validate().is_ok());
6406 }
6407
6408 #[test]
6409 fn test_ohlcv_bar_validate_high_less_than_close_fails() {
6410 let bar = make_bar("100", "104", "90", "110");
6411 assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
6412 }
6413
6414 #[test]
6415 fn test_ohlcv_bar_validate_low_greater_than_open_fails() {
6416 let bar = make_bar("80", "110", "90", "105");
6417 assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
6418 }
6419
6420 #[test]
6421 fn test_ohlcv_bar_validate_high_less_than_open_fails() {
6422 let bar = make_bar("115", "110", "90", "105");
6423 assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
6424 }
6425
6426 #[test]
6427 fn test_ohlcv_bar_typical_price() {
6428 let bar = make_bar("100", "120", "80", "110");
6429 let expected = dec!(310) / Decimal::from(3u32);
6430 assert_eq!(bar.typical_price(), expected);
6431 }
6432
6433 #[test]
6434 fn test_ohlcv_bar_range() {
6435 let bar = make_bar("100", "120", "80", "110");
6436 assert_eq!(bar.range(), dec!(40));
6437 }
6438
6439 #[test]
6440 fn test_ohlcv_bar_is_bullish_true() {
6441 let bar = make_bar("100", "110", "95", "105");
6442 assert!(bar.is_bullish());
6443 }
6444
6445 #[test]
6446 fn test_ohlcv_bar_is_bullish_false() {
6447 let bar = make_bar("105", "110", "95", "100");
6448 assert!(!bar.is_bullish());
6449 }
6450
6451 #[test]
6452 fn test_ohlcv_bar_midpoint() {
6453 let bar = make_bar("100", "120", "80", "110");
6454 assert_eq!(bar.midpoint(), dec!(100)); }
6456
6457 #[test]
6458 fn test_ohlcv_bar_body_size_bullish() {
6459 let bar = make_bar("100", "120", "80", "110");
6460 assert_eq!(bar.body_size(), dec!(10)); }
6462
6463 #[test]
6464 fn test_ohlcv_bar_body_size_bearish() {
6465 let bar = make_bar("110", "120", "80", "100");
6466 assert_eq!(bar.body_size(), dec!(10)); }
6468
6469 #[test]
6470 fn test_ohlcv_bar_is_long_candle_flat() {
6471 let bar = make_bar("100", "100", "100", "100");
6473 assert!(!bar.is_long_candle(dec!(0.7)));
6474 }
6475
6476 #[test]
6477 fn test_ohlcv_bar_is_long_candle_true() {
6478 let bar = make_bar("100", "112", "98", "110");
6480 assert!(bar.is_long_candle(dec!(0.7)));
6481 }
6482
6483 #[test]
6484 fn test_ohlcv_bar_is_long_candle_false() {
6485 let bar = make_bar("100", "110", "90", "101");
6487 assert!(!bar.is_long_candle(dec!(0.7)));
6488 }
6489
6490 #[test]
6491 fn test_ohlcv_bar_is_doji_flat_range() {
6492 let bar = make_bar("100", "100", "100", "100");
6493 assert!(bar.is_doji(dec!(0.1)));
6494 assert!(!bar.is_doji(dec!(0)));
6495 }
6496
6497 #[test]
6498 fn test_ohlcv_bar_is_doji_small_body() {
6499 let bar = make_bar("100", "110", "90", "101");
6501 assert!(bar.is_doji(dec!(0.1)));
6502 assert!(!bar.is_doji(dec!(0.04)));
6503 }
6504
6505 #[test]
6506 fn test_ohlcv_bar_partial_eq() {
6507 let a = make_bar("100", "110", "90", "105");
6508 let b = make_bar("100", "110", "90", "105");
6509 assert_eq!(a, b);
6510 let c = make_bar("100", "110", "90", "106");
6511 assert_ne!(a, c);
6512 }
6513
6514 #[test]
6517 fn test_timeframe_seconds_to_nanos() {
6518 let tf = Timeframe::Seconds(5);
6519 assert_eq!(tf.to_nanos().unwrap(), 5_000_000_000);
6520 }
6521
6522 #[test]
6523 fn test_timeframe_minutes_to_nanos() {
6524 let tf = Timeframe::Minutes(1);
6525 assert_eq!(tf.to_nanos().unwrap(), 60_000_000_000);
6526 }
6527
6528 #[test]
6529 fn test_timeframe_zero_seconds_fails() {
6530 let tf = Timeframe::Seconds(0);
6531 assert!(matches!(tf.to_nanos(), Err(FinError::InvalidTimeframe)));
6532 }
6533
6534 #[test]
6535 fn test_timeframe_weeks_to_nanos() {
6536 let tf = Timeframe::Weeks(1);
6537 assert_eq!(tf.to_nanos().unwrap(), 7 * 86_400 * 1_000_000_000_i64);
6538 }
6539
6540 #[test]
6541 fn test_timeframe_bucket_start() {
6542 let tf = Timeframe::Seconds(60);
6543 let nanos_per_min = 60_000_000_000_i64;
6544 let ts = NanoTimestamp::new(nanos_per_min + 500_000_000);
6545 let bucket = tf.bucket_start(ts).unwrap();
6546 assert_eq!(bucket.nanos(), nanos_per_min);
6547 }
6548
6549 #[test]
6552 fn test_ohlcv_aggregator_new_invalid_timeframe_fails() {
6553 let sym = Symbol::new("X").unwrap();
6554 let result = OhlcvAggregator::new(sym, Timeframe::Seconds(0));
6555 assert!(matches!(result, Err(FinError::InvalidTimeframe)));
6556 }
6557
6558 #[test]
6559 fn test_ohlcv_aggregator_completes_bar_on_boundary() {
6560 let sym = Symbol::new("X").unwrap();
6561 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6562 let nanos_per_min = 60_000_000_000_i64;
6563
6564 let t1 = make_tick("X", "100", "1", 0);
6565 let t2 = make_tick("X", "105", "2", nanos_per_min / 2);
6566 let t3 = make_tick("X", "110", "1", nanos_per_min + 1);
6567
6568 let r1 = agg.push_tick(&t1).unwrap();
6569 assert!(r1.is_empty());
6570 let r2 = agg.push_tick(&t2).unwrap();
6571 assert!(r2.is_empty());
6572 let r3 = agg.push_tick(&t3).unwrap();
6573 assert_eq!(r3.len(), 1);
6574 let bar = &r3[0];
6575 assert_eq!(bar.open.value(), dec!(100));
6576 assert_eq!(bar.high.value(), dec!(105));
6577 assert_eq!(bar.close.value(), dec!(105));
6578 assert_eq!(bar.tick_count, 2);
6579 }
6580
6581 #[test]
6582 fn test_ohlcv_aggregator_gap_fills_empty_buckets() {
6583 let sym = Symbol::new("X").unwrap();
6584 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6585 let nanos_per_min = 60_000_000_000_i64;
6586
6587 agg.push_tick(&make_tick("X", "100", "1", 0)).unwrap();
6589 let out = agg
6591 .push_tick(&make_tick("X", "200", "1", 3 * nanos_per_min + 1))
6592 .unwrap();
6593 assert_eq!(out.len(), 3, "expected 1 completed + 2 gap bars, got {}", out.len());
6595 assert_eq!(out[0].tick_count, 1);
6597 assert_eq!(out[1].tick_count, 0);
6599 assert_eq!(out[1].volume.value(), dec!(0));
6600 assert_eq!(out[2].tick_count, 0);
6601 assert_eq!(out[1].close, out[0].close);
6603 }
6604
6605 #[test]
6606 fn test_ohlcv_aggregator_flush_returns_partial() {
6607 let sym = Symbol::new("X").unwrap();
6608 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6609 let t1 = make_tick("X", "100", "1", 0);
6610 agg.push_tick(&t1).unwrap();
6611 let bar = agg.flush().unwrap();
6612 assert_eq!(bar.open.value(), dec!(100));
6613 assert!(agg.flush().is_none());
6614 }
6615
6616 #[test]
6617 fn test_ohlcv_aggregator_symbol_getter() {
6618 let sym = Symbol::new("BTC").unwrap();
6619 let agg = OhlcvAggregator::new(sym.clone(), Timeframe::Seconds(60)).unwrap();
6620 assert_eq!(agg.symbol(), &sym);
6621 }
6622
6623 #[test]
6624 fn test_ohlcv_aggregator_ignores_different_symbol() {
6625 let sym = Symbol::new("X").unwrap();
6626 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6627 let t = make_tick("Y", "100", "1", 0);
6628 let result = agg.push_tick(&t).unwrap();
6629 assert!(result.is_empty());
6630 assert!(agg.current_bar().is_none());
6631 }
6632
6633 #[test]
6636 fn test_ohlcv_series_push_valid() {
6637 let mut series = OhlcvSeries::new();
6638 let bar = make_bar("100", "110", "90", "105");
6639 assert!(series.push(bar).is_ok());
6640 assert_eq!(series.len(), 1);
6641 }
6642
6643 #[test]
6644 fn test_ohlcv_series_push_invalid_fails() {
6645 let mut series = OhlcvSeries::new();
6646 let bar = make_bar("100", "95", "90", "105");
6647 assert!(matches!(series.push(bar), Err(FinError::BarInvariant(_))));
6648 }
6649
6650 #[test]
6651 fn test_ohlcv_series_window_returns_last_n() {
6652 let mut series = OhlcvSeries::new();
6653 for i in 1u32..=5 {
6654 let p = format!("{}", 100 + i);
6655 let h = format!("{}", 110 + i);
6656 let l = format!("{}", 90 + i);
6657 let c = format!("{}", 105 + i);
6658 series.push(make_bar(&p, &h, &l, &c)).unwrap();
6659 }
6660 let w = series.window(3);
6661 assert_eq!(w.len(), 3);
6662 assert_eq!(w[0].open.value(), dec!(103));
6663 }
6664
6665 #[test]
6666 fn test_ohlcv_series_window_larger_than_len() {
6667 let mut series = OhlcvSeries::new();
6668 series.push(make_bar("100", "110", "90", "105")).unwrap();
6669 let w = series.window(10);
6670 assert_eq!(w.len(), 1);
6671 }
6672
6673 #[test]
6674 fn test_ohlcv_series_opens() {
6675 let mut series = OhlcvSeries::new();
6676 series.push(make_bar("100", "110", "90", "105")).unwrap();
6677 series.push(make_bar("105", "115", "95", "110")).unwrap();
6678 assert_eq!(series.opens(), vec![dec!(100), dec!(105)]);
6679 }
6680
6681 #[test]
6682 fn test_ohlcv_series_highs() {
6683 let mut series = OhlcvSeries::new();
6684 series.push(make_bar("100", "110", "90", "105")).unwrap();
6685 series.push(make_bar("105", "115", "95", "110")).unwrap();
6686 assert_eq!(series.highs(), vec![dec!(110), dec!(115)]);
6687 }
6688
6689 #[test]
6690 fn test_ohlcv_series_lows() {
6691 let mut series = OhlcvSeries::new();
6692 series.push(make_bar("100", "110", "90", "105")).unwrap();
6693 series.push(make_bar("105", "115", "95", "110")).unwrap();
6694 assert_eq!(series.lows(), vec![dec!(90), dec!(95)]);
6695 }
6696
6697 #[test]
6698 fn test_ohlcv_series_closes() {
6699 let mut series = OhlcvSeries::new();
6700 series.push(make_bar("100", "110", "90", "105")).unwrap();
6701 series.push(make_bar("105", "115", "95", "110")).unwrap();
6702 let closes = series.closes();
6703 assert_eq!(closes, vec![dec!(105), dec!(110)]);
6704 }
6705
6706 #[test]
6707 fn test_ohlcv_series_is_empty() {
6708 let series = OhlcvSeries::new();
6709 assert!(series.is_empty());
6710 }
6711
6712 #[test]
6713 fn test_ohlcv_series_into_iterator() {
6714 let mut series = OhlcvSeries::new();
6715 series.push(make_bar("100", "110", "90", "105")).unwrap();
6716 series.push(make_bar("105", "115", "95", "110")).unwrap();
6717 let count = (&series).into_iter().count();
6718 assert_eq!(count, 2);
6719 }
6720
6721 #[test]
6722 fn test_ohlcv_series_iter() {
6723 let mut series = OhlcvSeries::new();
6724 series.push(make_bar("100", "110", "90", "105")).unwrap();
6725 let bar = series.iter().next().unwrap();
6726 assert_eq!(bar.open.value(), dec!(100));
6727 }
6728
6729 #[test]
6730 fn test_ohlcv_bar_upper_shadow() {
6731 let b = make_bar("100", "115", "90", "108");
6733 assert_eq!(b.upper_shadow(), dec!(7));
6734 }
6735
6736 #[test]
6737 fn test_ohlcv_bar_lower_shadow() {
6738 let b = make_bar("100", "115", "90", "108");
6740 assert_eq!(b.lower_shadow(), dec!(10));
6741 }
6742
6743 #[test]
6744 fn test_ohlcv_bar_from_tick() {
6745 let tick = make_tick("AAPL", "150", "5", 1_000);
6746 let bar = OhlcvBar::from_tick(&tick);
6747 assert_eq!(bar.open.value(), dec!(150));
6748 assert_eq!(bar.high.value(), dec!(150));
6749 assert_eq!(bar.low.value(), dec!(150));
6750 assert_eq!(bar.close.value(), dec!(150));
6751 assert_eq!(bar.volume.value(), dec!(5));
6752 assert_eq!(bar.tick_count, 1);
6753 assert_eq!(bar.ts_open.nanos(), 1_000);
6754 }
6755
6756 #[test]
6757 fn test_ohlcv_series_bars_slice() {
6758 let mut series = OhlcvSeries::new();
6759 series.push(make_bar("100", "110", "90", "105")).unwrap();
6760 series.push(make_bar("105", "115", "95", "110")).unwrap();
6761 assert_eq!(series.bars().len(), 2);
6762 }
6763
6764 #[test]
6765 fn test_ohlcv_series_max_high_min_low() {
6766 let mut series = OhlcvSeries::new();
6767 series.push(make_bar("100", "110", "90", "105")).unwrap();
6768 series.push(make_bar("105", "120", "85", "110")).unwrap();
6769 assert_eq!(series.max_high().unwrap(), dec!(120));
6770 assert_eq!(series.min_low().unwrap(), dec!(85));
6771 }
6772
6773 #[test]
6774 fn test_ohlcv_series_max_high_empty() {
6775 let series = OhlcvSeries::new();
6776 assert!(series.max_high().is_none());
6777 assert!(series.min_low().is_none());
6778 }
6779
6780 #[test]
6781 fn test_ohlcv_series_slice() {
6782 let mut series = OhlcvSeries::new();
6783 series.push(make_bar("100", "110", "90", "105")).unwrap();
6784 series.push(make_bar("105", "115", "95", "110")).unwrap();
6785 series.push(make_bar("110", "120", "100", "115")).unwrap();
6786 let s = series.slice(1, 3).unwrap();
6787 assert_eq!(s.len(), 2);
6788 assert_eq!(s[0].open.value(), dec!(105));
6789 }
6790
6791 #[test]
6792 fn test_ohlcv_series_slice_out_of_bounds() {
6793 let series = OhlcvSeries::new();
6794 assert!(series.slice(0, 1).is_none());
6795 }
6796
6797 #[test]
6798 fn test_ohlcv_series_truncate_keeps_last_n() {
6799 let mut series = OhlcvSeries::new();
6800 for _ in 0..5 {
6801 series.push(make_bar("100", "110", "90", "105")).unwrap();
6802 }
6803 series.truncate(3);
6804 assert_eq!(series.len(), 3);
6805 }
6806
6807 #[test]
6808 fn test_ohlcv_series_truncate_noop_when_n_ge_len() {
6809 let mut series = OhlcvSeries::new();
6810 series.push(make_bar("100", "110", "90", "105")).unwrap();
6811 series.push(make_bar("105", "115", "95", "110")).unwrap();
6812 series.truncate(5);
6813 assert_eq!(series.len(), 2);
6814 }
6815
6816 #[test]
6817 fn test_ohlcv_series_truncate_to_zero() {
6818 let mut series = OhlcvSeries::new();
6819 series.push(make_bar("100", "110", "90", "105")).unwrap();
6820 series.push(make_bar("105", "115", "95", "110")).unwrap();
6821 series.truncate(0);
6822 assert!(series.is_empty());
6823 }
6824
6825 #[test]
6826 fn test_ohlcv_bar_serde_roundtrip() {
6827 let bar = make_bar("100", "110", "90", "105");
6828 let json = serde_json::to_string(&bar).unwrap();
6829 let back: OhlcvBar = serde_json::from_str(&json).unwrap();
6830 assert_eq!(back.open, bar.open);
6831 assert_eq!(back.high, bar.high);
6832 assert_eq!(back.low, bar.low);
6833 assert_eq!(back.close, bar.close);
6834 assert_eq!(back.tick_count, bar.tick_count);
6835 }
6836
6837 #[test]
6838 fn test_ohlcv_bar_duration_nanos() {
6839 let mut bar = make_bar("100", "110", "90", "105");
6840 bar.ts_open = NanoTimestamp::new(1_000_000_000);
6841 bar.ts_close = NanoTimestamp::new(1_060_000_000_000);
6842 assert_eq!(bar.duration_nanos(), 1_059_000_000_000);
6843 }
6844
6845 #[test]
6846 fn test_ohlcv_bar_duration_nanos_same_timestamps() {
6847 let mut bar = make_bar("100", "110", "90", "100");
6848 bar.ts_open = NanoTimestamp::new(5_000);
6849 bar.ts_close = NanoTimestamp::new(5_000);
6850 assert_eq!(bar.duration_nanos(), 0);
6851 }
6852
6853 #[test]
6854 fn test_ohlcv_series_extend_valid() {
6855 let mut series = OhlcvSeries::new();
6856 let bars = vec![
6857 make_bar("100", "110", "90", "105"),
6858 make_bar("105", "115", "95", "110"),
6859 ];
6860 series.extend(bars).unwrap();
6861 assert_eq!(series.len(), 2);
6862 }
6863
6864 #[test]
6865 fn test_ohlcv_series_extend_stops_on_invalid_bar() {
6866 let mut series = OhlcvSeries::new();
6867 let valid = make_bar("100", "110", "90", "105");
6868 let mut invalid = make_bar("100", "110", "90", "105");
6869 invalid.high = make_price("80");
6871 invalid.low = make_price("110");
6872 let result = series.extend([valid, invalid]);
6873 assert!(result.is_err());
6874 assert_eq!(series.len(), 1, "valid bar added before error");
6875 }
6876
6877 #[test]
6878 fn test_ohlcv_bar_to_bar_input_fields_match() {
6879 let bar = make_bar("100", "110", "90", "105");
6880 let input = bar.to_bar_input();
6881 assert_eq!(input.open, bar.open.value());
6882 assert_eq!(input.high, bar.high.value());
6883 assert_eq!(input.low, bar.low.value());
6884 assert_eq!(input.close, bar.close.value());
6885 assert_eq!(input.volume, bar.volume.value());
6886 }
6887
6888 #[test]
6889 fn test_ohlcv_series_retain_removes_gap_fills() {
6890 let mut series = OhlcvSeries::new();
6891 series.push(make_bar("100", "110", "90", "105")).unwrap();
6892 let mut gap = make_bar("105", "105", "105", "105");
6894 gap.tick_count = 0;
6895 series.push(gap).unwrap();
6896 series.push(make_bar("105", "115", "95", "110")).unwrap();
6897 series.retain(|b| !b.is_gap_fill());
6898 assert_eq!(series.len(), 2);
6899 }
6900
6901 #[test]
6902 fn test_ohlcv_series_retain_keeps_all() {
6903 let mut series = OhlcvSeries::new();
6904 series.push(make_bar("100", "110", "90", "105")).unwrap();
6905 series.push(make_bar("105", "115", "95", "110")).unwrap();
6906 series.retain(|_| true);
6907 assert_eq!(series.len(), 2);
6908 }
6909
6910 #[test]
6911 fn test_ohlcv_bar_is_bearish() {
6912 let bar = make_bar("110", "115", "95", "100");
6913 assert!(bar.is_bearish());
6914 assert!(!bar.is_bullish());
6915 }
6916
6917 #[test]
6918 fn test_ohlcv_bar_is_hammer() {
6919 let not_hammer = make_bar("100", "111", "80", "105");
6921 assert!(!not_hammer.is_hammer());
6922 let hammer = make_bar("95", "100", "75", "100");
6924 assert!(hammer.is_hammer());
6925 }
6926
6927 #[test]
6928 fn test_ohlcv_bar_is_shooting_star() {
6929 let star = make_bar("100", "125", "100", "105");
6931 assert!(star.is_shooting_star());
6932 let not_star = make_bar("100", "110", "80", "105");
6934 assert!(!not_star.is_shooting_star());
6935 }
6936
6937 #[test]
6938 fn test_ohlcv_bar_bar_return_positive() {
6939 let bar = make_bar("100", "110", "90", "110");
6940 assert_eq!(bar.bar_return().unwrap(), dec!(10));
6941 }
6942
6943 #[test]
6944 fn test_ohlcv_bar_bar_return_negative() {
6945 let bar = make_bar("100", "105", "85", "90");
6946 assert_eq!(bar.bar_return().unwrap(), dec!(-10));
6947 }
6948
6949 #[test]
6950 fn test_ohlcv_series_highest_high() {
6951 let mut series = OhlcvSeries::new();
6952 series.push(make_bar("100", "150", "90", "105")).unwrap();
6953 series.push(make_bar("105", "130", "95", "110")).unwrap();
6954 series.push(make_bar("110", "120", "100", "115")).unwrap();
6955 assert_eq!(series.highest_high(2).unwrap(), dec!(130));
6956 assert_eq!(series.highest_high(10).unwrap(), dec!(150));
6957 }
6958
6959 #[test]
6960 fn test_ohlcv_series_lowest_low() {
6961 let mut series = OhlcvSeries::new();
6962 series.push(make_bar("100", "110", "70", "105")).unwrap();
6963 series.push(make_bar("105", "115", "85", "110")).unwrap();
6964 series.push(make_bar("110", "120", "90", "115")).unwrap();
6965 assert_eq!(series.lowest_low(2).unwrap(), dec!(85));
6966 assert_eq!(series.lowest_low(10).unwrap(), dec!(70));
6967 }
6968
6969 #[test]
6970 fn test_ohlcv_series_extend_from_series() {
6971 let mut a = OhlcvSeries::new();
6972 a.push(make_bar("100", "110", "90", "105")).unwrap();
6973 let mut b = OhlcvSeries::new();
6974 b.push(make_bar("105", "115", "95", "110")).unwrap();
6975 b.push(make_bar("110", "120", "100", "115")).unwrap();
6976 a.extend_from_series(&b).unwrap();
6977 assert_eq!(a.len(), 3);
6978 }
6979
6980 #[test]
6981 fn test_ohlcv_aggregator_bar_count() {
6982 let sym = Symbol::new("AAPL").unwrap();
6983 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(1)).unwrap();
6984 assert_eq!(agg.bar_count(), 0);
6985 agg.push_tick(&make_tick("AAPL", "100", "1", 0)).unwrap();
6986 agg.push_tick(&make_tick("AAPL", "101", "1", 2_000_000_000))
6988 .unwrap();
6989 assert_eq!(agg.bar_count(), 2);
6990 agg.flush();
6991 assert_eq!(agg.bar_count(), 3);
6992 agg.reset();
6993 assert_eq!(agg.bar_count(), 0);
6994 }
6995
6996 #[test]
6997 fn test_ohlcv_series_vwap_empty_returns_none() {
6998 assert!(OhlcvSeries::new().vwap().is_none());
6999 }
7000
7001 #[test]
7002 fn test_ohlcv_series_vwap_zero_volume_returns_none() {
7003 let mut series = OhlcvSeries::new();
7004 let mut bar = make_bar("100", "110", "90", "100");
7005 bar.volume = Quantity::zero();
7006 series.push(bar).unwrap();
7007 assert!(series.vwap().is_none());
7008 }
7009
7010 #[test]
7011 fn test_ohlcv_series_vwap_constant_price() {
7012 let mut series = OhlcvSeries::new();
7013 series.push(make_bar("100", "100", "100", "100")).unwrap();
7014 series.push(make_bar("100", "100", "100", "100")).unwrap();
7015 assert_eq!(series.vwap().unwrap(), dec!(100));
7016 }
7017
7018 #[test]
7019 fn test_ohlcv_series_sum_volume_empty() {
7020 assert_eq!(OhlcvSeries::new().sum_volume(), dec!(0));
7021 }
7022
7023 #[test]
7024 fn test_ohlcv_series_sum_volume_multiple_bars() {
7025 let mut series = OhlcvSeries::new();
7026 series.push(make_bar("100", "110", "90", "105")).unwrap();
7027 series.push(make_bar("105", "115", "95", "110")).unwrap();
7028 series.push(make_bar("110", "120", "100", "115")).unwrap();
7029 assert_eq!(series.sum_volume(), dec!(300));
7031 }
7032
7033 #[test]
7034 fn test_ohlcv_series_avg_volume_none_when_empty() {
7035 assert!(OhlcvSeries::new().avg_volume(3).is_none());
7036 }
7037
7038 #[test]
7039 fn test_ohlcv_series_avg_volume_none_when_n_zero() {
7040 let mut series = OhlcvSeries::new();
7041 series.push(make_bar("100", "110", "90", "105")).unwrap();
7042 assert!(series.avg_volume(0).is_none());
7043 }
7044
7045 #[test]
7046 fn test_ohlcv_series_avg_volume_correct() {
7047 let mut series = OhlcvSeries::new();
7049 series.push(make_bar("100", "110", "90", "105")).unwrap();
7050 series.push(make_bar("105", "115", "95", "110")).unwrap();
7051 series.push(make_bar("110", "120", "100", "115")).unwrap();
7052 assert_eq!(series.avg_volume(3).unwrap(), dec!(100));
7054 }
7055
7056 #[test]
7057 fn test_ohlcv_series_avg_volume_partial_window() {
7058 let mut series = OhlcvSeries::new();
7060 series.push(make_bar("100", "110", "90", "105")).unwrap();
7061 series.push(make_bar("105", "115", "95", "110")).unwrap();
7062 assert!(series.avg_volume(5).is_none());
7063 }
7064
7065 #[test]
7066 fn test_ohlcv_series_price_range_none_when_insufficient() {
7067 let mut series = OhlcvSeries::new();
7068 series.push(make_bar("100", "110", "90", "105")).unwrap();
7069 assert!(series.price_range(0).is_none());
7070 assert!(series.price_range(2).is_none());
7071 }
7072
7073 #[test]
7074 fn test_ohlcv_series_price_range_correct() {
7075 let mut series = OhlcvSeries::new();
7077 series.push(make_bar("100", "110", "90", "100")).unwrap();
7078 series.push(make_bar("100", "120", "80", "100")).unwrap();
7079 assert_eq!(series.price_range(2).unwrap(), dec!(40));
7080 }
7081
7082 #[test]
7083 fn test_ohlcv_series_above_ema_false_when_insufficient() {
7084 assert!(!OhlcvSeries::new().above_ema(3));
7085 }
7086
7087 #[test]
7088 fn test_ohlcv_series_above_ema_rising_close() {
7089 let mut series = OhlcvSeries::new();
7090 for c in ["100", "100", "100", "100", "200"] {
7091 series.push(make_bar(c, "210", "90", c)).unwrap();
7092 }
7093 assert!(series.above_ema(3));
7094 }
7095
7096 #[test]
7097 fn test_ohlcv_series_bullish_engulfing_count_zero_when_short() {
7098 assert_eq!(OhlcvSeries::new().bullish_engulfing_count(5), 0);
7099 }
7100
7101 #[test]
7102 fn test_ohlcv_series_bullish_engulfing_count_detects_pattern() {
7103 let mut series = OhlcvSeries::new();
7104 series.push(make_bar("105", "110", "90", "95")).unwrap();
7106 series.push(make_bar("90", "120", "88", "110")).unwrap();
7108 assert_eq!(series.bullish_engulfing_count(2), 1);
7109 }
7110
7111 #[test]
7112 fn test_ohlcv_series_range_expansion_none_when_insufficient() {
7113 assert!(OhlcvSeries::new().range_expansion(3).is_none());
7114 }
7115
7116 #[test]
7117 fn test_ohlcv_series_range_expansion_constant_returns_one() {
7118 let mut series = OhlcvSeries::new();
7119 for _ in 0..5 {
7120 series.push(make_bar("100", "110", "90", "100")).unwrap();
7121 }
7122 assert_eq!(series.range_expansion(5).unwrap(), dec!(1));
7124 }
7125
7126 #[test]
7127 fn test_ohlcv_series_bearish_engulfing_count_zero_when_short() {
7128 assert_eq!(OhlcvSeries::new().bearish_engulfing_count(5), 0);
7129 }
7130
7131 #[test]
7132 fn test_ohlcv_series_bearish_engulfing_count_detects_pattern() {
7133 let mut series = OhlcvSeries::new();
7134 series.push(make_bar("95", "110", "90", "105")).unwrap();
7136 series.push(make_bar("110", "115", "88", "90")).unwrap();
7138 assert_eq!(series.bearish_engulfing_count(2), 1);
7139 }
7140
7141 #[test]
7142 fn test_ohlcv_series_trend_strength_none_when_insufficient() {
7143 let mut series = OhlcvSeries::new();
7144 series.push(make_bar("100", "110", "90", "100")).unwrap();
7145 assert!(series.trend_strength(2).is_none());
7146 }
7147
7148 #[test]
7149 fn test_ohlcv_series_trend_strength_pure_trend_is_one() {
7150 let mut series = OhlcvSeries::new();
7152 for c in ["100", "110", "120", "130"] {
7153 series.push(make_bar(c, "135", "95", c)).unwrap();
7154 }
7155 assert_eq!(series.trend_strength(4).unwrap(), dec!(1));
7156 }
7157
7158 #[test]
7159 fn test_ohlcv_series_close_location_value_none_when_insufficient() {
7160 assert!(OhlcvSeries::new().close_location_value(1).is_none());
7161 }
7162
7163 #[test]
7164 fn test_ohlcv_series_close_location_value_close_at_high() {
7165 let mut series = OhlcvSeries::new();
7167 series.push(make_bar("100", "110", "90", "110")).unwrap();
7168 assert_eq!(series.close_location_value(1).unwrap(), dec!(1));
7169 }
7170
7171 #[test]
7172 fn test_ohlcv_series_close_location_value_close_at_midpoint() {
7173 let mut series = OhlcvSeries::new();
7175 series.push(make_bar("100", "110", "90", "100")).unwrap();
7176 assert_eq!(series.close_location_value(1).unwrap(), dec!(0));
7177 }
7178
7179 #[test]
7180 fn test_ohlcv_series_mean_close_empty_returns_none() {
7181 assert!(OhlcvSeries::new().mean_close(5).is_none());
7182 }
7183
7184 #[test]
7185 fn test_ohlcv_series_mean_close_equal_prices() {
7186 let mut series = OhlcvSeries::new();
7187 series.push(make_bar("100", "110", "90", "100")).unwrap();
7188 series.push(make_bar("100", "110", "90", "100")).unwrap();
7189 series.push(make_bar("100", "110", "90", "100")).unwrap();
7190 assert_eq!(series.mean_close(3).unwrap(), dec!(100));
7191 }
7192
7193 #[test]
7194 fn test_ohlcv_series_mean_close_windowed() {
7195 let mut series = OhlcvSeries::new();
7197 series.push(make_bar("100", "100", "100", "100")).unwrap();
7198 series.push(make_bar("110", "110", "110", "110")).unwrap();
7199 series.push(make_bar("120", "120", "120", "120")).unwrap();
7200 assert_eq!(series.mean_close(2).unwrap(), dec!(115));
7201 }
7202
7203 #[test]
7204 fn test_ohlcv_series_std_dev_less_than_two_bars_returns_none() {
7205 let mut series = OhlcvSeries::new();
7206 series.push(make_bar("100", "110", "90", "100")).unwrap();
7207 assert!(series.std_dev(5).is_none());
7208 }
7209
7210 #[test]
7211 fn test_ohlcv_series_std_dev_constant_prices_is_zero() {
7212 let mut series = OhlcvSeries::new();
7213 for _ in 0..4 {
7214 series.push(make_bar("100", "100", "100", "100")).unwrap();
7215 }
7216 assert_eq!(series.std_dev(4).unwrap(), dec!(0));
7217 }
7218
7219 #[test]
7220 fn test_ohlcv_bar_gap_pct_upward_gap() {
7221 let prev = make_bar("100", "110", "90", "100");
7222 let curr = make_bar("110", "120", "105", "115");
7223 assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(10));
7225 }
7226
7227 #[test]
7228 fn test_ohlcv_bar_gap_pct_downward_gap() {
7229 let prev = make_bar("100", "110", "90", "100");
7230 let curr = make_bar("90", "95", "85", "92");
7231 assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(-10));
7233 }
7234
7235 #[test]
7236 fn test_ohlcv_bar_gap_pct_no_gap() {
7237 let prev = make_bar("100", "110", "90", "100");
7238 let curr = make_bar("100", "110", "90", "105");
7239 assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(0));
7240 }
7241
7242 #[test]
7243 fn test_ohlcv_series_n_bars_ago_returns_correct_bar() {
7244 let mut series = OhlcvSeries::new();
7245 series.push(make_bar("100", "110", "90", "105")).unwrap();
7246 series.push(make_bar("105", "115", "95", "110")).unwrap();
7247 series.push(make_bar("110", "120", "100", "115")).unwrap();
7248 assert_eq!(series.n_bars_ago(0).unwrap().close.value(), dec!(115));
7249 assert_eq!(series.n_bars_ago(1).unwrap().close.value(), dec!(110));
7250 assert_eq!(series.n_bars_ago(2).unwrap().close.value(), dec!(105));
7251 }
7252
7253 #[test]
7254 fn test_ohlcv_series_n_bars_ago_out_of_bounds() {
7255 let mut series = OhlcvSeries::new();
7256 series.push(make_bar("100", "110", "90", "105")).unwrap();
7257 assert!(series.n_bars_ago(1).is_none());
7258 assert!(OhlcvSeries::new().n_bars_ago(0).is_none());
7259 }
7260
7261 #[test]
7262 fn test_ohlcv_bar_is_outside_bar_true() {
7263 let prev = make_bar("100", "110", "90", "105");
7264 let outside = make_bar("100", "120", "80", "110");
7265 assert!(outside.is_outside_bar(&prev));
7266 }
7267
7268 #[test]
7269 fn test_ohlcv_bar_is_outside_bar_false_for_inside() {
7270 let prev = make_bar("100", "120", "80", "110");
7271 let inside = make_bar("100", "110", "90", "105");
7272 assert!(!inside.is_outside_bar(&prev));
7273 }
7274
7275 #[test]
7276 fn test_ohlcv_bar_is_outside_bar_false_partial() {
7277 let prev = make_bar("100", "110", "90", "105");
7278 let partial = make_bar("100", "115", "92", "110");
7279 assert!(!partial.is_outside_bar(&prev));
7280 }
7281
7282 #[test]
7283 fn test_ohlcv_series_from_bars_valid() {
7284 let bars = vec![
7285 make_bar("100", "110", "90", "105"),
7286 make_bar("105", "115", "95", "110"),
7287 ];
7288 let series = OhlcvSeries::from_bars(bars).unwrap();
7289 assert_eq!(series.len(), 2);
7290 }
7291
7292 #[test]
7293 fn test_ohlcv_series_from_bars_empty() {
7294 let series = OhlcvSeries::from_bars(vec![]).unwrap();
7295 assert!(series.is_empty());
7296 }
7297
7298 #[test]
7299 fn test_ohlcv_series_count_bullish() {
7300 let mut series = OhlcvSeries::new();
7301 series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "95", "100")).unwrap(); series.push(make_bar("100", "110", "90", "108")).unwrap(); assert_eq!(series.count_bullish(3), 2);
7305 assert_eq!(series.count_bullish(1), 1); }
7307
7308 #[test]
7309 fn test_ohlcv_series_count_bearish() {
7310 let mut series = OhlcvSeries::new();
7311 series.push(make_bar("110", "115", "90", "100")).unwrap(); series.push(make_bar("105", "115", "95", "110")).unwrap(); assert_eq!(series.count_bearish(2), 1);
7314 assert_eq!(series.count_bearish(1), 0); }
7316
7317 #[test]
7318 fn test_ohlcv_series_count_bullish_exceeds_len() {
7319 let mut series = OhlcvSeries::new();
7320 series.push(make_bar("100", "110", "90", "105")).unwrap();
7321 assert_eq!(series.count_bullish(100), 1);
7322 }
7323
7324 #[test]
7325 fn test_ohlcv_series_median_close_empty() {
7326 assert!(OhlcvSeries::new().median_close(5).is_none());
7327 }
7328
7329 #[test]
7330 fn test_ohlcv_series_median_close_odd_count() {
7331 let mut series = OhlcvSeries::new();
7333 series.push(make_bar("100", "100", "100", "100")).unwrap();
7334 series.push(make_bar("110", "110", "110", "110")).unwrap();
7335 series.push(make_bar("120", "120", "120", "120")).unwrap();
7336 assert_eq!(series.median_close(3).unwrap(), dec!(110));
7337 }
7338
7339 #[test]
7340 fn test_ohlcv_series_median_close_even_count() {
7341 let mut series = OhlcvSeries::new();
7343 series.push(make_bar("100", "100", "100", "100")).unwrap();
7344 series.push(make_bar("110", "110", "110", "110")).unwrap();
7345 assert_eq!(series.median_close(2).unwrap(), dec!(105));
7346 }
7347
7348 #[test]
7349 fn test_ohlcv_series_percentile_rank_empty() {
7350 assert!(OhlcvSeries::new().percentile_rank(dec!(100), 5).is_none());
7351 }
7352
7353 #[test]
7354 fn test_ohlcv_series_percentile_rank_above_all() {
7355 let mut series = OhlcvSeries::new();
7357 for _ in 0..4 {
7358 series.push(make_bar("100", "100", "100", "100")).unwrap();
7359 }
7360 assert_eq!(series.percentile_rank(dec!(101), 4).unwrap(), dec!(100));
7361 }
7362
7363 #[test]
7364 fn test_ohlcv_series_percentile_rank_below_all() {
7365 let mut series = OhlcvSeries::new();
7367 for _ in 0..4 {
7368 series.push(make_bar("100", "100", "100", "100")).unwrap();
7369 }
7370 assert_eq!(series.percentile_rank(dec!(99), 4).unwrap(), dec!(0));
7371 }
7372
7373 #[test]
7374 fn test_ohlcv_series_consecutive_ups_empty() {
7375 assert_eq!(OhlcvSeries::new().consecutive_ups(), 0);
7376 }
7377
7378 #[test]
7379 fn test_ohlcv_series_consecutive_ups_all_bullish() {
7380 let mut series = OhlcvSeries::new();
7381 series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "95", "110")).unwrap(); assert_eq!(series.consecutive_ups(), 2);
7385 }
7386
7387 #[test]
7388 fn test_ohlcv_series_consecutive_ups_broken_by_bearish() {
7389 let mut series = OhlcvSeries::new();
7390 series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("110", "115", "95", "108")).unwrap(); series.push(make_bar("108", "115", "100", "112")).unwrap(); assert_eq!(series.consecutive_ups(), 1);
7394 }
7395
7396 #[test]
7397 fn test_ohlcv_series_consecutive_downs_counts_bearish_tail() {
7398 let mut series = OhlcvSeries::new();
7399 series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "110", "90", "100")).unwrap(); series.push(make_bar("100", "105", "85", "95")).unwrap(); assert_eq!(series.consecutive_downs(), 2);
7403 assert_eq!(series.consecutive_ups(), 0);
7404 }
7405
7406 #[test]
7407 fn test_ohlcv_bar_is_marubozu_full_body() {
7408 let bar = make_bar("100", "110", "100", "110");
7410 assert!(bar.is_marubozu());
7411 }
7412
7413 #[test]
7414 fn test_ohlcv_bar_is_marubozu_false_with_shadows() {
7415 let bar = make_bar("100", "115", "95", "110");
7416 assert!(!bar.is_marubozu());
7417 }
7418
7419 #[test]
7420 fn test_ohlcv_bar_is_spinning_top_true() {
7421 let bar = make_bar("100", "120", "80", "102");
7423 assert!(bar.is_spinning_top());
7424 }
7425
7426 #[test]
7427 fn test_ohlcv_bar_is_spinning_top_false_large_body() {
7428 let bar = make_bar("100", "115", "95", "114");
7430 assert!(!bar.is_spinning_top());
7431 }
7432
7433 #[test]
7434 fn test_ohlcv_series_average_volume_all_same() {
7435 let mut series = OhlcvSeries::new();
7437 series.push(make_bar("100", "110", "90", "105")).unwrap();
7438 series.push(make_bar("105", "115", "95", "110")).unwrap();
7439 assert_eq!(series.average_volume(2).unwrap(), dec!(100));
7440 }
7441
7442 #[test]
7443 fn test_ohlcv_series_average_range() {
7444 let mut series = OhlcvSeries::new();
7445 series.push(make_bar("100", "120", "80", "110")).unwrap(); series.push(make_bar("110", "125", "100", "115")).unwrap(); assert_eq!(series.average_range(2).unwrap(), dec!(32.5));
7448 }
7449
7450 #[test]
7451 fn test_ohlcv_series_average_volume_empty_returns_none() {
7452 let series = OhlcvSeries::new();
7453 assert!(series.average_volume(5).is_none());
7454 }
7455
7456 #[test]
7457 fn test_ohlcv_series_typical_price_mean_single_bar() {
7458 let mut series = OhlcvSeries::new();
7459 let bar = make_bar("100", "120", "80", "110");
7461 series.push(bar).unwrap();
7462 let tp = series.typical_price_mean(1).unwrap();
7463 let expected = (dec!(120) + dec!(80) + dec!(110)) / dec!(3);
7465 assert_eq!(tp, expected);
7466 }
7467
7468 #[test]
7469 fn test_ohlcv_series_below_sma_zero_when_all_above() {
7470 let mut series = OhlcvSeries::new();
7471 for _ in 0..3 { series.push(make_bar("100", "110", "90", "100")).unwrap(); }
7472 assert_eq!(series.below_sma(3, 3), 0);
7474 }
7475
7476 #[test]
7477 fn test_ohlcv_series_sortino_ratio_insufficient_data() {
7478 let mut series = OhlcvSeries::new();
7479 series.push(make_bar("100", "110", "90", "105")).unwrap();
7480 assert!(series.sortino_ratio(0.0, 252.0).is_none());
7481 }
7482
7483 #[test]
7484 fn test_ohlcv_bar_weighted_close_equals_hlcc4() {
7485 let bar = make_bar("100", "120", "80", "110");
7486 assert_eq!(bar.weighted_close(), bar.hlcc4());
7487 }
7488
7489 #[test]
7490 fn test_ohlcv_bar_weighted_close_value() {
7491 let bar = make_bar("100", "120", "80", "110");
7493 assert_eq!(bar.weighted_close(), dec!(105));
7494 }
7495
7496 #[test]
7497 fn test_close_above_open_streak_three_bullish() {
7498 let mut series = OhlcvSeries::new();
7499 series.push(make_bar("100", "110", "90", "95")).unwrap(); series.push(make_bar("95", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "100", "112")).unwrap(); series.push(make_bar("112", "120", "108", "118")).unwrap(); assert_eq!(series.close_above_open_streak(), 3);
7504 }
7505
7506 #[test]
7507 fn test_close_above_open_streak_last_bearish_returns_zero() {
7508 let mut series = OhlcvSeries::new();
7509 series.push(make_bar("105", "110", "100", "102")).unwrap(); series.push(make_bar("102", "108", "98", "99")).unwrap(); assert_eq!(series.close_above_open_streak(), 0);
7512 }
7513
7514 #[test]
7515 fn test_close_above_open_streak_empty_series_returns_zero() {
7516 assert_eq!(OhlcvSeries::new().close_above_open_streak(), 0);
7517 }
7518
7519 #[test]
7520 fn test_max_drawdown_pct_declining_series() {
7521 let mut series = OhlcvSeries::new();
7522 series.push(make_bar("100", "110", "90", "100")).unwrap();
7523 series.push(make_bar("100", "105", "75", "80")).unwrap(); series.push(make_bar("80", "85", "75", "84")).unwrap();
7525 let dd = series.max_drawdown_pct(10).unwrap();
7526 assert!((dd - 20.0).abs() < 1e-6, "expected ~20, got {dd}");
7527 }
7528
7529 #[test]
7530 fn test_max_drawdown_pct_flat_returns_zero() {
7531 let mut series = OhlcvSeries::new();
7532 series.push(make_bar("100", "110", "90", "100")).unwrap();
7533 series.push(make_bar("100", "110", "90", "100")).unwrap();
7534 assert_eq!(series.max_drawdown_pct(10).unwrap(), 0.0);
7535 }
7536
7537 #[test]
7538 fn test_max_drawdown_pct_single_bar_returns_none() {
7539 let mut series = OhlcvSeries::new();
7540 series.push(make_bar("100", "110", "90", "100")).unwrap();
7541 assert!(series.max_drawdown_pct(10).is_none());
7542 }
7543
7544 #[test]
7545 fn test_ohlcv_bar_gap_up_from_prev() {
7546 let prev = make_bar("100", "105", "95", "103");
7547 let curr = make_bar("107", "115", "106", "112"); assert!(curr.gap_up_from(&prev));
7549 }
7550
7551 #[test]
7552 fn test_ohlcv_bar_no_gap_up() {
7553 let prev = make_bar("100", "110", "90", "105");
7554 let curr = make_bar("105", "112", "104", "108"); assert!(!curr.gap_up_from(&prev));
7556 }
7557
7558 #[test]
7559 fn test_ohlcv_bar_gap_down_from_prev() {
7560 let prev = make_bar("100", "105", "95", "97");
7561 let curr = make_bar("93", "94", "88", "90"); assert!(curr.gap_down_from(&prev));
7563 }
7564
7565 #[test]
7566 fn test_ohlcv_bar_no_gap_down() {
7567 let prev = make_bar("100", "110", "90", "95");
7568 let curr = make_bar("96", "100", "92", "98"); assert!(!curr.gap_down_from(&prev));
7570 }
7571
7572 #[test]
7573 fn test_ohlcv_series_last_n_closes_returns_n() {
7574 let mut series = OhlcvSeries::new();
7575 for close in &["100", "102", "104", "106", "108"] {
7576 series.push(make_bar(close, "115", "95", close)).unwrap();
7577 }
7578 let closes = series.last_n_closes(3);
7579 assert_eq!(closes.len(), 3);
7580 assert_eq!(closes[2], dec!(108));
7581 }
7582
7583 #[test]
7584 fn test_ohlcv_series_last_n_closes_fewer_than_n() {
7585 let mut series = OhlcvSeries::new();
7586 series.push(make_bar("100", "110", "90", "100")).unwrap();
7587 let closes = series.last_n_closes(5);
7588 assert_eq!(closes.len(), 1);
7589 }
7590
7591 #[test]
7592 fn test_ohlcv_series_volume_spike_detects_spike() {
7593 use crate::types::{NanoTimestamp, Quantity, Symbol};
7594 let sym = Symbol::new("X").unwrap();
7595 let p = crate::types::Price::new(dec!(100)).unwrap();
7596 let mut series = OhlcvSeries::new();
7597 for _ in 0..3 {
7599 series.push(OhlcvBar {
7600 symbol: sym.clone(), open: p, high: p, low: p, close: p,
7601 volume: Quantity::new(dec!(100)).unwrap(),
7602 ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
7603 }).unwrap();
7604 }
7605 series.push(OhlcvBar {
7607 symbol: sym.clone(), open: p, high: p, low: p, close: p,
7608 volume: Quantity::new(dec!(500)).unwrap(),
7609 ts_open: NanoTimestamp::new(2), ts_close: NanoTimestamp::new(3), tick_count: 1,
7610 }).unwrap();
7611 assert!(series.volume_spike(3, dec!(3)));
7612 }
7613
7614 #[test]
7615 fn test_ohlcv_series_volume_spike_false_for_normal_volume() {
7616 use crate::types::{NanoTimestamp, Quantity, Symbol};
7617 let sym = Symbol::new("X").unwrap();
7618 let p = crate::types::Price::new(dec!(100)).unwrap();
7619 let mut series = OhlcvSeries::new();
7620 for _ in 0..4 {
7621 series.push(OhlcvBar {
7622 symbol: sym.clone(), open: p, high: p, low: p, close: p,
7623 volume: Quantity::new(dec!(100)).unwrap(),
7624 ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
7625 }).unwrap();
7626 }
7627 assert!(!series.volume_spike(3, dec!(3)));
7628 }
7629
7630 #[test]
7631 fn test_efficiency_ratio_trending() {
7632 let mut series = OhlcvSeries::new();
7633 for i in 0..6u32 {
7635 series.push(make_bar(&format!("{}", 100 + i), &format!("{}", 105 + i), &format!("{}", 99 + i), &format!("{}", 100 + i))).unwrap();
7636 }
7637 let er = series.efficiency_ratio(5).unwrap();
7638 assert_eq!(er, dec!(1));
7639 }
7640
7641 #[test]
7642 fn test_efficiency_ratio_none_when_not_enough_bars() {
7643 let mut series = OhlcvSeries::new();
7644 series.push(make_bar("100", "110", "90", "100")).unwrap();
7645 assert!(series.efficiency_ratio(5).is_none());
7646 }
7647
7648 #[test]
7649 fn test_efficiency_ratio_zero_period_returns_none() {
7650 let series = OhlcvSeries::new();
7651 assert!(series.efficiency_ratio(0).is_none());
7652 }
7653
7654 #[test]
7655 fn test_body_pct_series_full_body() {
7656 let mut series = OhlcvSeries::new();
7657 series.push(make_bar("90", "110", "90", "110")).unwrap();
7659 let v = series.body_pct_series(1);
7660 assert_eq!(v.len(), 1);
7661 assert_eq!(v[0], Some(dec!(100)));
7662 }
7663
7664 #[test]
7665 fn test_body_pct_series_zero_range_returns_none() {
7666 let mut series = OhlcvSeries::new();
7667 series.push(make_bar("100", "100", "100", "100")).unwrap();
7668 let v = series.body_pct_series(1);
7669 assert_eq!(v[0], None);
7670 }
7671
7672 #[test]
7673 fn test_candle_color_changes_alternating() {
7674 let mut series = OhlcvSeries::new();
7675 series.push(make_bar("95", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "100", "102")).unwrap(); series.push(make_bar("102", "115", "98", "110")).unwrap(); assert_eq!(series.candle_color_changes(3), 2);
7680 }
7681
7682 #[test]
7683 fn test_candle_color_changes_no_changes() {
7684 let mut series = OhlcvSeries::new();
7685 for _ in 0..3 {
7687 series.push(make_bar("95", "110", "90", "105")).unwrap();
7688 }
7689 assert_eq!(series.candle_color_changes(3), 0);
7690 }
7691
7692 #[test]
7693 fn test_typical_price_series_values() {
7694 let mut series = OhlcvSeries::new();
7695 series.push(make_bar("95", "110", "90", "100")).unwrap();
7697 let v = series.typical_price_series(1);
7698 assert_eq!(v.len(), 1);
7699 assert_eq!(v[0], dec!(100));
7700 }
7701
7702 #[test]
7703 fn test_typical_price_series_empty_series_returns_empty() {
7704 let series = OhlcvSeries::new();
7705 assert!(series.typical_price_series(3).is_empty());
7706 }
7707
7708 #[test]
7709 fn test_bar_at_index_valid() {
7710 let bars = vec![bar("100"), bar("101"), bar("102")];
7711 let series = OhlcvSeries::from_bars(bars).unwrap();
7712 assert!(series.bar_at_index(0).is_some());
7713 assert_eq!(series.bar_at_index(2).unwrap().close.value(), dec!(102));
7714 }
7715
7716 #[test]
7717 fn test_bar_at_index_out_of_bounds() {
7718 let bars = vec![bar("100")];
7719 let series = OhlcvSeries::from_bars(bars).unwrap();
7720 assert!(series.bar_at_index(5).is_none());
7721 }
7722
7723 #[test]
7724 fn test_rolling_close_std_returns_none_for_fewer_than_two() {
7725 let bars = vec![bar("100")];
7726 let series = OhlcvSeries::from_bars(bars).unwrap();
7727 assert!(series.rolling_close_std(1).is_none());
7728 }
7729
7730 #[test]
7731 fn test_rolling_close_std_constant_prices_is_zero() {
7732 let bars = vec![bar("100"), bar("100"), bar("100")];
7733 let series = OhlcvSeries::from_bars(bars).unwrap();
7734 let std = series.rolling_close_std(3).unwrap();
7735 assert_eq!(std, Decimal::ZERO);
7736 }
7737
7738 #[test]
7739 fn test_rolling_close_std_varying_prices_positive() {
7740 let bars = vec![bar("100"), bar("110"), bar("120"), bar("130")];
7741 let series = OhlcvSeries::from_bars(bars).unwrap();
7742 let std = series.rolling_close_std(4).unwrap();
7743 assert!(std > Decimal::ZERO);
7744 }
7745
7746 #[test]
7747 fn test_gap_direction_series_empty_for_single_bar() {
7748 let bars = vec![bar("100")];
7749 let series = OhlcvSeries::from_bars(bars).unwrap();
7750 assert!(series.gap_direction_series(3).is_empty());
7751 }
7752
7753 #[test]
7754 fn test_gap_direction_series_flat_on_equal_prices() {
7755 let bars = vec![bar("100"), bar("100"), bar("100")];
7756 let series = OhlcvSeries::from_bars(bars).unwrap();
7757 let gaps = series.gap_direction_series(3);
7758 assert!(gaps.iter().all(|&g| g == 0));
7759 }
7760
7761 #[test]
7762 fn test_gap_direction_series_detects_gap_up() {
7763 let p1 = Price::new(dec!(100)).unwrap();
7765 let p2 = Price::new(dec!(110)).unwrap();
7766 let b1 = OhlcvBar {
7767 symbol: Symbol::new("X").unwrap(),
7768 open: p1, high: p1, low: p1, close: p1,
7769 volume: Quantity::zero(),
7770 ts_open: NanoTimestamp::new(0),
7771 ts_close: NanoTimestamp::new(1),
7772 tick_count: 1,
7773 };
7774 let b2 = OhlcvBar {
7775 symbol: Symbol::new("X").unwrap(),
7776 open: p2, high: p2, low: p2, close: p2,
7777 volume: Quantity::zero(),
7778 ts_open: NanoTimestamp::new(2),
7779 ts_close: NanoTimestamp::new(3),
7780 tick_count: 1,
7781 };
7782 let series = OhlcvSeries::from_bars(vec![b1, b2]).unwrap();
7783 let gaps = series.gap_direction_series(2);
7784 assert_eq!(gaps, vec![1i8]);
7785 }
7786
7787 #[test]
7788 fn test_bullish_candle_pct_all_bullish() {
7789 let bars = vec![
7791 make_bar("95", "105", "94", "100"),
7792 make_bar("99", "110", "98", "108"),
7793 make_bar("107", "115", "106", "112"),
7794 ];
7795 let series = OhlcvSeries::from_bars(bars).unwrap();
7796 assert_eq!(series.bullish_candle_pct(3).unwrap(), 1.0);
7797 }
7798
7799 #[test]
7800 fn test_bullish_candle_pct_none_for_zero_n() {
7801 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7802 assert!(series.bullish_candle_pct(0).is_none());
7803 }
7804
7805 #[test]
7806 fn test_price_above_ma_pct_all_above() {
7807 let bars = vec![
7809 bar("100"), bar("102"), bar("104"), bar("106"), bar("108"),
7810 ];
7811 let series = OhlcvSeries::from_bars(bars).unwrap();
7812 let pct = series.price_above_ma_pct(3, 2).unwrap();
7814 assert!(pct > 0.0);
7815 }
7816
7817 #[test]
7818 fn test_price_above_ma_pct_insufficient_bars() {
7819 let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
7820 assert!(series.price_above_ma_pct(2, 3).is_none());
7821 }
7822
7823 #[test]
7824 fn test_avg_body_size_flat() {
7825 let bars = vec![bar("100"), bar("100"), bar("100")];
7827 let series = OhlcvSeries::from_bars(bars).unwrap();
7828 assert_eq!(series.avg_body_size(3).unwrap(), dec!(0));
7829 }
7830
7831 #[test]
7832 fn test_avg_body_size_none_for_zero_n() {
7833 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7834 assert!(series.avg_body_size(0).is_none());
7835 }
7836
7837 #[test]
7838 fn test_true_range_series_flat() {
7839 let bars = vec![bar("100"), bar("100"), bar("100")];
7840 let series = OhlcvSeries::from_bars(bars).unwrap();
7841 let trs = series.true_range_series(3).unwrap();
7842 assert_eq!(trs.len(), 3);
7843 for tr in trs {
7845 assert_eq!(tr, dec!(0));
7846 }
7847 }
7848
7849 #[test]
7850 fn test_true_range_series_none_when_insufficient() {
7851 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7852 assert!(series.true_range_series(0).is_none());
7853 assert!(series.true_range_series(2).is_none());
7854 }
7855
7856 #[test]
7857 fn test_intraday_return_pct_positive() {
7858 let make_bar = |o: &str, c: &str| {
7860 let op = Price::new(o.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
7861 let cl = Price::new(c.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
7862 OhlcvBar {
7863 symbol: Symbol::new("X").unwrap(),
7864 open: op, high: cl, low: op, close: cl,
7865 volume: Quantity::zero(),
7866 ts_open: NanoTimestamp::new(0),
7867 ts_close: NanoTimestamp::new(1),
7868 tick_count: 1,
7869 }
7870 };
7871 let series = OhlcvSeries::from_bars(vec![make_bar("100", "110")]).unwrap();
7872 assert_eq!(series.intraday_return_pct().unwrap(), dec!(10));
7874 }
7875
7876 #[test]
7877 fn test_intraday_return_pct_empty() {
7878 assert!(OhlcvSeries::new().intraday_return_pct().is_none());
7879 }
7880
7881 #[test]
7882 fn test_bearish_bar_count_all_flat() {
7883 let bars = vec![bar("100"), bar("100"), bar("100")];
7884 let series = OhlcvSeries::from_bars(bars).unwrap();
7885 assert_eq!(series.bearish_bar_count(3).unwrap(), 0);
7887 }
7888
7889 #[test]
7890 fn test_bearish_bar_count_none_insufficient() {
7891 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7892 assert!(series.bearish_bar_count(0).is_none());
7893 assert!(series.bearish_bar_count(2).is_none());
7894 }
7895
7896 #[test]
7897 fn test_hl_midpoint_flat() {
7898 let bars = vec![bar("100"), bar("100"), bar("100")];
7899 let series = OhlcvSeries::from_bars(bars).unwrap();
7900 assert_eq!(series.hl_midpoint(3).unwrap(), dec!(100));
7901 }
7902
7903 #[test]
7904 fn test_hl_midpoint_none_when_insufficient() {
7905 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7906 assert!(series.hl_midpoint(0).is_none());
7907 assert!(series.hl_midpoint(2).is_none());
7908 }
7909
7910 #[test]
7911 fn test_up_volume_ratio_flat_bars() {
7912 let bars = vec![bar("100"), bar("100"), bar("100")];
7914 let series = OhlcvSeries::from_bars(bars).unwrap();
7915 let ratio = series.up_volume_ratio(3);
7917 if let Some(r) = ratio {
7918 assert_eq!(r, dec!(0));
7919 }
7920 }
7922
7923 #[test]
7924 fn test_price_efficiency_trending() {
7925 let bars: Vec<_> = (100..106u32).map(|i| bar(&i.to_string())).collect();
7927 let series = OhlcvSeries::from_bars(bars).unwrap();
7928 assert_eq!(series.price_efficiency(5).unwrap(), dec!(1));
7929 }
7930
7931 #[test]
7932 fn test_price_efficiency_none_insufficient() {
7933 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7934 assert!(series.price_efficiency(1).is_none());
7935 assert!(series.price_efficiency(3).is_none());
7936 }
7937
7938 #[test]
7939 fn test_avg_gap_zero_when_no_jumps() {
7940 let bars = vec![bar("100"), bar("100"), bar("100")];
7941 let series = OhlcvSeries::from_bars(bars).unwrap();
7942 assert_eq!(series.avg_gap(2).unwrap(), dec!(0));
7943 }
7944
7945 #[test]
7946 fn test_avg_gap_none_when_insufficient() {
7947 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7948 assert!(series.avg_gap(0).is_none());
7949 assert!(series.avg_gap(1).is_none());
7950 }
7951
7952 #[test]
7953 fn test_largest_gap_pct_no_gap() {
7954 let bars: Vec<_> = (0..5).map(|_| bar("100")).collect();
7956 let series = OhlcvSeries::from_bars(bars).unwrap();
7957 assert_eq!(series.largest_gap_pct(4).unwrap(), dec!(0));
7958 }
7959
7960 #[test]
7961 fn test_largest_gap_pct_none_insufficient() {
7962 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7963 assert!(series.largest_gap_pct(2).is_none());
7964 assert!(series.largest_gap_pct(1).is_none());
7965 }
7966
7967 #[test]
7968 fn test_close_momentum_flat_zero() {
7969 let bars: Vec<_> = (0..6).map(|_| bar("100")).collect();
7970 let series = OhlcvSeries::from_bars(bars).unwrap();
7971 assert_eq!(series.close_momentum(3).unwrap(), dec!(0));
7972 }
7973
7974 #[test]
7975 fn test_close_momentum_none_insufficient() {
7976 let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
7978 assert!(series.close_momentum(2).is_none()); assert!(series.close_momentum(0).is_none());
7980 }
7981
7982 #[test]
7983 fn test_swing_high_count_none_when_insufficient() {
7984 let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
7985 assert!(series.swing_high_count(5, 1).is_none()); assert!(series.swing_high_count(0, 1).is_none()); assert!(series.swing_high_count(2, 0).is_none()); }
7989
7990 #[test]
7991 fn test_swing_high_count_detects_peak() {
7992 let bars = vec![bar("100"), bar("110"), bar("100"), bar("100"), bar("100")];
7994 let series = OhlcvSeries::from_bars(bars).unwrap();
7995 let count = series.swing_high_count(5, 1).unwrap();
7996 assert_eq!(count, 1);
7997 }
7998
7999 #[test]
8000 fn test_swing_high_count_flat_no_highs() {
8001 let bars: Vec<_> = (0..7).map(|_| bar("100")).collect();
8002 let series = OhlcvSeries::from_bars(bars).unwrap();
8003 assert_eq!(series.swing_high_count(7, 1).unwrap(), 0);
8004 }
8005
8006 #[test]
8007 fn test_avg_wick_pct_none_when_zero_range() {
8008 let bars = vec![bar("100"), bar("100")];
8009 let series = OhlcvSeries::from_bars(bars).unwrap();
8010 assert!(series.avg_wick_pct(2).is_none()); }
8012
8013 #[test]
8014 fn test_avg_wick_pct_none_insufficient() {
8015 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8016 assert!(series.avg_wick_pct(0).is_none());
8017 assert!(series.avg_wick_pct(2).is_none());
8018 }
8019
8020 #[test]
8021 fn test_trend_continuation_pct_none_insufficient() {
8022 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8023 assert!(series.trend_continuation_pct(0).is_none());
8024 assert!(series.trend_continuation_pct(1).is_none()); }
8026
8027 fn make_bar_vol(o: &str, h: &str, l: &str, c: &str, vol: &str) -> OhlcvBar {
8028 OhlcvBar {
8029 symbol: Symbol::new("X").unwrap(),
8030 open: make_price(o),
8031 high: make_price(h),
8032 low: make_price(l),
8033 close: make_price(c),
8034 volume: make_qty(vol),
8035 ts_open: NanoTimestamp::new(0),
8036 ts_close: NanoTimestamp::new(1),
8037 tick_count: 1,
8038 }
8039 }
8040
8041 #[test]
8042 fn test_close_to_open_ratio_bullish() {
8043 let bars = vec![
8045 make_bar_vol("100", "110", "95", "110", "1000"), make_bar_vol("105", "115", "100", "115", "1000"), ];
8048 let series = OhlcvSeries::from_bars(bars).unwrap();
8049 let ratio = series.close_to_open_ratio(2).unwrap();
8050 assert!(ratio > dec!(1), "bullish bars: ratio > 1, got {ratio}");
8051 }
8052
8053 #[test]
8054 fn test_close_to_open_ratio_none_zero_n() {
8055 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8056 assert!(series.close_to_open_ratio(0).is_none());
8057 }
8058
8059 #[test]
8060 fn test_volume_trend_rising() {
8061 let bars: Vec<OhlcvBar> = (1..=5u32).map(|i| {
8062 make_bar_vol("100", "100", "100", "100", &(i * 100).to_string())
8063 }).collect();
8064 let series = OhlcvSeries::from_bars(bars).unwrap();
8065 let slope = series.volume_trend(5).unwrap();
8066 assert!(slope > 0.0_f64, "rising volume: positive slope, got {slope}");
8067 }
8068
8069 #[test]
8070 fn test_volume_trend_none_insufficient() {
8071 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8072 assert!(series.volume_trend(0).is_none());
8073 assert!(series.volume_trend(2).is_none()); }
8075
8076 #[test]
8077 fn test_high_volume_price_returns_close_of_max_vol_bar() {
8078 let bars = vec![
8079 make_bar_vol("100", "100", "100", "100", "500"),
8080 make_bar_vol("200", "200", "200", "200", "1000"), make_bar_vol("150", "150", "150", "150", "300"),
8082 ];
8083 let series = OhlcvSeries::from_bars(bars).unwrap();
8084 assert_eq!(series.high_volume_price(3), Some(dec!(200)));
8085 }
8086
8087 #[test]
8088 fn test_high_volume_price_none_zero_n() {
8089 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8090 assert!(series.high_volume_price(0).is_none());
8091 }
8092
8093 #[test]
8094 fn test_avg_close_minus_open_bullish() {
8095 let bars = vec![
8096 make_bar_vol("100", "110", "95", "105", "1000"), make_bar_vol("105", "115", "100", "108", "1000"), ];
8099 let series = OhlcvSeries::from_bars(bars).unwrap();
8100 let avg = series.avg_close_minus_open(2).unwrap();
8101 assert_eq!(avg, dec!(4)); }
8103
8104 #[test]
8105 fn test_avg_close_minus_open_none_zero_n() {
8106 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8107 assert!(series.avg_close_minus_open(0).is_none());
8108 }
8109}