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::new(30, 2);
276 let threshold_20 = Decimal::new(20, 2);
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_or(false, |g| g.abs() >= pct_threshold)
463 }
464
465 pub fn from_tick(tick: &Tick) -> Self {
470 Self {
471 symbol: tick.symbol.clone(),
472 open: tick.price,
473 high: tick.price,
474 low: tick.price,
475 close: tick.price,
476 volume: tick.quantity,
477 ts_open: tick.timestamp,
478 ts_close: tick.timestamp,
479 tick_count: 1,
480 }
481 }
482
483 pub fn merge(&self, other: &OhlcvBar) -> Result<OhlcvBar, FinError> {
495 let high = self.high.value().max(other.high.value());
496 let low = self.low.value().min(other.low.value());
497 let volume_sum = self.volume.value() + other.volume.value();
498 let bar = OhlcvBar {
499 symbol: self.symbol.clone(),
500 open: self.open,
501 high: Price::new(high)?,
502 low: Price::new(low)?,
503 close: other.close,
504 volume: Quantity::new(volume_sum)?,
505 ts_open: self.ts_open,
506 ts_close: other.ts_close,
507 tick_count: self.tick_count + other.tick_count,
508 };
509 bar.validate()?;
510 Ok(bar)
511 }
512
513 pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
521 let prev_bearish = prev.open.value() > prev.close.value();
522 let self_bullish = self.close.value() > self.open.value();
523 prev_bearish
524 && self_bullish
525 && self.open.value() <= prev.close.value()
526 && self.close.value() >= prev.open.value()
527 }
528
529 pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
537 let prev_bullish = prev.close.value() > prev.open.value();
538 let self_bearish = self.open.value() > self.close.value();
539 prev_bullish
540 && self_bearish
541 && self.open.value() >= prev.close.value()
542 && self.close.value() <= prev.open.value()
543 }
544
545}
546
547#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
549pub enum Timeframe {
550 Seconds(u32),
552 Minutes(u32),
554 Hours(u32),
556 Days(u32),
558 Weeks(u32),
560}
561
562impl Timeframe {
563 pub fn to_nanos(&self) -> Result<i64, FinError> {
568 let secs: u64 = match self {
569 Timeframe::Seconds(n) => u64::from(*n),
570 Timeframe::Minutes(n) => u64::from(*n) * 60,
571 Timeframe::Hours(n) => u64::from(*n) * 3_600,
572 Timeframe::Days(n) => u64::from(*n) * 86_400,
573 Timeframe::Weeks(n) => u64::from(*n) * 7 * 86_400,
574 };
575 if secs == 0 {
576 return Err(FinError::InvalidTimeframe);
577 }
578 #[allow(clippy::cast_possible_wrap)]
579 Ok((secs * 1_000_000_000) as i64)
580 }
581
582 pub fn bucket_start(&self, ts: NanoTimestamp) -> Result<NanoTimestamp, FinError> {
587 let nanos = self.to_nanos()?;
588 let bucket = (ts.nanos() / nanos) * nanos;
589 Ok(NanoTimestamp::new(bucket))
590 }
591}
592
593pub struct OhlcvAggregator {
600 symbol: Symbol,
601 timeframe: Timeframe,
602 current_bar: Option<OhlcvBar>,
603 current_bucket_start: Option<NanoTimestamp>,
604 last_close: Option<Price>,
606 bars_emitted: usize,
608}
609
610impl OhlcvAggregator {
611 pub fn new(symbol: Symbol, timeframe: Timeframe) -> Result<Self, FinError> {
616 timeframe.to_nanos()?;
617 Ok(Self {
618 symbol,
619 timeframe,
620 current_bar: None,
621 current_bucket_start: None,
622 last_close: None,
623 bars_emitted: 0,
624 })
625 }
626
627 pub fn push_tick(&mut self, tick: &Tick) -> Result<Vec<OhlcvBar>, FinError> {
640 if tick.symbol != self.symbol {
641 return Ok(vec![]);
642 }
643 let bucket = self.timeframe.bucket_start(tick.timestamp)?;
644 match self.current_bucket_start {
645 None => {
646 self.current_bucket_start = Some(bucket);
647 self.current_bar = Some(self.new_bar(tick));
648 Ok(vec![])
649 }
650 Some(current_bucket) if bucket == current_bucket => {
651 self.update_bar(tick);
652 Ok(vec![])
653 }
654 Some(_) => {
655 let completed = self.current_bar.take().expect("current bar must be Some here");
656 self.last_close = Some(completed.close);
657
658 let mut out = vec![completed];
660 let nanos = self.timeframe.to_nanos()?;
661 let prev_bucket = self.current_bucket_start.expect("set above");
662 let mut gap_bucket = NanoTimestamp::new(prev_bucket.nanos() + nanos);
663 while gap_bucket < bucket {
664 if let Some(close) = self.last_close {
665 out.push(OhlcvBar {
666 symbol: self.symbol.clone(),
667 open: close,
668 high: close,
669 low: close,
670 close,
671 volume: Quantity::zero(),
672 ts_open: gap_bucket,
673 ts_close: gap_bucket,
674 tick_count: 0,
675 });
676 }
677 gap_bucket = NanoTimestamp::new(gap_bucket.nanos() + nanos);
678 }
679
680 self.bars_emitted += out.len();
681 self.current_bucket_start = Some(bucket);
682 self.current_bar = Some(self.new_bar(tick));
683 Ok(out)
684 }
685 }
686 }
687
688 pub fn flush(&mut self) -> Option<OhlcvBar> {
690 self.current_bucket_start = None;
691 let bar = self.current_bar.take();
692 if let Some(ref b) = bar {
693 self.last_close = Some(b.close);
694 self.bars_emitted += 1;
695 }
696 bar
697 }
698
699 pub fn symbol(&self) -> &Symbol {
701 &self.symbol
702 }
703
704 pub fn timeframe(&self) -> Timeframe {
706 self.timeframe
707 }
708
709 pub fn reset(&mut self) {
714 self.current_bar = None;
715 self.current_bucket_start = None;
716 self.last_close = None;
717 self.bars_emitted = 0;
718 }
719
720 pub fn bar_count(&self) -> usize {
722 self.bars_emitted
723 }
724
725 pub fn current_bar(&self) -> Option<&OhlcvBar> {
727 self.current_bar.as_ref()
728 }
729
730 pub fn current_bar_open_ts(&self) -> Option<NanoTimestamp> {
735 self.current_bucket_start
736 }
737
738 fn new_bar(&self, tick: &Tick) -> OhlcvBar {
739 OhlcvBar {
740 symbol: self.symbol.clone(),
741 open: tick.price,
742 high: tick.price,
743 low: tick.price,
744 close: tick.price,
745 volume: tick.quantity,
746 ts_open: tick.timestamp,
747 ts_close: tick.timestamp,
748 tick_count: 1,
749 }
750 }
751
752 fn update_bar(&mut self, tick: &Tick) {
753 if let Some(ref mut bar) = self.current_bar {
754 if tick.price > bar.high {
755 bar.high = tick.price;
756 }
757 if tick.price < bar.low {
758 bar.low = tick.price;
759 }
760 bar.close = tick.price;
761 bar.volume =
762 Quantity::new(bar.volume.value() + tick.quantity.value()).unwrap_or(bar.volume);
763 bar.ts_close = tick.timestamp;
764 bar.tick_count += 1;
765 }
766 }
767}
768
769pub struct OhlcvSeries {
771 bars: Vec<OhlcvBar>,
772}
773
774impl OhlcvSeries {
775 pub fn new() -> Self {
777 Self { bars: Vec::new() }
778 }
779
780 pub fn from_bars(bars: Vec<OhlcvBar>) -> Result<Self, FinError> {
785 for bar in &bars {
786 bar.validate()?;
787 }
788 Ok(Self { bars })
789 }
790
791 pub fn with_capacity(capacity: usize) -> Self {
795 Self {
796 bars: Vec::with_capacity(capacity),
797 }
798 }
799
800 pub fn push(&mut self, bar: OhlcvBar) -> Result<(), FinError> {
805 bar.validate()?;
806 self.bars.push(bar);
807 Ok(())
808 }
809
810 pub fn len(&self) -> usize {
812 self.bars.len()
813 }
814
815 pub fn is_empty(&self) -> bool {
817 self.bars.is_empty()
818 }
819
820 pub fn clear(&mut self) {
822 self.bars.clear();
823 }
824
825 pub fn retain(&mut self, mut predicate: impl FnMut(&OhlcvBar) -> bool) {
829 self.bars.retain(|b| predicate(b));
830 }
831
832 pub fn get(&self, index: usize) -> Option<&OhlcvBar> {
834 self.bars.get(index)
835 }
836
837 pub fn first(&self) -> Option<&OhlcvBar> {
839 self.bars.first()
840 }
841
842 pub fn last(&self) -> Option<&OhlcvBar> {
844 self.bars.last()
845 }
846
847 pub fn n_bars_ago(&self, n: usize) -> Option<&OhlcvBar> {
852 let len = self.bars.len();
853 if n >= len {
854 return None;
855 }
856 self.bars.get(len - 1 - n)
857 }
858
859 pub fn window(&self, n: usize) -> &[OhlcvBar] {
861 let len = self.bars.len();
862 if n >= len {
863 &self.bars
864 } else {
865 &self.bars[len - n..]
866 }
867 }
868
869 pub fn iter(&self) -> std::slice::Iter<'_, OhlcvBar> {
871 self.bars.iter()
872 }
873
874 pub fn consecutive_ups(&self) -> usize {
878 self.bars
879 .iter()
880 .rev()
881 .take_while(|b| b.is_bullish())
882 .count()
883 }
884
885 pub fn consecutive_downs(&self) -> usize {
889 self.bars
890 .iter()
891 .rev()
892 .take_while(|b| b.is_bearish())
893 .count()
894 }
895
896 pub fn opens(&self) -> Vec<Decimal> {
898 self.bars.iter().map(|b| b.open.value()).collect()
899 }
900
901 pub fn highs(&self) -> Vec<Decimal> {
903 self.bars.iter().map(|b| b.high.value()).collect()
904 }
905
906 pub fn lows(&self) -> Vec<Decimal> {
908 self.bars.iter().map(|b| b.low.value()).collect()
909 }
910
911 pub fn closes(&self) -> Vec<Decimal> {
913 self.bars.iter().map(|b| b.close.value()).collect()
914 }
915
916 pub fn volumes(&self) -> Vec<Decimal> {
918 self.bars.iter().map(|b| b.volume.value()).collect()
919 }
920
921 pub fn typical_prices(&self) -> Vec<Decimal> {
923 self.bars.iter().map(|b| b.typical_price()).collect()
924 }
925
926 pub fn bars(&self) -> &[OhlcvBar] {
928 &self.bars
929 }
930
931 pub fn max_high(&self) -> Option<Decimal> {
933 self.bars.iter().map(|b| b.high.value()).reduce(Decimal::max)
934 }
935
936 pub fn min_low(&self) -> Option<Decimal> {
938 self.bars.iter().map(|b| b.low.value()).reduce(Decimal::min)
939 }
940
941 pub fn highest_high(&self, n: usize) -> Option<Decimal> {
945 let start = self.bars.len().saturating_sub(n);
946 self.bars[start..].iter().map(|b| b.high.value()).reduce(Decimal::max)
947 }
948
949 pub fn lowest_low(&self, n: usize) -> Option<Decimal> {
953 let start = self.bars.len().saturating_sub(n);
954 self.bars[start..].iter().map(|b| b.low.value()).reduce(Decimal::min)
955 }
956
957 pub fn vwap(&self) -> Option<Decimal> {
962 if self.bars.is_empty() {
963 return None;
964 }
965 let total_vol: Decimal = self.bars.iter().map(|b| b.volume.value()).sum();
966 if total_vol == Decimal::ZERO {
967 return None;
968 }
969 let weighted_sum: Decimal = self
970 .bars
971 .iter()
972 .map(|b| b.typical_price() * b.volume.value())
973 .sum();
974 Some(weighted_sum / total_vol)
975 }
976
977 pub fn sum_volume(&self) -> Decimal {
979 self.bars.iter().map(|b| b.volume.value()).sum()
980 }
981
982 pub fn avg_volume(&self, n: usize) -> Option<Decimal> {
984 if n == 0 || self.bars.len() < n {
985 return None;
986 }
987 let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.volume.value()).sum();
988 #[allow(clippy::cast_possible_truncation)]
989 Some(sum / Decimal::from(n as u32))
990 }
991
992 pub fn price_range(&self, n: usize) -> Option<Decimal> {
995 if n == 0 || self.bars.len() < n {
996 return None;
997 }
998 let hh = self.highest_high(n)?;
999 let ll = self.lowest_low(n)?;
1000 Some(hh - ll)
1001 }
1002
1003 pub fn close_location_value(&self, n: usize) -> Option<Decimal> {
1010 if n == 0 || self.bars.len() < n {
1011 return None;
1012 }
1013 let start = self.bars.len() - n;
1014 let sum: Decimal = self.bars[start..].iter().map(|b| {
1015 let h = b.high.value();
1016 let l = b.low.value();
1017 let c = b.close.value();
1018 let range = h - l;
1019 if range == Decimal::ZERO { Decimal::ZERO } else { ((c - l) - (h - c)) / range }
1020 }).sum();
1021 #[allow(clippy::cast_possible_truncation)]
1022 Some(sum / Decimal::from(n as u32))
1023 }
1024
1025 pub fn avg_dollar_volume(&self, n: usize) -> Option<Decimal> {
1031 if n == 0 || self.bars.len() < n {
1032 return None;
1033 }
1034 let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.dollar_volume()).sum();
1035 Some(sum / Decimal::from(n as u64))
1036 }
1037
1038 pub fn slice(&self, from: usize, to: usize) -> Option<&[OhlcvBar]> {
1040 if from > to || to > self.bars.len() {
1041 return None;
1042 }
1043 Some(&self.bars[from..to])
1044 }
1045
1046 pub fn truncate(&mut self, n: usize) {
1050 let len = self.bars.len();
1051 if n < len {
1052 self.bars.drain(0..len - n);
1053 }
1054 }
1055
1056 pub fn extend(&mut self, bars: impl IntoIterator<Item = OhlcvBar>) -> Result<(), FinError> {
1063 for bar in bars {
1064 self.push(bar)?;
1065 }
1066 Ok(())
1067 }
1068
1069 pub fn extend_from_series(&mut self, other: &OhlcvSeries) -> Result<(), FinError> {
1074 for bar in &other.bars {
1075 self.push(bar.clone())?;
1076 }
1077 Ok(())
1078 }
1079
1080 pub fn to_bar_inputs(&self) -> Vec<crate::signals::BarInput> {
1085 self.bars
1086 .iter()
1087 .map(crate::signals::BarInput::from)
1088 .collect()
1089 }
1090
1091 pub fn apply_signal(
1100 &self,
1101 signal: &mut dyn crate::signals::Signal,
1102 ) -> Result<Vec<crate::signals::SignalValue>, FinError> {
1103 self.bars.iter().map(|b| signal.update_bar(b)).collect()
1104 }
1105
1106 pub fn returns(&self) -> Vec<Decimal> {
1111 if self.bars.len() < 2 {
1112 return Vec::new();
1113 }
1114 self.bars
1115 .windows(2)
1116 .filter_map(|w| {
1117 let prev = w[0].close.value();
1118 if prev.is_zero() {
1119 return None;
1120 }
1121 Some((w[1].close.value() - prev) / prev)
1122 })
1123 .collect()
1124 }
1125
1126 pub fn highest_close(&self, n: usize) -> Option<Decimal> {
1130 let start = self.bars.len().saturating_sub(n);
1131 self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::max)
1132 }
1133
1134 pub fn lowest_close(&self, n: usize) -> Option<Decimal> {
1138 let start = self.bars.len().saturating_sub(n);
1139 self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::min)
1140 }
1141
1142 pub fn mean_close(&self, n: usize) -> Option<Decimal> {
1146 let start = self.bars.len().saturating_sub(n);
1147 let slice = &self.bars[start..];
1148 if slice.is_empty() {
1149 return None;
1150 }
1151 let sum: Decimal = slice.iter().map(|b| b.close.value()).sum();
1152 Some(sum / Decimal::from(slice.len() as u64))
1153 }
1154
1155 pub fn std_dev(&self, n: usize) -> Option<Decimal> {
1160 let start = self.bars.len().saturating_sub(n);
1161 let slice = &self.bars[start..];
1162 if slice.len() < 2 {
1163 return None;
1164 }
1165 let n_dec = Decimal::from(slice.len() as u64);
1166 let mean: Decimal = slice.iter().map(|b| b.close.value()).sum::<Decimal>() / n_dec;
1167 let variance: Decimal = slice
1168 .iter()
1169 .map(|b| { let d = b.close.value() - mean; d * d })
1170 .sum::<Decimal>()
1171 / n_dec;
1172 decimal_sqrt(variance).ok()
1173 }
1174
1175 pub fn median_close(&self, n: usize) -> Option<Decimal> {
1180 let start = self.bars.len().saturating_sub(n);
1181 let mut closes: Vec<Decimal> =
1182 self.bars[start..].iter().map(|b| b.close.value()).collect();
1183 if closes.is_empty() {
1184 return None;
1185 }
1186 closes.sort();
1187 let mid = closes.len() / 2;
1188 if closes.len() % 2 == 1 {
1189 Some(closes[mid])
1190 } else {
1191 Some((closes[mid - 1] + closes[mid]) / Decimal::TWO)
1192 }
1193 }
1194
1195 pub fn percentile_rank(&self, value: Decimal, n: usize) -> Option<Decimal> {
1201 let start = self.bars.len().saturating_sub(n);
1202 let slice = &self.bars[start..];
1203 if slice.is_empty() {
1204 return None;
1205 }
1206 let below = slice.iter().filter(|b| b.close.value() < value).count();
1207 Some(Decimal::from(below as u64) / Decimal::from(slice.len() as u64) * Decimal::ONE_HUNDRED)
1208 }
1209
1210 pub fn correlation(&self, other: &OhlcvSeries) -> Option<Decimal> {
1215 let n = self.bars.len().min(other.bars.len());
1216 if n < 2 {
1217 return None;
1218 }
1219 let xs: Vec<Decimal> = self.bars[self.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1220 let ys: Vec<Decimal> = other.bars[other.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1221 let n_dec = Decimal::from(n);
1222 let mean_x: Decimal = xs.iter().copied().sum::<Decimal>() / n_dec;
1223 let mean_y: Decimal = ys.iter().copied().sum::<Decimal>() / n_dec;
1224 let cov: Decimal = xs.iter().zip(ys.iter())
1225 .map(|(x, y)| (*x - mean_x) * (*y - mean_y))
1226 .sum::<Decimal>() / n_dec;
1227 let var_x: Decimal = xs.iter().map(|x| (*x - mean_x) * (*x - mean_x)).sum::<Decimal>() / n_dec;
1228 let var_y: Decimal = ys.iter().map(|y| (*y - mean_y) * (*y - mean_y)).sum::<Decimal>() / n_dec;
1229 if var_x.is_zero() || var_y.is_zero() {
1230 return None;
1231 }
1232 let std_x = decimal_sqrt(var_x).ok()?;
1234 let std_y = decimal_sqrt(var_y).ok()?;
1235 Some(cov / (std_x * std_y))
1236 }
1237
1238 pub fn rolling_sma(&self, period: usize) -> Vec<Option<Decimal>> {
1243 if period == 0 {
1244 return self.bars.iter().map(|_| None).collect();
1245 }
1246 let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1247 closes
1248 .windows(period)
1249 .enumerate()
1250 .fold(vec![None; closes.len()], |mut acc, (i, window)| {
1251 let sum: Decimal = window.iter().copied().sum();
1252 acc[i + period - 1] = Some(sum / Decimal::from(period as u64));
1253 acc
1254 })
1255 }
1256
1257 pub fn zscore(&self, period: usize) -> Vec<Option<Decimal>> {
1262 if period < 2 {
1263 return self.bars.iter().map(|_| None).collect();
1264 }
1265 let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1266 let n = closes.len();
1267 let mut result = vec![None; n];
1268 let period_dec = Decimal::from(period as u64);
1269 for i in (period - 1)..n {
1270 let window = &closes[(i + 1 - period)..=i];
1271 let mean: Decimal = window.iter().copied().sum::<Decimal>() / period_dec;
1272 let variance: Decimal = window
1273 .iter()
1274 .map(|x| (*x - mean) * (*x - mean))
1275 .sum::<Decimal>()
1276 / period_dec;
1277 if let Ok(std_dev) = decimal_sqrt(variance) {
1278 if !std_dev.is_zero() {
1279 result[i] = Some((closes[i] - mean) / std_dev);
1280 }
1281 }
1282 }
1283 result
1284 }
1285
1286 #[allow(clippy::cast_precision_loss)]
1293 pub fn log_returns(&self) -> Vec<f64> {
1294 if self.bars.len() < 2 {
1295 return Vec::new();
1296 }
1297 self.bars
1298 .windows(2)
1299 .filter_map(|w| {
1300 let prev = w[0].close.value();
1301 if prev.is_zero() {
1302 return None;
1303 }
1304 let ratio = w[1].close.value().checked_div(prev)?;
1305 use rust_decimal::prelude::ToPrimitive;
1306 let ratio_f64 = ratio.to_f64()?;
1307 if ratio_f64 > 0.0 {
1308 Some(ratio_f64.ln())
1309 } else {
1310 None
1311 }
1312 })
1313 .collect()
1314 }
1315
1316 pub fn cumulative_returns(&self) -> Vec<Decimal> {
1322 let first = match self.bars.first() {
1323 Some(b) => b.close.value(),
1324 None => return Vec::new(),
1325 };
1326 if first.is_zero() {
1327 return Vec::new();
1328 }
1329 self.bars
1330 .iter()
1331 .map(|b| b.close.value() / first - Decimal::ONE)
1332 .collect()
1333 }
1334
1335 pub fn resample(&self, n: usize) -> Result<Vec<OhlcvBar>, FinError> {
1343 if n == 0 || self.bars.is_empty() {
1344 return Ok(Vec::new());
1345 }
1346 let mut result = Vec::new();
1347 let mut chunks = self.bars.chunks(n);
1348 for chunk in &mut chunks {
1349 let mut merged = chunk[0].clone();
1350 for b in &chunk[1..] {
1351 merged = merged.merge(b)?;
1352 }
1353 result.push(merged);
1354 }
1355 Ok(result)
1356 }
1357
1358 pub fn max_drawdown(&self) -> Option<Decimal> {
1365 let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1366 if closes.is_empty() {
1367 return None;
1368 }
1369 let mut peak = closes[0];
1370 let mut max_dd = Decimal::ZERO;
1371 for &c in &closes[1..] {
1372 if c > peak {
1373 peak = c;
1374 } else if !peak.is_zero() {
1375 let dd = (peak - c) / peak;
1376 if dd > max_dd {
1377 max_dd = dd;
1378 }
1379 }
1380 }
1381 Some(max_dd)
1382 }
1383
1384 pub fn sharpe_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1393 let lr = self.log_returns();
1394 if lr.len() < 2 {
1395 return None;
1396 }
1397 let n = lr.len() as f64;
1398 let mean = lr.iter().sum::<f64>() / n;
1399 let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
1400 let std_dev = variance.sqrt();
1401 if std_dev == 0.0 {
1402 return None;
1403 }
1404 let bars_per_year = if bars_per_year <= 0.0 { 252.0 } else { bars_per_year };
1405 Some((mean - risk_free_rate) / std_dev * bars_per_year.sqrt())
1406 }
1407
1408 pub fn price_change_pct(&self, n: usize) -> Option<Decimal> {
1415 let len = self.bars.len();
1416 if len < n + 1 {
1417 return None;
1418 }
1419 let ref_close = self.bars[len - 1 - n].close.value();
1420 if ref_close.is_zero() {
1421 return None;
1422 }
1423 let last_close = self.bars[len - 1].close.value();
1424 Some((last_close - ref_close) / ref_close * Decimal::ONE_HUNDRED)
1425 }
1426
1427 pub fn count_bullish(&self, n: usize) -> usize {
1432 let start = self.bars.len().saturating_sub(n);
1433 self.bars[start..].iter().filter(|b| b.is_bullish()).count()
1434 }
1435
1436 pub fn count_bearish(&self, n: usize) -> usize {
1441 let start = self.bars.len().saturating_sub(n);
1442 self.bars[start..].iter().filter(|b| b.is_bearish()).count()
1443 }
1444
1445 pub fn count_inside_bars(&self) -> usize {
1450 self.bars
1451 .windows(2)
1452 .filter(|w| w[1].is_inside_bar(&w[0]))
1453 .count()
1454 }
1455
1456 pub fn count_outside_bars(&self) -> usize {
1461 self.bars
1462 .windows(2)
1463 .filter(|w| w[1].is_outside_bar(&w[0]))
1464 .count()
1465 }
1466
1467 pub fn pivot_highs(&self, n: usize) -> Vec<usize> {
1475 if n == 0 || self.bars.len() < 2 * n + 1 {
1476 return vec![];
1477 }
1478 let mut pivots = Vec::new();
1479 for i in n..self.bars.len() - n {
1480 let h = self.bars[i].high.value();
1481 let is_pivot = (1..=n).all(|j| {
1482 h > self.bars[i - j].high.value() && h > self.bars[i + j].high.value()
1483 });
1484 if is_pivot {
1485 pivots.push(i);
1486 }
1487 }
1488 pivots
1489 }
1490
1491 pub fn pivot_lows(&self, n: usize) -> Vec<usize> {
1499 if n == 0 || self.bars.len() < 2 * n + 1 {
1500 return vec![];
1501 }
1502 let mut pivots = Vec::new();
1503 for i in n..self.bars.len() - n {
1504 let l = self.bars[i].low.value();
1505 let is_pivot = (1..=n).all(|j| {
1506 l < self.bars[i - j].low.value() && l < self.bars[i + j].low.value()
1507 });
1508 if is_pivot {
1509 pivots.push(i);
1510 }
1511 }
1512 pivots
1513 }
1514
1515 #[allow(clippy::cast_possible_truncation)]
1520 pub fn above_sma(&self, period: usize, n: usize) -> usize {
1521 if self.bars.len() < period || period == 0 {
1522 return 0;
1523 }
1524 let start = self.bars.len().saturating_sub(n);
1525 let window_start = start.saturating_sub(period - 1);
1526 let mut count = 0usize;
1527 for i in start..self.bars.len() {
1528 if i + 1 < period {
1529 continue;
1530 }
1531 let sma_start = i + 1 - period;
1532 let sma: Decimal = self.bars[sma_start..=i]
1533 .iter()
1534 .map(|b| b.close.value())
1535 .sum::<Decimal>()
1536 / Decimal::from(period as u32);
1537 if self.bars[i].close.value() > sma {
1538 count += 1;
1539 }
1540 }
1541 let _ = window_start; count
1543 }
1544
1545 #[allow(clippy::cast_possible_truncation)]
1549 pub fn below_sma(&self, period: usize, n: usize) -> usize {
1550 if self.bars.len() < period || period == 0 {
1551 return 0;
1552 }
1553 let start = self.bars.len().saturating_sub(n);
1554 let mut count = 0usize;
1555 for i in start..self.bars.len() {
1556 if i + 1 < period {
1557 continue;
1558 }
1559 let sma_start = i + 1 - period;
1560 let sma: Decimal = self.bars[sma_start..=i]
1561 .iter()
1562 .map(|b| b.close.value())
1563 .sum::<Decimal>()
1564 / Decimal::from(period as u32);
1565 if self.bars[i].close.value() < sma {
1566 count += 1;
1567 }
1568 }
1569 count
1570 }
1571
1572 #[allow(clippy::cast_possible_truncation)]
1576 pub fn above_ema(&self, period: usize) -> bool {
1577 if period == 0 || self.bars.len() < period {
1578 return false;
1579 }
1580 let k = Decimal::TWO / Decimal::from((period + 1) as u32);
1581 let seed: Decimal = self.bars[..period].iter().map(|b| b.close.value()).sum::<Decimal>()
1582 / Decimal::from(period as u32);
1583 let mut ema = seed;
1584 for bar in &self.bars[period..] {
1585 ema = bar.close.value() * k + ema * (Decimal::ONE - k);
1586 }
1587 self.bars.last().map_or(false, |b| b.close.value() > ema)
1588 }
1589
1590 pub fn bullish_engulfing_count(&self, n: usize) -> usize {
1595 if self.bars.len() < 2 {
1596 return 0;
1597 }
1598 let start = self.bars.len().saturating_sub(n).max(1);
1599 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1600 let prev = &self.bars[start + i - 1];
1601 bar.is_bullish_engulfing(prev)
1602 }).count()
1603 }
1604
1605 pub fn range_expansion(&self, n: usize) -> Option<Decimal> {
1610 let last = self.bars.last()?;
1611 if n == 0 || self.bars.len() < n {
1612 return None;
1613 }
1614 let start = self.bars.len() - n;
1615 let avg_range: Decimal = self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>();
1616 #[allow(clippy::cast_possible_truncation)]
1617 let avg_range = avg_range / Decimal::from(n as u32);
1618 if avg_range == Decimal::ZERO {
1619 return None;
1620 }
1621 Some(last.range() / avg_range)
1622 }
1623
1624 pub fn bearish_engulfing_count(&self, n: usize) -> usize {
1628 if self.bars.len() < 2 {
1629 return 0;
1630 }
1631 let start = self.bars.len().saturating_sub(n).max(1);
1632 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1633 let prev = &self.bars[start + i - 1];
1634 let p_o = prev.open.value();
1636 let p_c = prev.close.value();
1637 let s_o = bar.open.value();
1638 let s_c = bar.close.value();
1639 p_c > p_o && s_c < s_o && s_o >= p_c && s_c <= p_o
1640 }).count()
1641 }
1642
1643 pub fn trend_strength(&self, n: usize) -> Option<Decimal> {
1650 if n < 2 || self.bars.len() < n {
1651 return None;
1652 }
1653 let start = self.bars.len() - n;
1654 let window = &self.bars[start..];
1655 let net = (window.last()?.close.value() - window[0].close.value()).abs();
1656 let total: Decimal = window.windows(2)
1657 .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1658 .sum();
1659 if total == Decimal::ZERO {
1660 return None;
1661 }
1662 Some(net / total)
1663 }
1664
1665 pub fn open_to_close_return(&self, n: usize) -> Option<Decimal> {
1671 if n == 0 || self.bars.len() < n {
1672 return None;
1673 }
1674 let start = self.bars.len() - n;
1675 let mut sum = Decimal::ZERO;
1676 for b in &self.bars[start..] {
1677 let o = b.open.value();
1678 if o == Decimal::ZERO {
1679 return None;
1680 }
1681 sum += (b.close.value() - o) / o;
1682 }
1683 #[allow(clippy::cast_possible_truncation)]
1684 Some(sum / Decimal::from(n as u32))
1685 }
1686
1687 pub fn gap_up_count(&self, n: usize) -> usize {
1689 if self.bars.len() < 2 {
1690 return 0;
1691 }
1692 let start = self.bars.len().saturating_sub(n).max(1);
1693 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1694 bar.open.value() > self.bars[start + i - 1].close.value()
1695 }).count()
1696 }
1697
1698 pub fn gap_down_count(&self, n: usize) -> usize {
1700 if self.bars.len() < 2 {
1701 return 0;
1702 }
1703 let start = self.bars.len().saturating_sub(n).max(1);
1704 self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1705 bar.open.value() < self.bars[start + i - 1].close.value()
1706 }).count()
1707 }
1708
1709 pub fn overnight_gap_pct(&self, n: usize) -> Option<Decimal> {
1715 if n == 0 || self.bars.len() < 2 {
1716 return None;
1717 }
1718 let start = self.bars.len().saturating_sub(n).max(1);
1719 let window_len = self.bars.len() - start;
1720 if window_len == 0 {
1721 return None;
1722 }
1723 let mut sum = Decimal::ZERO;
1724 for i in start..self.bars.len() {
1725 let pc = self.bars[i - 1].close.value();
1726 if pc == Decimal::ZERO {
1727 return None;
1728 }
1729 sum += (self.bars[i].open.value() - pc) / pc * Decimal::ONE_HUNDRED;
1730 }
1731 #[allow(clippy::cast_possible_truncation)]
1732 Some(sum / Decimal::from(window_len as u32))
1733 }
1734
1735 pub fn close_rank(&self, n: usize) -> Option<Decimal> {
1741 if n < 2 || self.bars.len() < n {
1742 return None;
1743 }
1744 let start = self.bars.len() - n;
1745 let current = self.bars.last()?.close.value();
1746 let below = self.bars[start..self.bars.len() - 1]
1747 .iter()
1748 .filter(|b| b.close.value() < current)
1749 .count();
1750 #[allow(clippy::cast_possible_truncation)]
1751 Some(Decimal::from(below as u32) / Decimal::from((n - 1) as u32) * Decimal::ONE_HUNDRED)
1752 }
1753
1754 pub fn high_low_ratio(&self, n: usize) -> Option<Decimal> {
1758 if n == 0 || self.bars.len() < n {
1759 return None;
1760 }
1761 let hh = self.highest_high(n)?;
1762 let ll = self.lowest_low(n)?;
1763 if ll == Decimal::ZERO {
1764 return None;
1765 }
1766 Some(hh / ll)
1767 }
1768
1769 #[allow(clippy::cast_possible_truncation)]
1771 pub fn average_volume(&self, n: usize) -> Option<Decimal> {
1772 let start = self.bars.len().saturating_sub(n);
1773 let slice = &self.bars[start..];
1774 if slice.is_empty() {
1775 return None;
1776 }
1777 let sum: Decimal = slice.iter().map(|b| b.volume.value()).sum();
1778 Some(sum / Decimal::from(slice.len() as u32))
1779 }
1780
1781 pub fn last_n_closes(&self, n: usize) -> Vec<Decimal> {
1785 let start = self.bars.len().saturating_sub(n);
1786 self.bars[start..].iter().map(|b| b.close.value()).collect()
1787 }
1788
1789 pub fn volume_spike(&self, n: usize, multiplier: Decimal) -> bool {
1794 if self.bars.len() < 2 || multiplier.is_zero() {
1795 return false;
1796 }
1797 let last_vol = self.bars.last().unwrap().volume.value();
1798 let prior_count = self.bars.len() - 1;
1800 let start = prior_count.saturating_sub(n);
1801 let prior = &self.bars[start..prior_count];
1802 if prior.is_empty() {
1803 return false;
1804 }
1805 let avg: Decimal = prior.iter().map(|b| b.volume.value()).sum::<Decimal>()
1806 / Decimal::from(prior.len() as u32);
1807 last_vol > avg * multiplier
1808 }
1809
1810 #[allow(clippy::cast_possible_truncation)]
1814 pub fn average_range(&self, n: usize) -> Option<Decimal> {
1815 let start = self.bars.len().saturating_sub(n);
1816 let slice = &self.bars[start..];
1817 if slice.is_empty() {
1818 return None;
1819 }
1820 let sum: Decimal = slice.iter().map(|b| b.range()).sum();
1821 Some(sum / Decimal::from(slice.len() as u32))
1822 }
1823
1824 #[allow(clippy::cast_possible_truncation)]
1828 pub fn typical_price_mean(&self, n: usize) -> Option<Decimal> {
1829 let start = self.bars.len().saturating_sub(n);
1830 let slice = &self.bars[start..];
1831 if slice.is_empty() {
1832 return None;
1833 }
1834 let sum: Decimal = slice.iter().map(|b| b.typical_price()).sum();
1835 Some(sum / Decimal::from(slice.len() as u32))
1836 }
1837
1838 pub fn sortino_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1843 let log_rets = self.log_returns();
1844 if log_rets.len() < 2 {
1845 return None;
1846 }
1847 let mean_ret = log_rets.iter().copied().sum::<f64>() / log_rets.len() as f64;
1848 let downside: Vec<f64> = log_rets.iter().map(|&r| if r < 0.0 { r * r } else { 0.0 }).collect();
1849 let downside_var = downside.iter().copied().sum::<f64>() / downside.len() as f64;
1850 let downside_dev = downside_var.sqrt();
1851 if downside_dev == 0.0 {
1852 return None;
1853 }
1854 let rf_per_bar = risk_free_rate / bars_per_year;
1855 Some((mean_ret - rf_per_bar) / downside_dev * bars_per_year.sqrt())
1856 }
1857
1858 pub fn close_above_open_streak(&self) -> usize {
1862 self.bars
1863 .iter()
1864 .rev()
1865 .take_while(|b| b.is_bullish())
1866 .count()
1867 }
1868
1869 pub fn max_drawdown_pct(&self, n: usize) -> Option<f64> {
1874 let window: Vec<f64> = self
1875 .bars
1876 .iter()
1877 .rev()
1878 .take(n)
1879 .map(|b| { use rust_decimal::prelude::ToPrimitive; b.close.value().to_f64().unwrap_or(0.0) })
1880 .collect::<Vec<_>>()
1881 .into_iter()
1882 .rev()
1883 .collect();
1884 if window.len() < 2 {
1885 return None;
1886 }
1887 let mut max_dd = 0.0f64;
1888 let mut peak = window[0];
1889 for &price in &window[1..] {
1890 if price > peak {
1891 peak = price;
1892 }
1893 if peak > 0.0 {
1894 let dd = (peak - price) / peak * 100.0;
1895 if dd > max_dd {
1896 max_dd = dd;
1897 }
1898 }
1899 }
1900 Some(max_dd)
1901 }
1902
1903 #[allow(clippy::cast_possible_truncation)]
1908 pub fn atr_series(&self, period: usize) -> Vec<Option<Decimal>> {
1909 let n = self.bars.len();
1910 let mut result = vec![None; n];
1911 if period == 0 || n == 0 {
1912 return result;
1913 }
1914 let trs: Vec<Decimal> = self
1915 .bars
1916 .iter()
1917 .enumerate()
1918 .map(|(i, b)| {
1919 let prev = if i == 0 { None } else { Some(&self.bars[i - 1]) };
1920 b.true_range(prev)
1921 })
1922 .collect();
1923 for i in (period - 1)..n {
1924 let sum: Decimal = trs[i + 1 - period..=i].iter().copied().sum();
1925 result[i] = Some(sum / Decimal::from(period as u32));
1926 }
1927 result
1928 }
1929
1930 pub fn up_days(&self, n: usize) -> usize {
1935 if self.bars.len() < 2 {
1936 return 0;
1937 }
1938 let start = self.bars.len().saturating_sub(n).max(1);
1939 self.bars[start..]
1940 .iter()
1941 .enumerate()
1942 .filter(|(i, b)| b.close.value() > self.bars[start + i - 1].close.value())
1943 .count()
1944 }
1945
1946 pub fn down_days(&self, n: usize) -> usize {
1950 if self.bars.len() < 2 {
1951 return 0;
1952 }
1953 let start = self.bars.len().saturating_sub(n).max(1);
1954 self.bars[start..]
1955 .iter()
1956 .enumerate()
1957 .filter(|(i, b)| b.close.value() < self.bars[start + i - 1].close.value())
1958 .count()
1959 }
1960
1961 pub fn range_series(&self) -> Vec<Decimal> {
1965 self.bars.iter().map(|b| b.range()).collect()
1966 }
1967
1968 pub fn close_to_close_changes(&self) -> Vec<Decimal> {
1973 if self.bars.len() < 2 {
1974 return vec![];
1975 }
1976 self.bars
1977 .windows(2)
1978 .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1979 .collect()
1980 }
1981
1982 pub fn volatility_ratio(&self, short: usize, long: usize) -> Option<Decimal> {
1988 let n = self.bars.len();
1989 if short == 0 || long == 0 || n == 0 {
1990 return None;
1991 }
1992 let short_atr = *self.atr_series(short).last()?;
1993 let long_atr = *self.atr_series(long).last()?;
1994 let s = short_atr?;
1995 let l = long_atr?;
1996 if l.is_zero() {
1997 return None;
1998 }
1999 Some(s / l)
2000 }
2001
2002 pub fn streak(&self) -> i32 {
2024 let n = self.bars.len();
2025 if n < 2 {
2026 return 0;
2027 }
2028 let mut count: i32 = 0;
2029 for i in (1..n).rev() {
2030 let prev = self.bars[i - 1].close.value();
2031 let curr = self.bars[i].close.value();
2032 if curr > prev {
2033 if count < 0 {
2034 break;
2035 }
2036 count += 1;
2037 } else if curr < prev {
2038 if count > 0 {
2039 break;
2040 }
2041 count -= 1;
2042 } else {
2043 break;
2044 }
2045 }
2046 count
2047 }
2048
2049 pub fn calmar_ratio(&self, bars_per_year: f64) -> Option<f64> {
2056 let lr = self.log_returns();
2057 if lr.len() < 2 {
2058 return None;
2059 }
2060 let ann_return = (lr.iter().sum::<f64>() / lr.len() as f64) * bars_per_year;
2061 let dd = self.max_drawdown()?;
2062 use rust_decimal::prelude::ToPrimitive;
2063 let dd_f64 = dd.to_f64()?;
2064 if dd_f64 == 0.0_f64 {
2065 return None;
2066 }
2067 Some(ann_return / dd_f64)
2068 }
2069
2070 pub fn session_high_low(&self, n: usize) -> Option<(Decimal, Decimal)> {
2075 let start = self.bars.len().saturating_sub(n);
2076 let slice = &self.bars[start..];
2077 if slice.is_empty() {
2078 return None;
2079 }
2080 let h = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
2081 let l = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
2082 Some((h, l))
2083 }
2084
2085 pub fn percentage_change_series(&self) -> Vec<Option<Decimal>> {
2090 if self.bars.len() < 2 {
2091 return vec![];
2092 }
2093 self.bars
2094 .windows(2)
2095 .map(|w| {
2096 let prev_c = w[0].close.value();
2097 if prev_c.is_zero() {
2098 None
2099 } else {
2100 Some((w[1].close.value() - prev_c) / prev_c * Decimal::ONE_HUNDRED)
2101 }
2102 })
2103 .collect()
2104 }
2105
2106 pub fn realized_volatility(&self, n: usize, bars_per_year: f64) -> Option<f64> {
2111 if n == 0 || self.bars.len() < n + 1 {
2112 return None;
2113 }
2114 let start = self.bars.len() - n - 1;
2115 let lr: Vec<f64> = self.bars[start..]
2116 .windows(2)
2117 .filter_map(|w| {
2118 let prev = w[0].close.value();
2119 if prev.is_zero() {
2120 return None;
2121 }
2122 use rust_decimal::prelude::ToPrimitive;
2123 let ratio = (w[1].close.value() / prev).to_f64()?;
2124 Some(ratio.ln())
2125 })
2126 .collect();
2127 if lr.len() < 2 {
2128 return None;
2129 }
2130 let mean = lr.iter().sum::<f64>() / lr.len() as f64;
2131 let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / lr.len() as f64;
2132 Some(variance.sqrt() * bars_per_year.sqrt())
2133 }
2134
2135 pub fn rolling_correlation(&self, other: &OhlcvSeries, n: usize) -> Option<f64> {
2140 if n < 2 || self.bars.len() < n || other.bars.len() < n {
2141 return None;
2142 }
2143 use rust_decimal::prelude::ToPrimitive;
2144 let xs: Vec<f64> = self.bars[self.bars.len() - n..]
2145 .iter()
2146 .filter_map(|b| b.close.value().to_f64())
2147 .collect();
2148 let ys: Vec<f64> = other.bars[other.bars.len() - n..]
2149 .iter()
2150 .filter_map(|b| b.close.value().to_f64())
2151 .collect();
2152 if xs.len() != n || ys.len() != n {
2153 return None;
2154 }
2155 let n_f = n as f64;
2156 let mx = xs.iter().sum::<f64>() / n_f;
2157 let my = ys.iter().sum::<f64>() / n_f;
2158 let cov = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum::<f64>() / n_f;
2159 let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2160 let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2161 if sx == 0.0 || sy == 0.0 {
2162 return None;
2163 }
2164 Some(cov / (sx * sy))
2165 }
2166
2167 pub fn beta(&self, market: &OhlcvSeries, n: usize) -> Option<f64> {
2172 if n < 2 || self.bars.len() < n + 1 || market.bars.len() < n + 1 {
2173 return None;
2174 }
2175 use rust_decimal::prelude::ToPrimitive;
2176 let asset_lr: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2177 .windows(2)
2178 .filter_map(|w| {
2179 let prev = w[0].close.value();
2180 if prev.is_zero() { return None; }
2181 (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2182 })
2183 .collect();
2184 let mkt_lr: Vec<f64> = market.bars[market.bars.len() - n - 1..]
2185 .windows(2)
2186 .filter_map(|w| {
2187 let prev = w[0].close.value();
2188 if prev.is_zero() { return None; }
2189 (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2190 })
2191 .collect();
2192 let len = asset_lr.len().min(mkt_lr.len());
2193 if len < 2 {
2194 return None;
2195 }
2196 let n_f = len as f64;
2197 let ma = asset_lr[..len].iter().sum::<f64>() / n_f;
2198 let mm = mkt_lr[..len].iter().sum::<f64>() / n_f;
2199 let cov = asset_lr[..len].iter().zip(mkt_lr[..len].iter())
2200 .map(|(a, m)| (a - ma) * (m - mm))
2201 .sum::<f64>() / n_f;
2202 let var_m = mkt_lr[..len].iter().map(|m| (m - mm).powi(2)).sum::<f64>() / n_f;
2203 if var_m == 0.0 { return None; }
2204 Some(cov / var_m)
2205 }
2206
2207 pub fn information_ratio(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
2212 if n < 2 || self.bars.len() < n + 1 || benchmark.bars.len() < n + 1 {
2213 return None;
2214 }
2215 use rust_decimal::prelude::ToPrimitive;
2216 let excess: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2217 .windows(2)
2218 .zip(benchmark.bars[benchmark.bars.len() - n - 1..].windows(2))
2219 .filter_map(|(aw, bw)| {
2220 let ap = aw[0].close.value();
2221 let bp = bw[0].close.value();
2222 if ap.is_zero() || bp.is_zero() { return None; }
2223 let ar = (aw[1].close.value() / ap).to_f64()?.ln();
2224 let br = (bw[1].close.value() / bp).to_f64()?.ln();
2225 Some(ar - br)
2226 })
2227 .collect();
2228 if excess.len() < 2 { return None; }
2229 let n_f = excess.len() as f64;
2230 let mean = excess.iter().sum::<f64>() / n_f;
2231 let te = (excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / n_f).sqrt();
2232 if te == 0.0 { return None; }
2233 Some(mean / te)
2234 }
2235
2236 pub fn drawdown_series(&self) -> Vec<Decimal> {
2241 if self.bars.is_empty() {
2242 return vec![];
2243 }
2244 let mut peak = Decimal::MIN;
2245 self.bars
2246 .iter()
2247 .map(|b| {
2248 let close = b.close.value();
2249 if close > peak {
2250 peak = close;
2251 }
2252 if peak.is_zero() {
2253 Decimal::ZERO
2254 } else {
2255 (peak - close) / peak
2256 }
2257 })
2258 .collect()
2259 }
2260
2261 pub fn above_moving_average(&self, period: usize) -> Option<bool> {
2265 if period == 0 || self.bars.len() < period {
2266 return None;
2267 }
2268 let start = self.bars.len() - period;
2269 #[allow(clippy::cast_possible_truncation)]
2270 let sma: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2271 / Decimal::from(period as u32);
2272 Some(self.bars.last()?.close.value() > sma)
2273 }
2274
2275 pub fn consecutive_higher_highs(&self, n: usize) -> usize {
2279 if n == 0 || self.bars.len() < 2 {
2280 return 0;
2281 }
2282 let start = self.bars.len().saturating_sub(n).max(1);
2283 self.bars[start..]
2284 .iter()
2285 .enumerate()
2286 .filter(|(i, b)| b.high.value() > self.bars[start + i - 1].high.value())
2287 .count()
2288 }
2289
2290 pub fn consecutive_lower_lows(&self, n: usize) -> usize {
2295 if n == 0 || self.bars.len() < 2 {
2296 return 0;
2297 }
2298 let start = self.bars.len().saturating_sub(n).max(1);
2299 self.bars[start..]
2300 .iter()
2301 .enumerate()
2302 .filter(|(i, b)| b.low.value() < self.bars[start + i - 1].low.value())
2303 .count()
2304 }
2305
2306 pub fn vwap_deviation(&self, n: usize) -> Option<Decimal> {
2312 if n == 0 || self.bars.len() < n {
2313 return None;
2314 }
2315 let start = self.bars.len().saturating_sub(n);
2316 let slice = &self.bars[start..];
2317 let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
2318 if total_vol.is_zero() {
2319 return None;
2320 }
2321 let vwap: Decimal = slice.iter()
2322 .map(|b| {
2323 let tp = b.typical_price();
2324 tp * b.volume.value()
2325 })
2326 .sum::<Decimal>() / total_vol;
2327 if vwap.is_zero() {
2328 return None;
2329 }
2330 let last_close = self.bars.last()?.close.value();
2331 Some((last_close - vwap) / vwap * Decimal::ONE_HUNDRED)
2332 }
2333
2334 pub fn average_true_range_pct(&self, n: usize) -> Option<f64> {
2339 use rust_decimal::prelude::ToPrimitive;
2340 if n == 0 || self.bars.len() < n {
2341 return None;
2342 }
2343 let atrs = self.atr_series(n);
2344 let last_close = self.bars.last()?.close.value();
2345 if last_close.is_zero() {
2346 return None;
2347 }
2348 let atr = (*atrs.last()?.as_ref()?).to_f64()?;
2349 let close_f64 = last_close.to_f64()?;
2350 Some(atr / close_f64 * 100.0)
2351 }
2352
2353 pub fn count_doji(&self, n: usize, threshold: Decimal) -> usize {
2357 if n == 0 {
2358 return 0;
2359 }
2360 let start = self.bars.len().saturating_sub(n);
2361 self.bars[start..].iter().filter(|b| b.is_doji(threshold)).count()
2362 }
2363
2364 pub fn gap_up_bars(&self, n: usize) -> usize {
2368 if n == 0 || self.bars.len() < 2 {
2369 return 0;
2370 }
2371 let start = self.bars.len().saturating_sub(n + 1);
2372 self.bars[start..].windows(2).filter(|w| w[1].gap_up_from(&w[0])).count()
2373 }
2374
2375 pub fn gap_down_bars(&self, n: usize) -> usize {
2379 if n == 0 || self.bars.len() < 2 {
2380 return 0;
2381 }
2382 let start = self.bars.len().saturating_sub(n + 1);
2383 self.bars[start..].windows(2).filter(|w| w[1].gap_down_from(&w[0])).count()
2384 }
2385
2386 pub fn cum_volume(&self, n: usize) -> Decimal {
2390 if n == 0 {
2391 return Decimal::ZERO;
2392 }
2393 let start = self.bars.len().saturating_sub(n);
2394 self.bars[start..].iter().map(|b| b.volume.value()).sum()
2395 }
2396
2397 pub fn momentum_score(&self, short: usize, long: usize) -> Option<f64> {
2402 use rust_decimal::prelude::ToPrimitive;
2403 if short == 0 || long == 0 || short >= long || self.bars.len() < long {
2404 return None;
2405 }
2406 #[allow(clippy::cast_possible_truncation)]
2407 let sma = |n: usize| -> Option<Decimal> {
2408 let start = self.bars.len().saturating_sub(n);
2409 let s: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
2410 Some(s / Decimal::from(n as u32))
2411 };
2412 let sma_s = sma(short)?;
2413 let sma_l = sma(long)?;
2414 if sma_l.is_zero() {
2415 return None;
2416 }
2417 ((sma_s - sma_l) / sma_l * Decimal::ONE_HUNDRED).to_f64()
2418 }
2419
2420 pub fn first_bar(&self) -> Option<&OhlcvBar> {
2422 self.bars.first()
2423 }
2424
2425 pub fn volume_weighted_close(&self, n: usize) -> Option<Decimal> {
2429 if n == 0 || self.bars.len() < n {
2430 return None;
2431 }
2432 let start = self.bars.len() - n;
2433 let vol_sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
2434 if vol_sum.is_zero() {
2435 return None;
2436 }
2437 let pv_sum: Decimal = self.bars[start..]
2438 .iter()
2439 .map(|b| b.close.value() * b.volume.value())
2440 .sum();
2441 Some(pv_sum / vol_sum)
2442 }
2443
2444 pub fn range_expansion_ratio(&self, n: usize) -> Option<f64> {
2449 use rust_decimal::prelude::ToPrimitive;
2450 if n == 0 || self.bars.len() < n {
2451 return None;
2452 }
2453 let last_range = self.bars.last()?.range();
2454 let start = self.bars.len() - n;
2455 let avg_range = self.bars[start..]
2456 .iter()
2457 .map(|b| b.range())
2458 .sum::<Decimal>();
2459 #[allow(clippy::cast_possible_truncation)]
2460 let avg = avg_range / Decimal::from(n as u32);
2461 if avg.is_zero() {
2462 return None;
2463 }
2464 (last_range / avg).to_f64()
2465 }
2466
2467 pub fn efficiency_ratio(&self, n: usize) -> Option<Decimal> {
2472 if n == 0 || self.bars.len() <= n {
2473 return None;
2474 }
2475 let start = self.bars.len() - n - 1;
2476 let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2477 let direction = (closes[n] - closes[0]).abs();
2478 let path: Decimal = closes.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
2479 if path.is_zero() {
2480 return None;
2481 }
2482 Some(direction / path)
2483 }
2484
2485 pub fn body_pct_series(&self, n: usize) -> Vec<Option<Decimal>> {
2490 let start = self.bars.len().saturating_sub(n);
2491 self.bars[start..]
2492 .iter()
2493 .map(|b| {
2494 let range = b.range();
2495 if range.is_zero() {
2496 None
2497 } else {
2498 let body = b.body_size();
2499 Some(body / range * Decimal::ONE_HUNDRED)
2500 }
2501 })
2502 .collect()
2503 }
2504
2505 pub fn candle_color_changes(&self, n: usize) -> usize {
2510 let start = self.bars.len().saturating_sub(n);
2511 let slice = &self.bars[start..];
2512 if slice.len() < 2 {
2513 return 0;
2514 }
2515 slice.windows(2)
2516 .filter(|w| {
2517 let prev_bull = w[0].close.value() >= w[0].open.value();
2518 let curr_bull = w[1].close.value() >= w[1].open.value();
2519 prev_bull != curr_bull
2520 })
2521 .count()
2522 }
2523
2524 pub fn typical_price_series(&self, n: usize) -> Vec<Decimal> {
2526 let start = self.bars.len().saturating_sub(n);
2527 self.bars[start..]
2528 .iter()
2529 .map(|b| b.typical_price())
2530 .collect()
2531 }
2532
2533 pub fn open_gap_series(&self) -> Vec<Decimal> {
2539 if self.bars.len() < 2 {
2540 return Vec::new();
2541 }
2542 self.bars
2543 .windows(2)
2544 .filter_map(|w| {
2545 let prev_close = w[0].close.value();
2546 if prev_close.is_zero() {
2547 return None;
2548 }
2549 Some((w[1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
2550 })
2551 .collect()
2552 }
2553
2554 pub fn intraday_range_pct(&self, n: usize) -> Option<Decimal> {
2558 if n == 0 || self.bars.is_empty() {
2559 return None;
2560 }
2561 let start = self.bars.len().saturating_sub(n);
2562 let slice = &self.bars[start..];
2563 let count = slice.len();
2564 if count == 0 {
2565 return None;
2566 }
2567 let sum: Option<Decimal> = slice.iter().try_fold(Decimal::ZERO, |acc, b| {
2568 let o = b.open.value();
2569 if o.is_zero() { return None; }
2570 Some(acc + (b.range()) / o * Decimal::ONE_HUNDRED)
2571 });
2572 #[allow(clippy::cast_possible_truncation)]
2573 Some(sum? / Decimal::from(count as u32))
2574 }
2575
2576 pub fn close_above_prior_high(&self, n: usize) -> usize {
2580 if n == 0 || self.bars.len() < 2 {
2581 return 0;
2582 }
2583 let start = self.bars.len().saturating_sub(n + 1);
2584 self.bars[start..].windows(2).filter(|w| w[1].close.value() > w[0].high.value()).count()
2585 }
2586
2587 pub fn skewness(&self, n: usize) -> Option<f64> {
2591 use rust_decimal::prelude::ToPrimitive;
2592 if n < 3 || self.bars.len() < n {
2593 return None;
2594 }
2595 let start = self.bars.len().saturating_sub(n);
2596 let vals: Vec<f64> = self.bars[start..]
2597 .iter()
2598 .filter_map(|b| b.close.value().to_f64())
2599 .collect();
2600 if vals.len() < 3 {
2601 return None;
2602 }
2603 let n_f = vals.len() as f64;
2604 let mean = vals.iter().sum::<f64>() / n_f;
2605 let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2606 let std_dev = variance.sqrt();
2607 if std_dev == 0.0 {
2608 return None;
2609 }
2610 let skew = vals.iter().map(|x| ((x - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
2611 Some(skew)
2612 }
2613
2614 pub fn kurtosis(&self, n: usize) -> Option<f64> {
2619 use rust_decimal::prelude::ToPrimitive;
2620 if n < 4 || self.bars.len() < n {
2621 return None;
2622 }
2623 let start = self.bars.len().saturating_sub(n);
2624 let vals: Vec<f64> = self.bars[start..]
2625 .iter()
2626 .filter_map(|b| b.close.value().to_f64())
2627 .collect();
2628 if vals.len() < 4 {
2629 return None;
2630 }
2631 let n_f = vals.len() as f64;
2632 let mean = vals.iter().sum::<f64>() / n_f;
2633 let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2634 if variance == 0.0 {
2635 return None;
2636 }
2637 let kurt = vals.iter().map(|x| ((x - mean) / variance.sqrt()).powi(4)).sum::<f64>() / n_f - 3.0;
2638 Some(kurt)
2639 }
2640
2641 pub fn sma_crossover(&self, fast_period: usize, slow_period: usize) -> bool {
2646 if fast_period == 0 || slow_period == 0 || fast_period >= slow_period {
2647 return false;
2648 }
2649 if self.bars.len() < slow_period {
2650 return false;
2651 }
2652 let fast_start = self.bars.len() - fast_period;
2653 let slow_start = self.bars.len() - slow_period;
2654 let fast_avg: Decimal = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2655 / Decimal::from(fast_period as u32);
2656 let slow_avg: Decimal = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2657 / Decimal::from(slow_period as u32);
2658 fast_avg > slow_avg
2659 }
2660
2661 pub fn price_percentile(&self, price: Decimal, n: usize) -> Option<f64> {
2665 if n == 0 || self.bars.is_empty() {
2666 return None;
2667 }
2668 let start = self.bars.len().saturating_sub(n);
2669 let slice = &self.bars[start..];
2670 let count = slice.iter().filter(|b| b.close.value() <= price).count();
2671 Some(count as f64 / slice.len() as f64)
2672 }
2673
2674 pub fn intraday_range_mean(&self, n: usize) -> Option<Decimal> {
2678 if n == 0 || self.bars.len() < n {
2679 return None;
2680 }
2681 let start = self.bars.len() - n;
2682 let sum: Decimal = self.bars[start..].iter().map(|b| b.range()).sum();
2683 #[allow(clippy::cast_possible_truncation)]
2684 Some(sum / Decimal::from(n as u32))
2685 }
2686
2687 pub fn range_to_atr_ratio(&self, n: usize) -> Option<Decimal> {
2692 if n == 0 || self.bars.len() < n + 1 {
2693 return None;
2694 }
2695 let start = self.bars.len() - n - 1;
2696 let slice = &self.bars[start..];
2697 let mut tr_sum = Decimal::ZERO;
2698 for w in slice.windows(2) {
2699 let prev_close = w[0].close.value();
2700 let high = w[1].high.value();
2701 let low = w[1].low.value();
2702 let tr = (high - low)
2703 .max((high - prev_close).abs())
2704 .max((low - prev_close).abs());
2705 tr_sum += tr;
2706 }
2707 #[allow(clippy::cast_possible_truncation)]
2708 let atr = tr_sum / Decimal::from(n as u32);
2709 if atr.is_zero() {
2710 return None;
2711 }
2712 let last = self.bars.last()?;
2713 let current_range = last.range();
2714 Some(current_range / atr * Decimal::ONE_HUNDRED)
2715 }
2716
2717 pub fn close_momentum(&self, n: usize) -> Option<Decimal> {
2722 if n == 0 || self.bars.len() < n + 1 {
2723 return None;
2724 }
2725 let ref_close = self.bars[self.bars.len() - n - 1].close.value();
2726 if ref_close.is_zero() {
2727 return None;
2728 }
2729 let current = self.bars.last()?.close.value();
2730 Some((current - ref_close) / ref_close * Decimal::ONE_HUNDRED)
2731 }
2732
2733 pub fn average_gap_pct(&self, n: usize) -> Option<Decimal> {
2738 if n == 0 || self.bars.len() <= n {
2739 return None;
2740 }
2741 let start = self.bars.len() - n - 1;
2742 let slice = &self.bars[start..];
2743 let mut count = 0;
2744 let mut sum = Decimal::ZERO;
2745 for pair in slice.windows(2) {
2746 let pc = pair[0].close.value();
2747 if pc.is_zero() {
2748 continue;
2749 }
2750 sum += (pair[1].open.value() - pc).abs() / pc * Decimal::ONE_HUNDRED;
2751 count += 1;
2752 }
2753 if count == 0 {
2754 None
2755 } else {
2756 #[allow(clippy::cast_possible_truncation)]
2757 Some(sum / Decimal::from(count as u32))
2758 }
2759 }
2760
2761 pub fn returns_series(&self, n: usize) -> Vec<Decimal> {
2766 if n == 0 || self.bars.len() < 2 {
2767 return vec![];
2768 }
2769 use rust_decimal::prelude::ToPrimitive;
2770 let start = self.bars.len().saturating_sub(n + 1);
2771 let slice = &self.bars[start..];
2772 slice
2773 .windows(2)
2774 .map(|w| {
2775 let prev = w[0].close.value();
2776 let curr = w[1].close.value();
2777 if prev.is_zero() {
2778 Decimal::ZERO
2779 } else {
2780 let ratio = (curr / prev).to_f64().unwrap_or(1.0);
2781 Decimal::try_from(ratio.ln()).unwrap_or(Decimal::ZERO)
2782 }
2783 })
2784 .collect()
2785 }
2786
2787 pub fn max_consecutive_up(&self) -> usize {
2792 if self.bars.len() < 2 {
2793 return 0;
2794 }
2795 let mut max_run = 0usize;
2796 let mut current = 0usize;
2797 for w in self.bars.windows(2) {
2798 if w[1].close.value() > w[0].close.value() {
2799 current += 1;
2800 if current > max_run {
2801 max_run = current;
2802 }
2803 } else {
2804 current = 0;
2805 }
2806 }
2807 max_run
2808 }
2809
2810 pub fn max_consecutive_down(&self) -> usize {
2815 if self.bars.len() < 2 {
2816 return 0;
2817 }
2818 let mut max_run = 0usize;
2819 let mut current = 0usize;
2820 for w in self.bars.windows(2) {
2821 if w[1].close.value() < w[0].close.value() {
2822 current += 1;
2823 if current > max_run {
2824 max_run = current;
2825 }
2826 } else {
2827 current = 0;
2828 }
2829 }
2830 max_run
2831 }
2832
2833 pub fn typical_price_sma(&self, period: usize) -> Option<Decimal> {
2838 if period == 0 || self.bars.len() < period {
2839 return None;
2840 }
2841 let start = self.bars.len() - period;
2842 let sum: Decimal = self.bars[start..]
2843 .iter()
2844 .map(|b| b.typical_price())
2845 .sum();
2846 #[allow(clippy::cast_possible_truncation)]
2847 Some(sum / Decimal::from(period as u32))
2848 }
2849
2850 pub fn bar_at_index(&self, i: usize) -> Option<&OhlcvBar> {
2852 self.bars.get(i)
2853 }
2854
2855 #[allow(clippy::cast_possible_truncation)]
2859 pub fn rolling_close_std(&self, n: usize) -> Option<Decimal> {
2860 if n < 2 || self.bars.len() < n {
2861 return None;
2862 }
2863 let start = self.bars.len() - n;
2864 let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2865 let mean = closes.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
2866 let variance = closes
2867 .iter()
2868 .map(|c| { let d = *c - mean; d * d })
2869 .sum::<Decimal>()
2870 / Decimal::from((n - 1) as u32);
2871 use rust_decimal::prelude::ToPrimitive;
2872 let std = variance.to_f64()?.sqrt();
2873 Decimal::try_from(std).ok()
2874 }
2875
2876 pub fn gap_direction_series(&self, n: usize) -> Vec<i8> {
2882 if n < 2 || self.bars.len() < 2 {
2883 return vec![];
2884 }
2885 let start = self.bars.len().saturating_sub(n);
2886 self.bars[start..]
2887 .windows(2)
2888 .map(|w| {
2889 let gap = w[1].open.value() - w[0].close.value();
2890 if gap > Decimal::ZERO {
2891 1i8
2892 } else if gap < Decimal::ZERO {
2893 -1i8
2894 } else {
2895 0i8
2896 }
2897 })
2898 .collect()
2899 }
2900
2901 pub fn volume_trend(&self, n: usize) -> Option<f64> {
2906 use rust_decimal::prelude::ToPrimitive;
2907 if n < 2 || self.bars.len() < n {
2908 return None;
2909 }
2910 let start = self.bars.len() - n;
2911 let vols: Vec<f64> = self.bars[start..]
2912 .iter()
2913 .filter_map(|b| b.volume.value().to_f64())
2914 .collect();
2915 if vols.len() < 2 {
2916 return None;
2917 }
2918 let n_f = vols.len() as f64;
2919 let sum_x: f64 = (0..vols.len()).map(|i| i as f64).sum();
2920 let sum_y: f64 = vols.iter().sum();
2921 let sum_xy: f64 = vols.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
2922 let sum_xx: f64 = (0..vols.len()).map(|i| (i as f64).powi(2)).sum();
2923 let denom = n_f * sum_xx - sum_x * sum_x;
2924 if denom == 0.0 { return None; }
2925 Some((n_f * sum_xy - sum_x * sum_y) / denom)
2926 }
2927
2928 pub fn wick_body_ratio(&self, n: usize) -> Option<f64> {
2933 use rust_decimal::prelude::ToPrimitive;
2934 if n == 0 || self.bars.len() < n {
2935 return None;
2936 }
2937 let start = self.bars.len() - n;
2938 let mut sum = 0.0f64;
2939 let mut count = 0usize;
2940 for b in &self.bars[start..] {
2941 let body = b.body_size().to_f64()?;
2942 if body == 0.0 { continue; }
2943 let range = (b.range()).to_f64()?;
2944 let wick = (range - body).max(0.0);
2945 sum += wick / body;
2946 count += 1;
2947 }
2948 if count == 0 { return None; }
2949 Some(sum / count as f64)
2950 }
2951
2952 pub fn volume_price_correlation(&self, n: usize) -> Option<f64> {
2956 use rust_decimal::prelude::ToPrimitive;
2957 if n < 2 || self.bars.len() < n {
2958 return None;
2959 }
2960 let start = self.bars.len() - n;
2961 let xs: Vec<f64> = self.bars[start..]
2962 .iter()
2963 .filter_map(|b| b.volume.value().to_f64())
2964 .collect();
2965 let ys: Vec<f64> = self.bars[start..]
2966 .iter()
2967 .filter_map(|b| b.close.value().to_f64())
2968 .collect();
2969 if xs.len() < 2 { return None; }
2970 let n_f = xs.len() as f64;
2971 let mx = xs.iter().sum::<f64>() / n_f;
2972 let my = ys.iter().sum::<f64>() / n_f;
2973 let num: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum();
2974 let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2975 let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2976 if sx == 0.0 || sy == 0.0 { return None; }
2977 Some(num / (n_f * sx * sy))
2978 }
2979
2980 pub fn bar_range_pct(&self, n: usize) -> Option<Decimal> {
2984 if n == 0 || self.bars.len() < n {
2985 return None;
2986 }
2987 let start = self.bars.len() - n;
2988 let mut sum = Decimal::ZERO;
2989 let mut count = 0u32;
2990 for b in &self.bars[start..] {
2991 let c = b.close.value();
2992 if c.is_zero() { continue; }
2993 sum += (b.range()) / c * Decimal::ONE_HUNDRED;
2994 count += 1;
2995 }
2996 if count == 0 { return None; }
2997 Some(sum / Decimal::from(count))
2998 }
2999
3000 pub fn close_vs_prior_range_count(&self, n: usize) -> usize {
3004 if n < 2 || self.bars.len() < 2 {
3005 return 0;
3006 }
3007 let start = self.bars.len().saturating_sub(n);
3008 let slice = &self.bars[start..];
3009 slice.windows(2)
3010 .filter(|w| {
3011 let mid = (w[0].high.value() + w[0].low.value()) / Decimal::TWO;
3012 w[1].close.value() > mid
3013 })
3014 .count()
3015 }
3016
3017 pub fn rolling_sharpe(&self, n: usize, risk_free_rate: Decimal) -> Option<Decimal> {
3022 if n == 0 || self.bars.len() < 2 {
3023 return None;
3024 }
3025 use rust_decimal::prelude::ToPrimitive;
3026 let returns = self.returns_series(n);
3027 if returns.len() < 2 {
3028 return None;
3029 }
3030 #[allow(clippy::cast_possible_truncation)]
3031 let len_d = Decimal::from(returns.len() as u32);
3032 let mean: Decimal = returns.iter().copied().sum::<Decimal>() / len_d;
3033 let rf_daily = risk_free_rate / Decimal::from(252u32);
3034 let excess_mean = mean - rf_daily;
3035 let variance = returns
3036 .iter()
3037 .map(|r| { let d = *r - mean; d * d })
3038 .sum::<Decimal>()
3039 / len_d;
3040 let std_f64 = variance.to_f64()?.sqrt();
3041 if std_f64 == 0.0 {
3042 return None;
3043 }
3044 let sharpe = excess_mean.to_f64()? / std_f64 * 252.0f64.sqrt();
3045 Decimal::try_from(sharpe).ok()
3046 }
3047
3048 pub fn close_range_position(&self, n: usize) -> Option<Decimal> {
3054 if n == 0 || self.bars.len() < n {
3055 return None;
3056 }
3057 let start = self.bars.len() - n;
3058 let slice = &self.bars[start..];
3059 let highest = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
3060 let lowest = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
3061 let range = highest - lowest;
3062 if range.is_zero() {
3063 return None;
3064 }
3065 let close = self.bars.last()?.close.value();
3066 Some((close - lowest) / range * Decimal::ONE_HUNDRED)
3067 }
3068
3069 pub fn bar_count_since_high(&self, n: usize) -> usize {
3074 if n == 0 || self.bars.len() < n {
3075 return 0;
3076 }
3077 let start = self.bars.len() - n;
3078 let slice = &self.bars[start..];
3079 let mut max_val = Decimal::MIN;
3080 let mut max_idx = 0;
3081 for (i, b) in slice.iter().enumerate() {
3082 let c = b.close.value();
3083 if c > max_val {
3084 max_val = c;
3085 max_idx = i;
3086 }
3087 }
3088 slice.len() - 1 - max_idx
3089 }
3090
3091 pub fn close_to_open_ratio(&self, n: usize) -> Option<Decimal> {
3095 if n == 0 || self.bars.len() < n {
3096 return None;
3097 }
3098 let start = self.bars.len() - n;
3099 let mut sum = Decimal::ZERO;
3100 let mut count = 0usize;
3101 for b in &self.bars[start..] {
3102 let o = b.open.value();
3103 if o.is_zero() {
3104 continue;
3105 }
3106 sum += (b.close.value() / o - Decimal::ONE) * Decimal::ONE_HUNDRED;
3107 count += 1;
3108 }
3109 if count == 0 {
3110 return None;
3111 }
3112 #[allow(clippy::cast_possible_truncation)]
3113 Some(sum / Decimal::from(count as u32))
3114 }
3115
3116 pub fn autocorrelation(&self, n: usize, lag: usize) -> Option<f64> {
3122 if n == 0 || lag == 0 || self.bars.len() < n + lag + 1 {
3123 return None;
3124 }
3125 use rust_decimal::prelude::ToPrimitive;
3126 let returns = self.returns_series(n + lag);
3127 if returns.len() <= lag {
3128 return None;
3129 }
3130 let x: Vec<f64> = returns[..returns.len() - lag].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3131 let y: Vec<f64> = returns[lag..].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3132 let n_f = x.len() as f64;
3133 let mean_x = x.iter().sum::<f64>() / n_f;
3134 let mean_y = y.iter().sum::<f64>() / n_f;
3135 let cov: f64 = x.iter().zip(y.iter()).map(|(xi, yi)| (xi - mean_x) * (yi - mean_y)).sum::<f64>() / n_f;
3136 let std_x = (x.iter().map(|xi| (xi - mean_x).powi(2)).sum::<f64>() / n_f).sqrt();
3137 let std_y = (y.iter().map(|yi| (yi - mean_y).powi(2)).sum::<f64>() / n_f).sqrt();
3138 if std_x == 0.0 || std_y == 0.0 {
3139 return None;
3140 }
3141 Some(cov / (std_x * std_y))
3142 }
3143
3144 pub fn hurst_exponent(&self, n: usize) -> Option<f64> {
3149 if n < 8 || self.bars.len() < n + 1 {
3150 return None;
3151 }
3152 use rust_decimal::prelude::ToPrimitive;
3153 let returns: Vec<f64> = self
3154 .returns_series(n)
3155 .iter()
3156 .map(|r| r.to_f64().unwrap_or(0.0))
3157 .collect();
3158 if returns.is_empty() {
3159 return None;
3160 }
3161 let mean = returns.iter().sum::<f64>() / returns.len() as f64;
3162 let cum: Vec<f64> = returns.iter().scan(0.0f64, |acc, &r| { *acc += r - mean; Some(*acc) }).collect();
3163 let r = cum.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
3164 - cum.iter().cloned().fold(f64::INFINITY, f64::min);
3165 let s = (returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64).sqrt();
3166 if s == 0.0 || r <= 0.0 {
3167 return None;
3168 }
3169 Some((r / s).ln() / (returns.len() as f64).ln())
3170 }
3171
3172 pub fn ulcer_index(&self, n: usize) -> Option<Decimal> {
3177 if n == 0 || self.bars.len() < n {
3178 return None;
3179 }
3180 use rust_decimal::prelude::ToPrimitive;
3181 let start = self.bars.len() - n;
3182 let slice = &self.bars[start..];
3183 let mut peak = Decimal::ZERO;
3184 let mut sum_sq = 0.0f64;
3185 for b in slice {
3186 let c = b.close.value();
3187 if c > peak { peak = c; }
3188 if peak.is_zero() { continue; }
3189 let dd_pct = ((c - peak) / peak * Decimal::ONE_HUNDRED).to_f64().unwrap_or(0.0);
3190 sum_sq += dd_pct * dd_pct;
3191 }
3192 let ui = (sum_sq / n as f64).sqrt();
3193 Decimal::try_from(ui).ok()
3194 }
3195
3196 pub fn cvar(&self, n: usize, confidence_pct: Decimal) -> Option<Decimal> {
3202 use rust_decimal::prelude::ToPrimitive;
3203 if n < 2 || confidence_pct <= Decimal::ZERO || confidence_pct >= Decimal::ONE_HUNDRED {
3204 return None;
3205 }
3206 let mut returns = self.returns_series(n);
3207 if returns.len() < 2 {
3208 return None;
3209 }
3210 returns.sort_unstable_by(|a, b| a.cmp(b));
3211 let cutoff = ((Decimal::ONE - confidence_pct / Decimal::ONE_HUNDRED)
3212 .to_f64()
3213 .unwrap_or(0.05)
3214 * returns.len() as f64)
3215 .ceil() as usize;
3216 let tail = &returns[..cutoff.min(returns.len())];
3217 if tail.is_empty() {
3218 return None;
3219 }
3220 #[allow(clippy::cast_possible_truncation)]
3221 let avg = tail.iter().copied().sum::<Decimal>() / Decimal::from(tail.len() as u32);
3222 Some(avg)
3223 }
3224
3225 pub fn close_change_pct(&self, n: usize) -> Option<Decimal> {
3230 if n == 0 || self.bars.len() <= n {
3231 return None;
3232 }
3233 let recent = self.bars.last()?.close.value();
3234 let earlier = self.bars[self.bars.len() - 1 - n].close.value();
3235 if earlier.is_zero() {
3236 return None;
3237 }
3238 Some((recent - earlier) / earlier * Decimal::ONE_HUNDRED)
3239 }
3240
3241 pub fn close_above_vwap_pct(&self, n: usize) -> Option<f64> {
3244 if n == 0 || self.bars.len() < n { return None; }
3245 let start = self.bars.len() - n;
3246 let window = &self.bars[start..];
3247 let total_vol: Decimal = window.iter().map(|b| b.volume.value()).sum();
3248 if total_vol.is_zero() { return None; }
3249 let vwap = window.iter()
3250 .map(|b| b.typical_price() * b.volume.value())
3251 .sum::<Decimal>() / total_vol;
3252 let above = window.iter().filter(|b| b.close.value() > vwap).count();
3253 Some(above as f64 / n as f64 * 100.0)
3254 }
3255
3256 pub fn reversal_count(&self, n: usize) -> usize {
3259 if n < 2 || self.bars.len() < n { return 0; }
3260 let start = self.bars.len() - n;
3261 self.bars[start..].windows(3)
3262 .filter(|w| {
3263 let prev_dir = w[1].close.value() > w[0].close.value();
3264 let curr_dir = w[2].close.value() > w[1].close.value();
3265 prev_dir != curr_dir
3266 })
3267 .count()
3268 }
3269
3270 pub fn open_gap_fill_rate(&self, n: usize) -> Option<f64> {
3274 if n == 0 || self.bars.len() < n + 1 { return None; }
3275 let start = self.bars.len() - n;
3276 let mut gap_count = 0usize;
3277 let mut filled = 0usize;
3278 for i in start..self.bars.len() {
3279 let prior_close = self.bars[i - 1].close.value();
3280 let bar = &self.bars[i];
3281 let open = bar.open.value();
3282 if open == prior_close { continue; }
3283 gap_count += 1;
3284 let gap_up = open > prior_close;
3285 if gap_up && bar.low.value() <= prior_close {
3286 filled += 1;
3287 } else if !gap_up && bar.high.value() >= prior_close {
3288 filled += 1;
3289 }
3290 }
3291 if gap_count == 0 { return None; }
3292 Some(filled as f64 / gap_count as f64 * 100.0)
3293 }
3294
3295 pub fn candle_symmetry(&self, n: usize) -> Option<f64> {
3299 if n == 0 || self.bars.len() < n { return None; }
3300 let start = self.bars.len() - n;
3301 let mut ratios = Vec::new();
3302 for bar in &self.bars[start..] {
3303 let body_top = bar.close.value().max(bar.open.value());
3304 let body_bot = bar.close.value().min(bar.open.value());
3305 let upper = bar.high.value() - body_top;
3306 let lower = body_bot - bar.low.value();
3307 if upper.is_zero() && lower.is_zero() { continue; }
3308 let total = upper + lower;
3309 if total.is_zero() { continue; }
3310 use rust_decimal::prelude::ToPrimitive;
3311 let ratio: f64 = lower.to_f64().unwrap_or(0.0)
3312 / total.to_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.is_bullish())
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.range()
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.body_size())
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.midpoint())
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| b.upper_shadow())
3792 .sum();
3793 #[allow(clippy::cast_possible_truncation)]
3794 Some(sum / Decimal::from(n as u32))
3795 }
3796
3797 pub fn median_price(&self, n: usize) -> Option<Decimal> {
3801 if n == 0 || self.bars.len() < n {
3802 return None;
3803 }
3804 let start = self.bars.len() - n;
3805 let mut mids: Vec<Decimal> = self.bars[start..]
3806 .iter()
3807 .map(|b| b.midpoint())
3808 .collect();
3809 mids.sort();
3810 let mid = n / 2;
3811 if n % 2 == 0 {
3812 Some((mids[mid - 1] + mids[mid]) / Decimal::TWO)
3813 } else {
3814 Some(mids[mid])
3815 }
3816 }
3817
3818 pub fn upper_shadow_ratio(&self, n: usize) -> Option<Decimal> {
3823 if n == 0 || self.bars.len() < n {
3824 return None;
3825 }
3826 let start = self.bars.len() - n;
3827 let sum: Decimal = self.bars[start..]
3828 .iter()
3829 .map(|b| {
3830 let range = b.range();
3831 if range.is_zero() {
3832 Decimal::ZERO
3833 } else {
3834 (b.upper_shadow()) / range
3835 }
3836 })
3837 .sum();
3838 #[allow(clippy::cast_possible_truncation)]
3839 Some(sum / Decimal::from(n as u32))
3840 }
3841
3842 pub fn percent_gap_up_bars(&self, n: usize) -> Option<Decimal> {
3846 if n == 0 || self.bars.len() < n + 1 {
3847 return None;
3848 }
3849 let start = self.bars.len() - n;
3850 let count = (start..self.bars.len())
3851 .filter(|&i| self.bars[i].open > self.bars[i - 1].close)
3852 .count();
3853 #[allow(clippy::cast_possible_truncation)]
3854 Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
3855 }
3856
3857 pub fn consecutive_higher_closes(&self, n: usize) -> Option<usize> {
3864 if n < 2 || self.bars.len() < n {
3865 return None;
3866 }
3867 let start = self.bars.len() - n;
3868 let mut max_run = 0usize;
3869 let mut cur_run = 0usize;
3870 for i in (start + 1)..self.bars.len() {
3871 if self.bars[i].close > self.bars[i - 1].close {
3872 cur_run += 1;
3873 if cur_run > max_run { max_run = cur_run; }
3874 } else {
3875 cur_run = 0;
3876 }
3877 }
3878 Some(max_run)
3879 }
3880
3881 pub fn volume_weighted_return(&self, n: usize) -> Option<Decimal> {
3888 if n < 2 || self.bars.len() < n {
3889 return None;
3890 }
3891 let start = self.bars.len() - n;
3892 let mut vol_return_sum = Decimal::ZERO;
3893 let mut vol_sum = Decimal::ZERO;
3894 for i in (start + 1)..self.bars.len() {
3895 let prev_close = self.bars[i - 1].close.value();
3896 if prev_close.is_zero() { continue; }
3897 let ret = (self.bars[i].close.value() - prev_close) / prev_close;
3898 let vol = self.bars[i].volume.value();
3899 vol_return_sum += ret * vol;
3900 vol_sum += vol;
3901 }
3902 if vol_sum.is_zero() {
3903 return None;
3904 }
3905 Some(vol_return_sum / vol_sum)
3906 }
3907
3908 pub fn close_returns(&self, n: usize) -> Option<Vec<Decimal>> {
3913 if n < 2 || self.bars.len() < n {
3914 return None;
3915 }
3916 let start = self.bars.len() - n;
3917 let mut returns = Vec::with_capacity(n - 1);
3918 for i in (start + 1)..self.bars.len() {
3919 let prev = self.bars[i - 1].close.value();
3920 if prev.is_zero() {
3921 returns.push(Decimal::ZERO);
3922 } else {
3923 returns.push((self.bars[i].close.value() - prev) / prev);
3924 }
3925 }
3926 Some(returns)
3927 }
3928
3929 pub fn volatility_regime(&self, atr_period: usize, lookback: usize) -> Option<&'static str> {
3939 if atr_period == 0 || lookback == 0 {
3940 return None;
3941 }
3942 let needed = lookback + atr_period;
3943 if self.bars.len() < needed {
3944 return None;
3945 }
3946 let atr_series = self.atr_series(atr_period);
3947 let recent_atrs: Vec<Decimal> = atr_series
3948 .iter()
3949 .rev()
3950 .take(lookback)
3951 .filter_map(|v| *v)
3952 .collect();
3953 if recent_atrs.is_empty() {
3954 return None;
3955 }
3956 let mean: Decimal = recent_atrs.iter().copied().sum::<Decimal>()
3957 / Decimal::from(recent_atrs.len() as u32);
3958 if mean.is_zero() {
3959 return Some("medium");
3960 }
3961 let latest = *recent_atrs.first()?;
3962 let ratio = latest / mean;
3963 if ratio < Decimal::new(80, 2) {
3964 Some("low")
3965 } else if ratio > Decimal::new(120, 2) {
3966 Some("high")
3967 } else {
3968 Some("medium")
3969 }
3970 }
3971
3972 pub fn up_down_volume_ratio(&self, n: usize) -> Option<Decimal> {
3978 if n == 0 || self.bars.len() < n {
3979 return None;
3980 }
3981 let start = self.bars.len() - n;
3982 let mut up_vol = Decimal::ZERO;
3983 let mut dn_vol = Decimal::ZERO;
3984 for b in &self.bars[start..] {
3985 let vol = b.volume.value();
3986 if b.close > b.open { up_vol += vol; }
3987 else if b.close < b.open { dn_vol += vol; }
3988 }
3989 if dn_vol.is_zero() { return None; }
3990 Some(up_vol / dn_vol)
3991 }
3992
3993 pub fn avg_range_pct(&self, n: usize) -> Option<Decimal> {
3999 if n == 0 || self.bars.len() < n {
4000 return None;
4001 }
4002 let start = self.bars.len() - n;
4003 let mut sum = Decimal::ZERO;
4004 let mut count = 0usize;
4005 let hundred = Decimal::from(100u32);
4006 for b in &self.bars[start..] {
4007 let tp = b.typical_price();
4008 if tp.is_zero() { continue; }
4009 sum += (b.range()) / tp * hundred;
4010 count += 1;
4011 }
4012 if count == 0 { return None; }
4013 Some(sum / Decimal::from(count as u32))
4014 }
4015
4016 pub fn bar_efficiency(&self, n: usize) -> Option<f64> {
4024 use rust_decimal::prelude::ToPrimitive;
4025 if n < 2 || self.bars.len() < n {
4026 return None;
4027 }
4028 let start = self.bars.len() - n;
4029 let net = (self.bars.last().unwrap().close.value()
4030 - self.bars[start].close.value())
4031 .abs()
4032 .to_f64()
4033 .unwrap_or(0.0);
4034 let path: f64 = (start + 1..self.bars.len())
4035 .map(|i| {
4036 (self.bars[i].close.value() - self.bars[i - 1].close.value())
4037 .abs()
4038 .to_f64()
4039 .unwrap_or(0.0)
4040 })
4041 .sum();
4042 if path == 0.0 { return None; }
4043 Some(net / path)
4044 }
4045
4046 pub fn avg_bars_between_highs(&self, n: usize, m: usize) -> Option<f64> {
4052 if n == 0 || m <= n || self.bars.len() < m {
4053 return None;
4054 }
4055 let start = self.bars.len() - m;
4056 let mut high_indices: Vec<usize> = Vec::new();
4057 for i in (start + n)..self.bars.len() {
4058 let prev_max = self.bars[(i - n)..i]
4059 .iter()
4060 .map(|b| b.close.value())
4061 .max()
4062 .unwrap_or(Decimal::ZERO);
4063 if self.bars[i].close.value() > prev_max {
4064 high_indices.push(i);
4065 }
4066 }
4067 if high_indices.len() < 2 { return None; }
4068 let gaps: Vec<usize> = high_indices.windows(2).map(|w| w[1] - w[0]).collect();
4069 Some(gaps.iter().sum::<usize>() as f64 / gaps.len() as f64)
4070 }
4071
4072 pub fn breakout_bars(&self, n: usize) -> Option<usize> {
4080 if n == 0 || self.bars.len() <= n {
4081 return None;
4082 }
4083 let mut streak = 0usize;
4084 for i in (n..self.bars.len()).rev() {
4085 let prior_max = self.bars[(i - n)..i]
4086 .iter()
4087 .map(|b| b.close.value())
4088 .max()
4089 .unwrap_or(Decimal::ZERO);
4090 if self.bars[i].close.value() > prior_max {
4091 streak += 1;
4092 } else {
4093 break;
4094 }
4095 }
4096 Some(streak)
4097 }
4098
4099 pub fn doji_count(&self, n: usize, threshold: f64) -> Option<usize> {
4106 if n == 0 || self.bars.len() < n {
4107 return None;
4108 }
4109 let start = self.bars.len() - n;
4110 use rust_decimal::prelude::ToPrimitive;
4111 let count = self.bars[start..]
4112 .iter()
4113 .filter(|b| {
4114 let range = (b.range()).to_f64().unwrap_or(0.0);
4115 if range == 0.0 {
4116 return true; }
4118 let body = (b.close.value() - b.open.value())
4119 .abs()
4120 .to_f64()
4121 .unwrap_or(0.0);
4122 body / range < threshold
4123 })
4124 .count();
4125 Some(count)
4126 }
4127
4128 pub fn close_dispersion(&self, n: usize) -> Option<f64> {
4132 use rust_decimal::prelude::ToPrimitive;
4133 if n < 2 || self.bars.len() < n {
4134 return None;
4135 }
4136 let start = self.bars.len() - n;
4137 let vals: Vec<f64> = self.bars[start..]
4138 .iter()
4139 .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4140 .collect();
4141 let mean = vals.iter().sum::<f64>() / n as f64;
4142 if mean == 0.0 { return None; }
4143 let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
4144 Some(variance.sqrt() / mean)
4145 }
4146
4147 pub fn relative_volume(&self, n: usize) -> Option<Decimal> {
4151 if n == 0 || self.bars.len() < n {
4152 return None;
4153 }
4154 let start = self.bars.len() - n;
4155 let avg_vol: Decimal = self.bars[start..]
4156 .iter()
4157 .map(|b| b.volume.value())
4158 .sum::<Decimal>()
4159 / Decimal::from(n as u32);
4160 if avg_vol.is_zero() { return None; }
4161 let last_vol = self.bars.last()?.volume.value();
4162 Some(last_vol / avg_vol * Decimal::from(100u32))
4163 }
4164
4165 pub fn avg_oc_midpoint(&self, n: usize) -> Option<Decimal> {
4171 if n == 0 || self.bars.len() < n {
4172 return None;
4173 }
4174 let start = self.bars.len() - n;
4175 let sum: Decimal = self.bars[start..]
4176 .iter()
4177 .map(|b| (b.open.value() + b.close.value()) / Decimal::TWO)
4178 .sum();
4179 Some(sum / Decimal::from(n as u32))
4180 }
4181
4182 pub fn volume_spike_count(&self, n: usize, threshold: Decimal) -> Option<usize> {
4188 if n == 0 || self.bars.len() < n {
4189 return None;
4190 }
4191 let start = self.bars.len() - n;
4192 let avg_vol: Decimal = self.bars[start..]
4193 .iter()
4194 .map(|b| b.volume.value())
4195 .sum::<Decimal>()
4196 / Decimal::from(n as u32);
4197 if avg_vol.is_zero() { return None; }
4198 let limit = avg_vol * threshold;
4199 let count = self.bars[start..].iter().filter(|b| b.volume.value() > limit).count();
4200 Some(count)
4201 }
4202
4203 pub fn close_acceleration(&self, n: usize) -> Option<Decimal> {
4210 if n == 0 || self.bars.len() < n + 2 {
4211 return None;
4212 }
4213 let total = self.bars.len();
4214 let last_mom = self.bars[total - 1].close.value() - self.bars[total - 2].close.value();
4215 let first_idx = total - n - 1;
4216 let first_mom = self.bars[first_idx + 1].close.value() - self.bars[first_idx].close.value();
4217 Some(last_mom - first_mom)
4218 }
4219
4220 pub fn up_down_ratio(&self, n: usize) -> Option<Decimal> {
4226 if n == 0 || self.bars.len() < n {
4227 return None;
4228 }
4229 let start = self.bars.len() - n;
4230 let ups = self.bars[start..].iter().filter(|b| b.is_bullish()).count();
4231 let downs = self.bars[start..].iter().filter(|b| b.is_bearish()).count();
4232 if downs == 0 {
4233 return None;
4234 }
4235 Some(Decimal::from(ups as u32) / Decimal::from(downs as u32))
4236 }
4237
4238 pub fn consecutive_up_bars(&self, n: usize) -> Option<usize> {
4243 if n == 0 || self.bars.is_empty() {
4244 return None;
4245 }
4246 let window_start = self.bars.len().saturating_sub(n);
4247 let count = self.bars[window_start..]
4248 .iter()
4249 .rev()
4250 .take_while(|b| b.is_bullish())
4251 .count();
4252 Some(count)
4253 }
4254
4255 pub fn normalized_close(&self, n: usize) -> Option<f64> {
4261 use rust_decimal::prelude::ToPrimitive;
4262 if n < 2 || self.bars.len() < n {
4263 return None;
4264 }
4265 let start = self.bars.len() - n;
4266 let vals: Vec<f64> = self.bars[start..]
4267 .iter()
4268 .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4269 .collect();
4270 let mean = vals.iter().sum::<f64>() / n as f64;
4271 let std = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64).sqrt();
4272 if std == 0.0 { return None; }
4273 let last = *vals.last()?;
4274 Some((last - mean) / std)
4275 }
4276
4277 pub fn gap_counts(&self, n: usize) -> Option<(usize, usize)> {
4284 if n < 2 || self.bars.len() < n {
4285 return None;
4286 }
4287 let start = self.bars.len() - n;
4288 let mut ups = 0usize;
4289 let mut downs = 0usize;
4290 for i in (start + 1)..self.bars.len() {
4291 let prior_close = self.bars[i - 1].close.value();
4292 let cur_open = self.bars[i].open.value();
4293 if cur_open > prior_close { ups += 1; }
4294 else if cur_open < prior_close { downs += 1; }
4295 }
4296 Some((ups, downs))
4297 }
4298
4299 pub fn consecutive_volume_surge(&self, period: usize, factor: f64) -> Option<usize> {
4306 use rust_decimal::prelude::ToPrimitive;
4307 if period == 0 || factor <= 0.0 || self.bars.len() <= period {
4308 return None;
4309 }
4310 let mut streak = 0usize;
4311 let last = self.bars.len() - 1;
4313 let mut i = last;
4314 loop {
4315 if i < period {
4316 break;
4317 }
4318 let avg_vol: f64 = self.bars[(i - period)..i]
4319 .iter()
4320 .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4321 .sum::<f64>()
4322 / period as f64;
4323 let bar_vol = self.bars[i].volume.value().to_f64().unwrap_or(0.0);
4324 if avg_vol > 0.0 && bar_vol >= avg_vol * factor {
4325 streak += 1;
4326 } else {
4327 break;
4328 }
4329 if i == 0 { break; }
4330 i -= 1;
4331 }
4332 Some(streak)
4333 }
4334
4335 pub fn intrabar_range_expansion(&self, n: usize) -> Option<f64> {
4342 use rust_decimal::prelude::ToPrimitive;
4343 if n == 0 || self.bars.len() < n {
4344 return None;
4345 }
4346 let start = self.bars.len() - n;
4347 let avg_range: f64 = self.bars[start..]
4348 .iter()
4349 .map(|b| (b.range()).to_f64().unwrap_or(0.0))
4350 .sum::<f64>()
4351 / n as f64;
4352 if avg_range == 0.0 {
4353 return None;
4354 }
4355 let current = self.bars.last()?;
4356 let cur_range = (current.high.value() - current.low.value())
4357 .to_f64()
4358 .unwrap_or(0.0);
4359 Some(cur_range / avg_range)
4360 }
4361
4362 pub fn price_range_ratio(&self, n: usize) -> Option<Decimal> {
4368 if n < 2 || self.bars.len() < n {
4369 return None;
4370 }
4371 let start = self.bars.len() - n;
4372 let closes: Vec<Decimal> = self.bars[start..]
4373 .iter()
4374 .map(|b| b.close.value())
4375 .collect();
4376 let hi = closes.iter().copied().max()?;
4377 let lo = closes.iter().copied().min()?;
4378 let avg = closes.iter().sum::<Decimal>() / Decimal::from(n as u32);
4379 if avg.is_zero() { return None; }
4380 Some((hi - lo) / avg)
4381 }
4382
4383 pub fn close_volume_correlation(&self, n: usize) -> Option<f64> {
4387 use rust_decimal::prelude::ToPrimitive;
4388 if n < 2 || self.bars.len() < n {
4389 return None;
4390 }
4391 let start = self.bars.len() - n;
4392 let closes: Vec<f64> = self.bars[start..].iter()
4393 .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4394 .collect();
4395 let vols: Vec<f64> = self.bars[start..].iter()
4396 .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4397 .collect();
4398 let n_f = n as f64;
4399 let mean_c = closes.iter().sum::<f64>() / n_f;
4400 let mean_v = vols.iter().sum::<f64>() / n_f;
4401 let cov: f64 = closes.iter().zip(vols.iter())
4402 .map(|(c, v)| (c - mean_c) * (v - mean_v))
4403 .sum::<f64>() / n_f;
4404 let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / n_f).sqrt();
4405 let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / n_f).sqrt();
4406 if std_c == 0.0 || std_v == 0.0 { return None; }
4407 Some(cov / (std_c * std_v))
4408 }
4409
4410 pub fn close_relative_to_range(&self, n: usize) -> Option<Decimal> {
4416 if n == 0 || self.bars.len() < n {
4417 return None;
4418 }
4419 let start = self.bars.len() - n;
4420 let slice = &self.bars[start..];
4421 let high = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
4422 let low = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
4423 let range = high - low;
4424 if range.is_zero() {
4425 return None;
4426 }
4427 let close = self.bars.last()?.close.value();
4428 Some((close - low) / range)
4429 }
4430
4431 pub fn volume_sma(&self, n: usize) -> Option<Decimal> {
4435 if n == 0 || self.bars.len() < n {
4436 return None;
4437 }
4438 let start = self.bars.len() - n;
4439 #[allow(clippy::cast_possible_truncation)]
4440 let avg = self.bars[start..].iter().map(|b| b.volume.value()).sum::<Decimal>()
4441 / Decimal::from(n as u32);
4442 Some(avg)
4443 }
4444
4445 pub fn compression_ratio(&self, fast: usize, slow: usize) -> Option<Decimal> {
4454 if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow + 1 {
4455 return None;
4456 }
4457 let atr_avg = |n: usize| -> Option<Decimal> {
4458 let start = self.bars.len() - n;
4459 let trs: Decimal = self.bars[start..].iter().enumerate().map(|(i, b)| {
4460 let prev = if i == 0 { &self.bars[start - 1] } else { &self.bars[start + i - 1] };
4461 let hl = b.range();
4462 let hpc = (b.high.value() - prev.close.value()).abs();
4463 let lpc = (b.low.value() - prev.close.value()).abs();
4464 hl.max(hpc).max(lpc)
4465 }).sum();
4466 #[allow(clippy::cast_possible_truncation)]
4467 Some(trs / Decimal::from(n as u32))
4468 };
4469 let atr_fast = atr_avg(fast)?;
4470 let atr_slow = atr_avg(slow)?;
4471 if atr_slow.is_zero() { return None; }
4472 atr_fast.checked_div(atr_slow)
4473 }
4474
4475 pub fn typical_price_avg(&self, n: usize) -> Option<Decimal> {
4479 if n == 0 || self.bars.len() < n {
4480 return None;
4481 }
4482 let start = self.bars.len() - n;
4483 #[allow(clippy::cast_possible_truncation)]
4484 let avg = self.bars[start..]
4485 .iter()
4486 .map(|b| b.typical_price())
4487 .sum::<Decimal>()
4488 / Decimal::from(n as u32);
4489 Some(avg)
4490 }
4491
4492 pub fn avg_body_to_range(&self, n: usize) -> Option<Decimal> {
4499 if n == 0 || self.bars.len() < n {
4500 return None;
4501 }
4502 let start = self.bars.len() - n;
4503 let mut sum = Decimal::ZERO;
4504 let mut count = 0u32;
4505 for b in &self.bars[start..] {
4506 let range = b.range();
4507 if range.is_zero() { continue; }
4508 let body = b.body_size();
4509 sum += body
4510 .checked_div(range)
4511 .unwrap_or(Decimal::ZERO);
4512 count += 1;
4513 }
4514 if count == 0 { return None; }
4515 Some(sum / Decimal::from(count))
4516 }
4517
4518 pub fn avg_tick_count(&self, n: usize) -> Option<Decimal> {
4522 if n == 0 || self.bars.len() < n {
4523 return None;
4524 }
4525 let start = self.bars.len() - n;
4526 let sum: u64 = self.bars[start..].iter().map(|b| b.tick_count).sum();
4527 Some(Decimal::from(sum) / Decimal::from(n as u32))
4528 }
4529
4530 pub fn range_compression(&self, n: usize) -> Option<Decimal> {
4539 if n == 0 || self.bars.len() < n {
4540 return None;
4541 }
4542 let start = self.bars.len() - n;
4543 let max_range = self.bars[start..]
4544 .iter()
4545 .map(|b| b.range())
4546 .max()?;
4547 if max_range.is_zero() {
4548 return None;
4549 }
4550 let last = self.bars.last()?;
4551 let last_range = last.range();
4552 last_range.checked_div(max_range)
4553 }
4554
4555 pub fn largest_gap_pct(&self, n: usize) -> Option<Decimal> {
4562 if n < 2 || self.bars.len() < n {
4563 return None;
4564 }
4565 let start = self.bars.len() - n;
4566 let mut max_gap = Decimal::ZERO;
4567 for i in start + 1..self.bars.len() {
4568 let prev_close = self.bars[i - 1].close.value();
4569 if prev_close.is_zero() { return None; }
4570 let gap = (self.bars[i].open.value() - prev_close).abs()
4571 / prev_close
4572 * Decimal::from(100u32);
4573 if gap > max_gap { max_gap = gap; }
4574 }
4575 Some(max_gap)
4576 }
4577
4578 pub fn close_sma_crossover(&self, n: usize) -> Option<i8> {
4585 if n == 0 || self.bars.len() < n + 1 {
4586 return None;
4587 }
4588 let total = self.bars.len();
4589 #[allow(clippy::cast_possible_truncation)]
4590 let sma_now: Decimal = self.bars[total - n..]
4591 .iter()
4592 .map(|b| b.close.value())
4593 .sum::<Decimal>() / Decimal::from(n as u32);
4594 let sma_prev: Decimal = self.bars[total - n - 1..total - 1]
4595 .iter()
4596 .map(|b| b.close.value())
4597 .sum::<Decimal>() / Decimal::from(n as u32);
4598 let close_now = self.bars[total - 1].close.value();
4599 let close_prev = self.bars[total - 2].close.value();
4600 if close_prev <= sma_prev && close_now > sma_now {
4601 Some(1)
4602 } else if close_prev >= sma_prev && close_now < sma_now {
4603 Some(-1)
4604 } else {
4605 Some(0)
4606 }
4607 }
4608
4609
4610 pub fn max_volume_bar_idx(&self, n: usize) -> Option<usize> {
4614 if n == 0 || self.bars.len() < n {
4615 return None;
4616 }
4617 let start = self.bars.len() - n;
4618 self.bars[start..]
4619 .iter()
4620 .enumerate()
4621 .max_by(|a, b| a.1.volume.value().cmp(&b.1.volume.value()))
4622 .map(|(i, _)| i)
4623 }
4624
4625 pub fn range_pct_of_atr(&self, n: usize) -> Option<Decimal> {
4631 let atr = self.avg_true_range(n)?;
4632 if atr.is_zero() { return None; }
4633 let last = self.bars.last()?;
4634 let range = last.range();
4635 range.checked_div(atr).map(|r| r * Decimal::ONE_HUNDRED)
4636 }
4637
4638 pub fn max_close_drawdown(&self, n: usize) -> Option<Decimal> {
4645 if n == 0 || self.bars.len() < n {
4646 return None;
4647 }
4648 let start = self.bars.len() - n;
4649 let mut peak = self.bars[start].close.value();
4650 let mut max_dd = Decimal::ZERO;
4651 for b in &self.bars[start..] {
4652 let c = b.close.value();
4653 if c > peak { peak = c; }
4654 if !peak.is_zero() {
4655 let dd = (c - peak) / peak * Decimal::ONE_HUNDRED;
4656 if dd < max_dd { max_dd = dd; }
4657 }
4658 }
4659 Some(max_dd)
4660 }
4661
4662 pub fn close_above_sma_pct(&self, n: usize, sma_period: usize) -> Option<Decimal> {
4669 if n == 0 || sma_period == 0 || self.bars.len() < n + sma_period - 1 {
4670 return None;
4671 }
4672 let window_start = self.bars.len() - n;
4673 let mut above = 0u32;
4674 for (offset, b) in self.bars[window_start..].iter().enumerate() {
4675 let abs_idx = window_start + offset;
4676 if abs_idx + 1 < sma_period { continue; }
4677 let sma_start = abs_idx + 1 - sma_period;
4678 let sma = self.bars[sma_start..=abs_idx]
4679 .iter()
4680 .map(|x| x.close.value())
4681 .sum::<Decimal>()
4682 / Decimal::from(sma_period as u32);
4683 if b.close.value() > sma { above += 1; }
4684 }
4685 Some(Decimal::from(above) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
4686 }
4687
4688 pub fn swing_high_count(&self, n: usize, lookback: usize) -> Option<usize> {
4695 if n == 0 || lookback == 0 || self.bars.len() < n { return None; }
4696 let start = self.bars.len() - n;
4697 let slice = &self.bars[start..];
4698 let len = slice.len();
4699 let mut count = 0usize;
4700 for i in lookback..len.saturating_sub(lookback) {
4701 let peak = slice[i].high.value();
4702 let is_high = (0..lookback).all(|k| peak > slice[i - 1 - k].high.value())
4703 && (0..lookback).all(|k| peak > slice[i + 1 + k].high.value());
4704 if is_high { count += 1; }
4705 }
4706 Some(count)
4707 }
4708
4709 pub fn open_gap_pct(&self, n: usize) -> Option<Decimal> {
4716 if n < 2 || self.bars.len() < n {
4717 return None;
4718 }
4719 let start = self.bars.len() - n;
4720 let mut sum = Decimal::ZERO;
4721 for i in start..self.bars.len() {
4722 let prev_close = self.bars[i - 1].close.value();
4723 if prev_close.is_zero() { return None; }
4724 let gap = (self.bars[i].open.value() - prev_close).abs();
4725 sum += gap / prev_close * Decimal::ONE_HUNDRED;
4726 }
4727 Some(sum / Decimal::from((n - 1) as u32))
4728 }
4729
4730 pub fn volume_trend_ratio(&self, n: usize) -> Option<Decimal> {
4736 if n == 0 || self.bars.len() < n {
4737 return None;
4738 }
4739 let start = self.bars.len() - n;
4740 let mut up_sum = Decimal::ZERO;
4741 let mut up_count = 0u32;
4742 let mut down_sum = Decimal::ZERO;
4743 let mut down_count = 0u32;
4744 for b in &self.bars[start..] {
4745 let v = b.volume.value();
4746 if b.is_bullish() {
4747 up_sum += v;
4748 up_count += 1;
4749 } else if b.is_bearish() {
4750 down_sum += v;
4751 down_count += 1;
4752 }
4753 }
4754 if up_count == 0 || down_count == 0 { return None; }
4755 let avg_up = up_sum / Decimal::from(up_count);
4756 let avg_down = down_sum / Decimal::from(down_count);
4757 if avg_down.is_zero() { return None; }
4758 avg_up.checked_div(avg_down)
4759 }
4760
4761 pub fn avg_wick_pct(&self, n: usize) -> Option<Decimal> {
4768 if n == 0 || self.bars.len() < n { return None; }
4769 let start = self.bars.len() - n;
4770 let mut sum = Decimal::ZERO;
4771 for b in &self.bars[start..] {
4772 let range = b.range();
4773 if range.is_zero() { return None; }
4774 let upper_wick = b.high.value() - b.close.value().max(b.open.value());
4775 let lower_wick = b.close.value().min(b.open.value()) - b.low.value();
4776 sum += (upper_wick + lower_wick) / range * Decimal::from(100u32);
4777 }
4778 #[allow(clippy::cast_possible_truncation)]
4779 Some(sum / Decimal::from(n as u32))
4780 }
4781
4782 pub fn trend_continuation_pct(&self, n: usize) -> Option<Decimal> {
4788 if n == 0 || self.bars.len() < n + 1 { return None; }
4789 let start = self.bars.len() - n - 1;
4790 let mut continuing = 0u32;
4791 for i in 0..n {
4792 let prev_dir = self.bars[start + i].close.value()
4793 .cmp(&self.bars[start + i].open.value());
4794 let curr_dir = self.bars[start + i + 1].close.value()
4795 .cmp(&self.bars[start + i + 1].open.value());
4796 if prev_dir == curr_dir && prev_dir != std::cmp::Ordering::Equal {
4797 continuing += 1;
4798 }
4799 }
4800 #[allow(clippy::cast_possible_truncation)]
4801 Some(Decimal::from(continuing) / Decimal::from(n as u32) * Decimal::from(100u32))
4802 }
4803
4804 pub fn inside_bar_count(&self, n: usize) -> Option<usize> {
4806 if n == 0 || self.bars.len() < n { return None; }
4807 let start = self.bars.len() - n;
4808 let mut count = 0usize;
4809 for i in start..self.bars.len() {
4810 if i == 0 { continue; }
4811 let prev = &self.bars[i - 1];
4812 let cur = &self.bars[i];
4813 if cur.high <= prev.high && cur.low >= prev.low { count += 1; }
4814 }
4815 Some(count)
4816 }
4817
4818 pub fn outside_bar_count(&self, n: usize) -> Option<usize> {
4820 if n == 0 || self.bars.len() < n { return None; }
4821 let start = self.bars.len() - n;
4822 let mut count = 0usize;
4823 for i in start..self.bars.len() {
4824 if i == 0 { continue; }
4825 let prev = &self.bars[i - 1];
4826 let cur = &self.bars[i];
4827 if cur.high > prev.high && cur.low < prev.low { count += 1; }
4828 }
4829 Some(count)
4830 }
4831
4832 pub fn high_volume_price(&self, n: usize) -> Option<Decimal> {
4836 if n == 0 || self.bars.len() < n { return None; }
4837 let start = self.bars.len() - n;
4838 self.bars[start..].iter()
4839 .max_by_key(|b| b.volume.value())
4840 .map(|b| b.close.value())
4841 }
4842
4843 pub fn avg_close_minus_open(&self, n: usize) -> Option<Decimal> {
4849 if n == 0 || self.bars.len() < n { return None; }
4850 let start = self.bars.len() - n;
4851 let sum: Decimal = self.bars[start..]
4852 .iter()
4853 .map(|b| b.close.value() - b.open.value())
4854 .sum();
4855 #[allow(clippy::cast_possible_truncation)]
4856 Some(sum / Decimal::from(n as u32))
4857 }
4858
4859 pub fn avg_upper_shadow_pct(&self, n: usize) -> Option<Decimal> {
4866 if n == 0 || self.bars.len() < n { return None; }
4867 let start = self.bars.len() - n;
4868 let sum: Decimal = self.bars[start..].iter().map(|b| {
4869 let body_top = b.open.value().max(b.close.value());
4870 let shadow = b.high.value() - body_top;
4871 let close = b.close.value();
4872 if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4873 }).sum();
4874 #[allow(clippy::cast_possible_truncation)]
4875 Some(sum / Decimal::from(n as u32))
4876 }
4877
4878 pub fn avg_lower_shadow_pct(&self, n: usize) -> Option<Decimal> {
4885 if n == 0 || self.bars.len() < n { return None; }
4886 let start = self.bars.len() - n;
4887 let sum: Decimal = self.bars[start..].iter().map(|b| {
4888 let body_bottom = b.open.value().min(b.close.value());
4889 let shadow = body_bottom - b.low.value();
4890 let close = b.close.value();
4891 if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4892 }).sum();
4893 #[allow(clippy::cast_possible_truncation)]
4894 Some(sum / Decimal::from(n as u32))
4895 }
4896
4897 pub fn percent_doji(&self, n: usize) -> Option<Decimal> {
4903 if n == 0 || self.bars.len() < n { return None; }
4904 let start = self.bars.len() - n;
4905 let threshold = rust_decimal_macros::dec!(0.1);
4906 let mut doji_count = 0u32;
4907 for b in &self.bars[start..] {
4908 let range = b.range();
4909 let body = b.body_size();
4910 if range.is_zero() || body / range < threshold {
4911 doji_count += 1;
4912 }
4913 }
4914 #[allow(clippy::cast_possible_truncation)]
4915 Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::from(100u32))
4916 }
4917
4918 pub fn avg_close_range_pct(&self, n: usize) -> Option<Decimal> {
4925 if n == 0 || self.bars.len() < n { return None; }
4926 let start = self.bars.len() - n;
4927 let mut sum = Decimal::ZERO;
4928 for b in &self.bars[start..] {
4929 let range = b.range();
4930 if range.is_zero() { return None; }
4931 sum += (b.close.value() - b.low.value()) / range * Decimal::from(100u32);
4932 }
4933 #[allow(clippy::cast_possible_truncation)]
4934 Some(sum / Decimal::from(n as u32))
4935 }
4936
4937 pub fn price_channel_width(&self, n: usize) -> Option<Decimal> {
4943 if n == 0 || self.bars.len() < n { return None; }
4944 let start = self.bars.len() - n;
4945 let slice = &self.bars[start..];
4946 let max_high = slice.iter().map(|b| b.high.value()).max()?;
4947 let min_low = slice.iter().map(|b| b.low.value()).min()?;
4948 if min_low.is_zero() { return None; }
4949 Some((max_high - min_low) / min_low * Decimal::ONE_HUNDRED)
4950 }
4951
4952 pub fn avg_candle_efficiency(&self, n: usize) -> Option<Decimal> {
4958 if n == 0 || self.bars.len() < n { return None; }
4959 let start = self.bars.len() - n;
4960 let mut sum = Decimal::ZERO;
4961 for b in &self.bars[start..] {
4962 let range = b.range();
4963 if range.is_zero() { return None; }
4964 sum += b.body_size() / range;
4965 }
4966 #[allow(clippy::cast_possible_truncation)]
4967 Some(sum / Decimal::from(n as u32))
4968 }
4969
4970 pub fn volume_at_high(&self, n: usize) -> Option<Decimal> {
4975 if n == 0 || self.bars.len() < n { return None; }
4976 let start = self.bars.len() - n;
4977 let slice = &self.bars[start..];
4978 let mut running_high = slice[0].high.value();
4979 let mut total = slice[0].volume.value();
4980 for b in &slice[1..] {
4981 if b.high.value() > running_high {
4982 running_high = b.high.value();
4983 total += b.volume.value();
4984 }
4985 }
4986 Some(total)
4987 }
4988
4989 pub fn close_momentum_consistency(&self, n: usize) -> Option<Decimal> {
4993 if n == 0 || self.bars.len() < n + 1 { return None; }
4994 let start = self.bars.len() - n - 1;
4995 let mut up = 0u32;
4996 for i in 0..n {
4997 if self.bars[start + i + 1].close > self.bars[start + i].close {
4998 up += 1;
4999 }
5000 }
5001 #[allow(clippy::cast_possible_truncation)]
5002 Some(Decimal::from(up) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5003 }
5004
5005 pub fn price_gap_pct(&self) -> Option<Decimal> {
5009 let n = self.bars.len();
5010 if n < 2 { return None; }
5011 let prev_close = self.bars[n - 2].close.value();
5012 if prev_close.is_zero() { return None; }
5013 Some((self.bars[n - 1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
5014 }
5015
5016 pub fn longest_winning_streak(&self) -> usize {
5020 if self.bars.len() < 2 { return 0; }
5021 let mut max_streak = 0usize;
5022 let mut streak = 0usize;
5023 for i in 1..self.bars.len() {
5024 if self.bars[i].close > self.bars[i - 1].close {
5025 streak += 1;
5026 if streak > max_streak { max_streak = streak; }
5027 } else {
5028 streak = 0;
5029 }
5030 }
5031 max_streak
5032 }
5033
5034 pub fn avg_gap_pct(&self, n: usize) -> Option<Decimal> {
5039 if n == 0 || self.bars.len() < n + 1 { return None; }
5040 let start = self.bars.len() - n;
5041 let mut sum = Decimal::ZERO;
5042 for i in start..self.bars.len() {
5043 let prev_close = self.bars[i - 1].close.value();
5044 if prev_close.is_zero() { continue; }
5045 sum += (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5046 }
5047 #[allow(clippy::cast_possible_truncation)]
5048 Some(sum / Decimal::from(n as u32))
5049 }
5050
5051 pub fn intrabar_momentum(&self, n: usize) -> Option<Decimal> {
5056 if n == 0 || self.bars.len() < n { return None; }
5057 let start = self.bars.len() - n;
5058 let mut sum = Decimal::ZERO;
5059 let mut count = 0u32;
5060 for bar in &self.bars[start..] {
5061 let range = bar.range();
5062 if range.is_zero() { continue; }
5063 sum += (bar.close.value() - bar.open.value()) / range;
5064 count += 1;
5065 }
5066 if count == 0 { return None; }
5067 Some(sum / Decimal::from(count))
5068 }
5069
5070 pub fn volume_per_bar(&self, n: usize) -> Option<Decimal> {
5074 if n == 0 || self.bars.len() < n { return None; }
5075 let start = self.bars.len() - n;
5076 let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
5077 #[allow(clippy::cast_possible_truncation)]
5078 Some(sum / Decimal::from(n as u32))
5079 }
5080
5081 pub fn pct_bars_near_high(&self, n: usize, threshold_pct: Decimal) -> Option<Decimal> {
5086 if n == 0 || self.bars.len() < n { return None; }
5087 let start = self.bars.len() - n;
5088 let mut near = 0u32;
5089 for bar in &self.bars[start..] {
5090 let high = bar.high.value();
5091 if high.is_zero() { continue; }
5092 let dist_pct = (high - bar.close.value()) / high * Decimal::ONE_HUNDRED;
5093 if dist_pct <= threshold_pct {
5094 near += 1;
5095 }
5096 }
5097 #[allow(clippy::cast_possible_truncation)]
5098 Some(Decimal::from(near) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5099 }
5100
5101 pub fn avg_body_pct(&self, n: usize) -> Option<Decimal> {
5106 if n == 0 || self.bars.len() < n { return None; }
5107 let start = self.bars.len() - n;
5108 let mut sum = Decimal::ZERO;
5109 let mut count = 0u32;
5110 for bar in &self.bars[start..] {
5111 let range = bar.range();
5112 if range.is_zero() { continue; }
5113 sum += bar.body_size() / range * Decimal::ONE_HUNDRED;
5114 count += 1;
5115 }
5116 if count == 0 { return None; }
5117 Some(sum / Decimal::from(count))
5118 }
5119
5120 pub fn tail_ratio(&self, n: usize) -> Option<Decimal> {
5126 if n == 0 || self.bars.len() < n { return None; }
5127 let start = self.bars.len() - n;
5128 let mut sum = Decimal::ZERO;
5129 let mut count = 0u32;
5130 for bar in &self.bars[start..] {
5131 let body_top = bar.open.value().max(bar.close.value());
5132 let body_bot = bar.open.value().min(bar.close.value());
5133 let upper = bar.high.value() - body_top;
5134 let lower = body_bot - bar.low.value();
5135 if lower.is_zero() { continue; }
5136 sum += upper / lower;
5137 count += 1;
5138 }
5139 if count == 0 { return None; }
5140 Some(sum / Decimal::from(count))
5141 }
5142
5143 pub fn avg_volume_ratio(&self, n: usize, m: usize) -> Option<Decimal> {
5148 let len = self.bars.len();
5149 if n == 0 || m == 0 || len < n.max(m) { return None; }
5150 #[allow(clippy::cast_possible_truncation)]
5151 let avg_n: Decimal = self.bars[len - n..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5152 / Decimal::from(n as u32);
5153 #[allow(clippy::cast_possible_truncation)]
5154 let avg_m: Decimal = self.bars[len - m..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5155 / Decimal::from(m as u32);
5156 if avg_m.is_zero() { return None; }
5157 Some(avg_n / avg_m)
5158 }
5159
5160 pub fn open_close_correlation(&self, n: usize) -> Option<f64> {
5164 use rust_decimal::prelude::ToPrimitive;
5165 if n < 2 || self.bars.len() < n { return None; }
5166 let start = self.bars.len() - n;
5167 let opens: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.open.value().to_f64()).collect();
5168 let closes: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.close.value().to_f64()).collect();
5169 if opens.len() < 2 { return None; }
5170 let nf = opens.len() as f64;
5171 let mean_o = opens.iter().sum::<f64>() / nf;
5172 let mean_c = closes.iter().sum::<f64>() / nf;
5173 let cov: f64 = opens.iter().zip(closes.iter()).map(|(o, c)| (o - mean_o) * (c - mean_c)).sum::<f64>() / nf;
5174 let std_o = (opens.iter().map(|o| (o - mean_o).powi(2)).sum::<f64>() / nf).sqrt();
5175 let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
5176 if std_o == 0.0 || std_c == 0.0 { return None; }
5177 Some(cov / (std_o * std_c))
5178 }
5179
5180 pub fn price_acceleration(&self, n: usize) -> Option<Decimal> {
5186 if n < 4 || self.bars.len() < n + 1 { return None; }
5187 let start = self.bars.len() - n - 1;
5188 let half = n / 2;
5189 let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5190 .map(|i| self.bars[i + 1].close.value() - self.bars[i].close.value())
5191 .collect();
5192 #[allow(clippy::cast_possible_truncation)]
5193 let avg_first = changes[..half].iter().sum::<Decimal>() / Decimal::from(half as u32);
5194 #[allow(clippy::cast_possible_truncation)]
5195 let avg_second = changes[half..].iter().sum::<Decimal>() / Decimal::from((changes.len() - half) as u32);
5196 Some(avg_second - avg_first)
5197 }
5198
5199 pub fn returns_skewness(&self, n: usize) -> Option<f64> {
5204 use rust_decimal::prelude::ToPrimitive;
5205 if n == 0 || self.bars.len() < n + 1 { return None; }
5206 let start = self.bars.len() - n - 1;
5207 let returns: Vec<f64> = (start..self.bars.len() - 1)
5208 .filter_map(|i| {
5209 let prev = self.bars[i].close.value().to_f64()?;
5210 let curr = self.bars[i + 1].close.value().to_f64()?;
5211 if prev == 0.0 { return None; }
5212 Some((curr / prev).ln())
5213 })
5214 .collect();
5215 if returns.len() < 3 { return None; }
5216 let m = returns.len() as f64;
5217 let mean = returns.iter().sum::<f64>() / m;
5218 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m;
5219 let std = variance.sqrt();
5220 if std == 0.0 { return None; }
5221 Some(returns.iter().map(|r| ((r - mean) / std).powi(3)).sum::<f64>() / m)
5222 }
5223
5224 pub fn volume_zscore(&self, n: usize) -> Option<f64> {
5228 use rust_decimal::prelude::ToPrimitive;
5229 if n < 2 || self.bars.len() < n { return None; }
5230 let start = self.bars.len() - n;
5231 let vols: Vec<f64> = self.bars[start..].iter()
5232 .filter_map(|b| b.volume.value().to_f64())
5233 .collect();
5234 if vols.len() < 2 { return None; }
5235 let m = vols.len() as f64;
5236 let mean = vols.iter().sum::<f64>() / m;
5237 let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
5238 let std = variance.sqrt();
5239 if std == 0.0 { return None; }
5240 let last_vol = self.bars.last()?.volume.value().to_f64()?;
5241 Some((last_vol - mean) / std)
5242 }
5243
5244 pub fn upper_lower_shadow_ratio(&self) -> Option<Decimal> {
5250 let bar = self.bars.last()?;
5251 let body_top = bar.open.value().max(bar.close.value());
5252 let body_bot = bar.open.value().min(bar.close.value());
5253 let upper = bar.high.value() - body_top;
5254 let lower = body_bot - bar.low.value();
5255 if lower.is_zero() { return None; }
5256 Some(upper / lower)
5257 }
5258
5259 fn sma(&self, n: usize) -> Option<Decimal> {
5261 if n == 0 || self.bars.len() < n { return None; }
5262 let start = self.bars.len() - n;
5263 let sum: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
5264 Some(sum / Decimal::from(n as u32))
5265 }
5266
5267 fn atr(&self, n: usize) -> Option<Decimal> {
5269 if n == 0 || self.bars.len() < n + 1 { return None; }
5270 let start = self.bars.len() - n - 1;
5271 let mut sum = Decimal::ZERO;
5272 for i in start..self.bars.len() - 1 {
5273 let pc = self.bars[i].close.value();
5274 let h = self.bars[i + 1].high.value();
5275 let l = self.bars[i + 1].low.value();
5276 let tr = (h - l).max((h - pc).abs()).max((l - pc).abs());
5277 sum += tr;
5278 }
5279 Some(sum / Decimal::from(n as u32))
5280 }
5281
5282 fn ema(&self, n: usize) -> Option<Decimal> {
5284 if n == 0 || self.bars.len() < n { return None; }
5285 let start = self.bars.len() - n;
5286 let seed: Decimal = self.bars[start..start + n.min(self.bars.len() - start)]
5287 .iter().map(|b| b.close.value()).sum::<Decimal>()
5288 / Decimal::from(n as u32);
5289 let k = Decimal::from(2u32) / Decimal::from((n + 1) as u32);
5290 let mut e = seed;
5291 for bar in &self.bars[start + n..] {
5293 e = e * (Decimal::ONE - k) + bar.close.value() * k;
5294 }
5295 Some(e)
5296 }
5297
5298 pub fn mean_reversion_score(&self, n: usize) -> Option<Decimal> {
5303 let close = self.bars.last()?.close.value();
5304 let sma = self.sma(n)?;
5305 let atr = self.atr(n)?;
5306 if atr.is_zero() { return None; }
5307 Some((close - sma).abs() / atr)
5308 }
5309
5310 pub fn volume_price_trend(&self, n: usize) -> Option<Decimal> {
5315 if n == 0 || self.bars.len() < n + 1 { return None; }
5316 let start = self.bars.len() - n - 1;
5317 let mut vpt = Decimal::ZERO;
5318 for i in start..self.bars.len() - 1 {
5319 let prev_close = self.bars[i].close.value();
5320 if prev_close.is_zero() { continue; }
5321 let pct_chg = (self.bars[i + 1].close.value() - prev_close) / prev_close;
5322 vpt += pct_chg * self.bars[i + 1].volume.value();
5323 }
5324 Some(vpt)
5325 }
5326
5327 pub fn bear_run_length(&self) -> usize {
5331 let n = self.bars.len();
5332 if n < 2 { return 0; }
5333 let mut count = 0;
5334 let mut i = n - 1;
5335 while i > 0 && self.bars[i].close.value() < self.bars[i - 1].close.value() {
5336 count += 1;
5337 i -= 1;
5338 }
5339 count
5340 }
5341
5342 pub fn avg_true_range_pct(&self, n: usize) -> Option<Decimal> {
5347 let atr = self.atr(n)?;
5348 let close = self.bars.last()?.close.value();
5349 if close.is_zero() { return None; }
5350 Some(atr / close * Decimal::ONE_HUNDRED)
5351 }
5352
5353 pub fn close_vs_ema(&self, n: usize) -> Option<Decimal> {
5358 let ema = self.ema(n)?;
5359 let close = self.bars.last()?.close.value();
5360 if close.is_zero() { return None; }
5361 Some((close - ema) / close * Decimal::ONE_HUNDRED)
5362 }
5363
5364 pub fn volume_momentum(&self, n: usize) -> Option<Decimal> {
5369 if n < 2 || self.bars.len() < n { return None; }
5370 let start = self.bars.len() - n;
5371 let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5372 .map(|i| self.bars[i + 1].volume.value() - self.bars[i].volume.value())
5373 .collect();
5374 if changes.is_empty() { return None; }
5375 #[allow(clippy::cast_possible_truncation)]
5376 Some(changes.iter().sum::<Decimal>() / Decimal::from(changes.len() as u32))
5377 }
5378
5379 pub fn max_volume_bar(&self, n: usize) -> Option<usize> {
5384 if n == 0 || self.bars.len() < n { return None; }
5385 let start = self.bars.len() - n;
5386 let (rel_idx, _) = self.bars[start..]
5387 .iter()
5388 .enumerate()
5389 .max_by_key(|(_, b)| b.volume.value())?;
5390 Some(n - 1 - rel_idx)
5391 }
5392
5393 pub fn gap_count(&self, n: usize, min_pct: Decimal) -> Option<usize> {
5398 if n == 0 || self.bars.len() < n + 1 { return None; }
5399 let start = self.bars.len() - n;
5400 let count = (start..self.bars.len()).filter(|&i| {
5401 let prev_close = self.bars[i - 1].close.value();
5402 if prev_close.is_zero() { return false; }
5403 let gap = (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5404 gap >= min_pct
5405 }).count();
5406 Some(count)
5407 }
5408
5409 pub fn avg_close_pct_change(&self, n: usize) -> Option<Decimal> {
5414 if n == 0 || self.bars.len() < n + 1 { return None; }
5415 let start = self.bars.len() - n - 1;
5416 let mut sum = Decimal::ZERO;
5417 for i in start..self.bars.len() - 1 {
5418 let prev = self.bars[i].close.value();
5419 if prev.is_zero() { return None; }
5420 sum += (self.bars[i + 1].close.value() - prev) / prev * Decimal::ONE_HUNDRED;
5421 }
5422 #[allow(clippy::cast_possible_truncation)]
5423 Some(sum / Decimal::from(n as u32))
5424 }
5425
5426 pub fn bollinger_width(&self, n: usize, multiplier: Decimal) -> Option<Decimal> {
5431 let sma = self.sma(n)?;
5432 if sma.is_zero() { return None; }
5433 let std = self.std_dev(n)?;
5434 let upper = sma + multiplier * std;
5435 let lower = sma - multiplier * std;
5436 Some((upper - lower) / sma)
5437 }
5438
5439 pub fn close_above_ma_streak(&self, period: usize) -> usize {
5444 if self.bars.len() < period { return 0; }
5445 let mut streak = 0usize;
5446 for i in (period - 1..self.bars.len()).rev() {
5448 let sum: Decimal = (0..period).map(|j| self.bars[i + 1 - period + j].close.value()).sum();
5449 #[allow(clippy::cast_possible_truncation)]
5450 let sma = sum / Decimal::from(period as u32);
5451 if self.bars[i].close.value() >= sma {
5452 streak += 1;
5453 } else {
5454 break;
5455 }
5456 }
5457 streak
5458 }
5459
5460 pub fn avg_body_to_range_ratio(&self, n: usize) -> Option<Decimal> {
5464 if n == 0 || self.bars.len() < n { return None; }
5465 let start = self.bars.len() - n;
5466 let mut sum = Decimal::ZERO;
5467 let mut count = 0u32;
5468 for bar in &self.bars[start..] {
5469 let range = bar.range();
5470 if range.is_zero() { continue; }
5471 sum += bar.body_size() / range;
5472 count += 1;
5473 }
5474 if count == 0 { return None; }
5475 Some(sum / Decimal::from(count))
5476 }
5477
5478 pub fn net_volume(&self, n: usize) -> Option<Decimal> {
5484 if n == 0 || self.bars.len() < n { return None; }
5485 let start = self.bars.len() - n;
5486 let mut net = Decimal::ZERO;
5487 for bar in &self.bars[start..] {
5488 let range = bar.range();
5489 let vol = bar.volume.value();
5490 if range.is_zero() { continue; }
5491 let buy_frac = (bar.close.value() - bar.low.value()) / range;
5492 let buy_vol = vol * buy_frac;
5493 let sell_vol = vol - buy_vol;
5494 net += buy_vol - sell_vol;
5495 }
5496 Some(net)
5497 }
5498
5499 pub fn avg_high_minus_open(&self, n: usize) -> Option<Decimal> {
5504 if n == 0 || self.bars.len() < n { return None; }
5505 let start = self.bars.len() - n;
5506 #[allow(clippy::cast_possible_truncation)]
5507 let sum: Decimal = self.bars[start..].iter()
5508 .map(|b| b.high.value() - b.open.value())
5509 .sum();
5510 Some(sum / Decimal::from(n as u32))
5511 }
5512
5513 pub fn close_consistency(&self, n: usize) -> Option<Decimal> {
5518 if n == 0 || self.bars.len() < n { return None; }
5519 let start = self.bars.len() - n;
5520 let upper = self.bars[start..].iter().filter(|b| {
5521 let mid = b.midpoint();
5522 b.close.value() >= mid
5523 }).count();
5524 #[allow(clippy::cast_possible_truncation)]
5525 Some(Decimal::from(upper as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5526 }
5527
5528 pub fn momentum_divergence(&self, fast: usize, slow: usize) -> Option<Decimal> {
5534 if fast == 0 || slow == 0 || fast >= slow { return None; }
5535 if self.bars.len() <= slow { return None; }
5536 let n = self.bars.len();
5537 let current = self.bars[n - 1].close.value();
5538 let fast_prev = self.bars[n - 1 - fast].close.value();
5539 let slow_prev = self.bars[n - 1 - slow].close.value();
5540 Some((current - fast_prev) - (current - slow_prev))
5541 }
5542
5543 pub fn price_range_pct(&self, n: usize) -> Option<Decimal> {
5547 if n == 0 || self.bars.len() < n { return None; }
5548 let start = self.bars.len() - n;
5549 let high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5550 let low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5551 if low.is_zero() { return None; }
5552 Some((high - low) / low * Decimal::ONE_HUNDRED)
5553 }
5554
5555 pub fn avg_open_to_close(&self, n: usize) -> Option<Decimal> {
5560 if n == 0 || self.bars.len() < n { return None; }
5561 let start = self.bars.len() - n;
5562 #[allow(clippy::cast_possible_truncation)]
5563 let sum: Decimal = self.bars[start..].iter()
5564 .map(|b| b.close.value() - b.open.value())
5565 .sum();
5566 Some(sum / Decimal::from(n as u32))
5567 }
5568
5569 pub fn price_range_expansion(&self, n: usize) -> Option<Decimal> {
5574 if n == 0 || self.bars.len() < 2 * n { return None; }
5575 let len = self.bars.len();
5576 #[allow(clippy::cast_possible_truncation)]
5577 let n_dec = Decimal::from(n as u32);
5578 let recent_sum: Decimal = self.bars[len - n..].iter()
5579 .map(|b| b.range())
5580 .sum();
5581 let prior_sum: Decimal = self.bars[len - 2 * n..len - n].iter()
5582 .map(|b| b.range())
5583 .sum();
5584 Some((recent_sum - prior_sum) / n_dec)
5585 }
5586
5587 pub fn up_volume_fraction(&self, n: usize) -> Option<Decimal> {
5591 if n == 0 || self.bars.len() < n { return None; }
5592 let start = self.bars.len() - n;
5593 let mut up_vol = Decimal::ZERO;
5594 let mut total_vol = Decimal::ZERO;
5595 for bar in &self.bars[start..] {
5596 let v = bar.volume.value();
5597 total_vol += v;
5598 if bar.close.value() > bar.open.value() {
5599 up_vol += v;
5600 }
5601 }
5602 if total_vol.is_zero() { return None; }
5603 Some(up_vol / total_vol)
5604 }
5605
5606 pub fn std_volume(&self, n: usize) -> Option<f64> {
5610 use rust_decimal::prelude::ToPrimitive;
5611 if n < 2 || self.bars.len() < n { return None; }
5612 let start = self.bars.len() - n;
5613 let vols: Vec<f64> = self.bars[start..].iter()
5614 .filter_map(|b| b.volume.value().to_f64())
5615 .collect();
5616 if vols.len() < 2 { return None; }
5617 let nf = vols.len() as f64;
5618 let mean = vols.iter().sum::<f64>() / nf;
5619 let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0);
5620 Some(var.sqrt())
5621 }
5622
5623 pub fn longest_losing_streak(&self) -> usize {
5627 if self.bars.len() < 2 { return 0; }
5628 let mut max_streak = 0usize;
5629 let mut current = 0usize;
5630 for i in 1..self.bars.len() {
5631 if self.bars[i].close.value() < self.bars[i - 1].close.value() {
5632 current += 1;
5633 if current > max_streak { max_streak = current; }
5634 } else {
5635 current = 0;
5636 }
5637 }
5638 max_streak
5639 }
5640
5641 pub fn recent_max_close(&self, n: usize) -> Option<Decimal> {
5645 if n == 0 || self.bars.len() < n { return None; }
5646 let start = self.bars.len() - n;
5647 self.bars[start..].iter().map(|b| b.close.value()).max()
5648 }
5649
5650 pub fn recent_min_close(&self, n: usize) -> Option<Decimal> {
5654 if n == 0 || self.bars.len() < n { return None; }
5655 let start = self.bars.len() - n;
5656 self.bars[start..].iter().map(|b| b.close.value()).min()
5657 }
5658
5659 pub fn chaikin_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
5664 if fast == 0 || slow == 0 || fast >= slow || self.bars.len() <= slow { return None; }
5665 let n = self.bars.len();
5666 let alpha_fast = Decimal::TWO / Decimal::from(fast + 1);
5669 let alpha_slow = Decimal::TWO / Decimal::from(slow + 1);
5670 let start = n - slow;
5671 let mut ema_fast = self.bars[start].close.value() * self.bars[start].volume.value();
5672 let mut ema_slow = ema_fast;
5673 for bar in &self.bars[start + 1..] {
5674 let adv = bar.close.value() * bar.volume.value();
5675 ema_fast = alpha_fast * adv + (Decimal::ONE - alpha_fast) * ema_fast;
5676 ema_slow = alpha_slow * adv + (Decimal::ONE - alpha_slow) * ema_slow;
5677 }
5678 Some(ema_fast - ema_slow)
5679 }
5680
5681 pub fn candle_body_trend(&self, n: usize) -> Option<i64> {
5687 if n == 0 || self.bars.len() < n { return None; }
5688 let start = self.bars.len() - n;
5689 let bull = self.bars[start..].iter()
5690 .filter(|b| b.is_bullish()).count() as i64;
5691 let bear = self.bars[start..].iter()
5692 .filter(|b| b.is_bearish()).count() as i64;
5693 Some(bull - bear)
5694 }
5695
5696 pub fn pct_doji(&self, n: usize) -> Option<Decimal> {
5698 if n == 0 || self.bars.len() < n { return None; }
5699 let start = self.bars.len() - n;
5700 let doji_count = self.bars[start..].iter().filter(|b| {
5701 let range = b.range();
5702 if range.is_zero() { return true; }
5703 let body = b.body_size();
5704 body / range <= Decimal::new(1, 1)
5705 }).count() as u32;
5706 Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5707 }
5708
5709 pub fn recent_close_trend(&self, n: usize) -> Option<i64> {
5712 if n < 2 || self.bars.len() < n { return None; }
5713 let start = self.bars.len() - n;
5714 let closes: Vec<f64> = self.bars[start..]
5715 .iter()
5716 .map(|b| { use rust_decimal::prelude::ToPrimitive; b.close.value().to_f64().unwrap_or(0.0) })
5717 .collect();
5718 let m = closes.len() as f64;
5719 let x_mean = (m - 1.0) / 2.0;
5720 let y_mean: f64 = closes.iter().sum::<f64>() / m;
5721 let mut num = 0.0f64;
5722 let mut den = 0.0f64;
5723 for (i, &y) in closes.iter().enumerate() {
5724 let dx = i as f64 - x_mean;
5725 num += dx * (y - y_mean);
5726 den += dx * dx;
5727 }
5728 if den == 0.0 { return Some(0); }
5729 let slope = num / den;
5730 if slope > 1e-10 { Some(1) } else if slope < -1e-10 { Some(-1) } else { Some(0) }
5731 }
5732
5733 pub fn high_low_range(&self, n: usize) -> Option<Decimal> {
5735 if n == 0 || self.bars.len() < n { return None; }
5736 let start = self.bars.len() - n;
5737 let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5738 let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5739 Some(max_high - min_low)
5740 }
5741
5742 pub fn volume_above_avg_count(&self, n: usize) -> Option<usize> {
5746 if n == 0 || self.bars.len() < n { return None; }
5747 let start = self.bars.len() - n;
5748 let vols: Vec<Decimal> = self.bars[start..].iter().map(|b| b.volume.value()).collect();
5749 let avg = vols.iter().sum::<Decimal>() / Decimal::from(n);
5750 Some(vols.iter().filter(|&&v| v > avg).count())
5751 }
5752
5753 pub fn range_vs_atr_ratio(&self, n: usize) -> Option<Decimal> {
5757 if n == 0 || self.bars.len() < n { return None; }
5758 let start = self.bars.len() - n;
5759 let avg_range = self.bars[start..].iter()
5760 .map(|b| b.range())
5761 .sum::<Decimal>() / Decimal::from(n);
5762 if avg_range.is_zero() { return None; }
5763 let last = self.bars.last()?;
5764 Some((last.range()) / avg_range)
5765 }
5766
5767 pub fn avg_volume_on_up_bars(&self, n: usize) -> Option<Decimal> {
5769 if n == 0 || self.bars.len() < n { return None; }
5770 let start = self.bars.len() - n;
5771 let up_vols: Vec<Decimal> = self.bars[start..].iter()
5772 .filter(|b| b.is_bullish())
5773 .map(|b| b.volume.value())
5774 .collect();
5775 if up_vols.is_empty() { return None; }
5776 Some(up_vols.iter().sum::<Decimal>() / Decimal::from(up_vols.len() as u32))
5777 }
5778
5779 pub fn avg_volume_on_down_bars(&self, n: usize) -> Option<Decimal> {
5781 if n == 0 || self.bars.len() < n { return None; }
5782 let start = self.bars.len() - n;
5783 let down_vols: Vec<Decimal> = self.bars[start..].iter()
5784 .filter(|b| b.is_bearish())
5785 .map(|b| b.volume.value())
5786 .collect();
5787 if down_vols.is_empty() { return None; }
5788 Some(down_vols.iter().sum::<Decimal>() / Decimal::from(down_vols.len() as u32))
5789 }
5790
5791 pub fn pct_bars_close_above_open(&self, n: usize) -> Option<Decimal> {
5793 if n == 0 || self.bars.len() < n { return None; }
5794 let start = self.bars.len() - n;
5795 let bull = self.bars[start..].iter()
5796 .filter(|b| b.is_bullish())
5797 .count() as u32;
5798 Some(Decimal::from(bull) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5799 }
5800
5801 pub fn open_range_position(&self, n: usize) -> Option<Decimal> {
5805 if n == 0 || self.bars.len() < n { return None; }
5806 let start = self.bars.len() - n;
5807 let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5808 let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5809 let range = max_high - min_low;
5810 if range.is_zero() { return None; }
5811 let last_open = self.bars.last()?.open.value();
5812 Some((last_open - min_low) / range)
5813 }
5814
5815 pub fn overnight_gap_count(&self, n: usize, threshold_pct: Decimal) -> Option<usize> {
5819 if n < 2 || self.bars.len() <= n { return None; }
5820 let start = self.bars.len() - n;
5821 let threshold = threshold_pct / Decimal::ONE_HUNDRED;
5822 let count = self.bars[start..].iter().enumerate().filter(|(i, b)| {
5823 let prev_close = self.bars[start + i - 1].close.value();
5824 if prev_close.is_zero() { return false; }
5825 let gap = (b.open.value() - prev_close).abs() / prev_close;
5826 gap >= threshold
5827 }).count();
5828 Some(count)
5829 }
5830
5831 pub fn trend_consistency(&self, n: usize) -> Option<Decimal> {
5836 if n < 2 || self.bars.len() < n { return None; }
5837 let start = self.bars.len() - n;
5838 let first_close = self.bars[start].close.value();
5839 let last_close = self.bars.last()?.close.value();
5840 if first_close == last_close { return Some(Decimal::ZERO); }
5841 let up_trend = last_close > first_close;
5842 let consistent: usize = self.bars[start + 1..].iter().enumerate()
5843 .filter(|(i, b)| {
5844 let prev = self.bars[start + i].close.value();
5845 if up_trend { b.close.value() > prev } else { b.close.value() < prev }
5846 })
5847 .count();
5848 Some(Decimal::from(consistent) / Decimal::from(n - 1))
5849 }
5850
5851 pub fn last_close(&self) -> Option<Decimal> {
5853 self.bars.last().map(|b| b.close.value())
5854 }
5855
5856 pub fn first_close(&self) -> Option<Decimal> {
5858 self.bars.first().map(|b| b.close.value())
5859 }
5860
5861 pub fn close_change_n(&self, n: usize) -> Option<Decimal> {
5865 if n == 0 || self.bars.len() <= n { return None; }
5866 let prev = self.bars[self.bars.len() - 1 - n].close.value();
5867 let last = self.bars.last()?.close.value();
5868 Some(last - prev)
5869 }
5870
5871 pub fn pct_change_n(&self, n: usize) -> Option<Decimal> {
5875 if n == 0 || self.bars.len() <= n { return None; }
5876 let prev = self.bars[self.bars.len() - 1 - n].close.value();
5877 if prev.is_zero() { return None; }
5878 let last = self.bars.last()?.close.value();
5879 Some((last - prev) / prev * Decimal::ONE_HUNDRED)
5880 }
5881
5882 pub fn close_to_high_ratio(&self, n: usize) -> Option<Decimal> {
5886 if n == 0 || self.bars.len() < n { return None; }
5887 let start = self.bars.len() - n;
5888 let mut sum = Decimal::ZERO;
5889 let mut count = 0u32;
5890 for b in &self.bars[start..] {
5891 let range = b.range();
5892 if range.is_zero() { continue; }
5893 sum += (b.high.value() - b.close.value()) / range;
5894 count += 1;
5895 }
5896 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5897 }
5898
5899 pub fn close_to_low_ratio(&self, n: usize) -> Option<Decimal> {
5903 if n == 0 || self.bars.len() < n { return None; }
5904 let start = self.bars.len() - n;
5905 let mut sum = Decimal::ZERO;
5906 let mut count = 0u32;
5907 for b in &self.bars[start..] {
5908 let range = b.range();
5909 if range.is_zero() { continue; }
5910 sum += (b.close.value() - b.low.value()) / range;
5911 count += 1;
5912 }
5913 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5914 }
5915
5916 pub fn volume_coefficient_of_variation(&self, n: usize) -> Option<f64> {
5920 if n < 2 || self.bars.len() < n { return None; }
5921 let start = self.bars.len() - n;
5922 let vols: Vec<f64> = self.bars[start..]
5923 .iter()
5924 .map(|b| { use rust_decimal::prelude::ToPrimitive; b.volume.value().to_f64().unwrap_or(0.0) })
5925 .collect();
5926 let mean = vols.iter().sum::<f64>() / vols.len() as f64;
5927 if mean == 0.0 { return None; }
5928 let variance = vols.iter().map(|&v| { let d = v - mean; d * d }).sum::<f64>() / vols.len() as f64;
5929 Some(variance.sqrt() / mean)
5930 }
5931
5932 pub fn close_wick_ratio(&self, n: usize) -> Option<Decimal> {
5936 if n == 0 || self.bars.len() < n { return None; }
5937 let start = self.bars.len() - n;
5938 let mut sum = Decimal::ZERO;
5939 let mut count = 0u32;
5940 for b in &self.bars[start..] {
5941 let range = b.range();
5942 if range.is_zero() { continue; }
5943 let body_top = b.open.value().max(b.close.value());
5944 let upper_wick = b.high.value() - body_top;
5945 sum += upper_wick / range;
5946 count += 1;
5947 }
5948 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5949 }
5950
5951 pub fn wick_imbalance(&self, n: usize) -> Option<Decimal> {
5956 if n == 0 || self.bars.len() < n { return None; }
5957 let start = self.bars.len() - n;
5958 let mut upper_sum = Decimal::ZERO;
5959 let mut lower_sum = Decimal::ZERO;
5960 let mut range_sum = Decimal::ZERO;
5961 for b in &self.bars[start..] {
5962 let range = b.range();
5963 if range.is_zero() { continue; }
5964 let body_top = b.open.value().max(b.close.value());
5965 let body_bot = b.open.value().min(b.close.value());
5966 upper_sum += b.high.value() - body_top;
5967 lower_sum += body_bot - b.low.value();
5968 range_sum += range;
5969 }
5970 if range_sum.is_zero() { return None; }
5971 Some((upper_sum - lower_sum) / range_sum)
5972 }
5973
5974 pub fn avg_candle_size(&self, n: usize) -> Option<Decimal> {
5978 if n == 0 || self.bars.len() < n { return None; }
5979 let start = self.bars.len() - n;
5980 Some(self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>()
5981 / Decimal::from(n))
5982 }
5983
5984 pub fn bull_strength(&self, n: usize) -> Option<Decimal> {
5988 if n == 0 || self.bars.len() < n { return None; }
5989 let start = self.bars.len() - n;
5990 let mut sum = Decimal::ZERO;
5991 let mut count = 0u32;
5992 for b in &self.bars[start..] {
5993 if b.close.value() <= b.open.value() { continue; }
5994 let range = b.range();
5995 if range.is_zero() { continue; }
5996 sum += (b.close.value() - b.open.value()) / range;
5997 count += 1;
5998 }
5999 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6000 }
6001
6002 pub fn bear_strength(&self, n: usize) -> Option<Decimal> {
6004 if n == 0 || self.bars.len() < n { return None; }
6005 let start = self.bars.len() - n;
6006 let mut sum = Decimal::ZERO;
6007 let mut count = 0u32;
6008 for b in &self.bars[start..] {
6009 if b.is_bullish() { continue; }
6010 let range = b.range();
6011 if range.is_zero() { continue; }
6012 sum += (b.open.value() - b.close.value()) / range;
6013 count += 1;
6014 }
6015 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6016 }
6017
6018 pub fn last_open(&self) -> Option<Decimal> {
6020 self.bars.last().map(|b| b.open.value())
6021 }
6022
6023 pub fn last_high(&self) -> Option<Decimal> {
6025 self.bars.last().map(|b| b.high.value())
6026 }
6027
6028 pub fn last_low(&self) -> Option<Decimal> {
6030 self.bars.last().map(|b| b.low.value())
6031 }
6032
6033 pub fn last_volume(&self) -> Option<Decimal> {
6035 self.bars.last().map(|b| b.volume.value())
6036 }
6037
6038 pub fn close_above_prev_high(&self, n: usize) -> Option<usize> {
6042 if n == 0 || self.bars.len() <= n { return None; }
6043 let start = self.bars.len() - n;
6044 let count = self.bars[start..].iter().enumerate()
6046 .filter(|(i, b)| b.close.value() > self.bars[start - 1 + i].high.value())
6047 .count();
6048 Some(count)
6049 }
6050
6051 pub fn price_entropy(&self, n: usize) -> Option<f64> {
6055 if n < 2 || self.bars.len() < n { return None; }
6056 let start = self.bars.len() - n;
6057 let mut ups = 0usize;
6058 for i in start + 1..self.bars.len() {
6059 if self.bars[i].close.value() > self.bars[i - 1].close.value() { ups += 1; }
6060 }
6061 let total = n - 1;
6062 if ups == 0 || ups == total { return None; }
6063 let p = ups as f64 / total as f64;
6064 let q = 1.0 - p;
6065 Some(-(p * p.log2() + q * q.log2()))
6066 }
6067
6068 pub fn avg_spread_pct(&self, n: usize) -> Option<Decimal> {
6072 if n == 0 || self.bars.len() < n { return None; }
6073 let start = self.bars.len() - n;
6074 let mut sum = Decimal::ZERO;
6075 for b in &self.bars[start..] {
6076 let close = b.close.value();
6077 if close.is_zero() { return None; }
6078 sum += (b.range()) / close * Decimal::ONE_HUNDRED;
6079 }
6080 Some(sum / Decimal::from(n))
6081 }
6082
6083 pub fn close_momentum_ratio(&self, n: usize) -> Option<Decimal> {
6087 if n == 0 || self.bars.len() <= n { return None; }
6088 let prev = self.bars[self.bars.len() - 1 - n].close.value();
6089 if prev.is_zero() { return None; }
6090 Some(self.bars.last()?.close.value() / prev)
6091 }
6092
6093 pub fn price_velocity(&self, fast: usize, slow: usize) -> Option<Decimal> {
6097 let fast_chg = self.pct_change_n(fast)?;
6098 let slow_chg = self.pct_change_n(slow)?;
6099 Some(fast_chg - slow_chg)
6100 }
6101
6102 pub fn longest_flat_streak(&self) -> usize {
6104 let mut max_run = 0usize;
6105 let mut run = 0usize;
6106 for b in &self.bars {
6107 if b.close.value() == b.open.value() {
6108 run += 1;
6109 max_run = max_run.max(run);
6110 } else {
6111 run = 0;
6112 }
6113 }
6114 max_run
6115 }
6116
6117 pub fn bars_since_new_high(&self) -> Option<usize> {
6121 if self.bars.is_empty() { return None; }
6122 let mut last_high_idx = 0;
6123 let mut peak = self.bars[0].close.value();
6124 for (i, b) in self.bars.iter().enumerate() {
6125 if b.close.value() >= peak {
6126 peak = b.close.value();
6127 last_high_idx = i;
6128 }
6129 }
6130 Some(self.bars.len() - 1 - last_high_idx)
6131 }
6132
6133 pub fn drawdown_from_peak(&self, n: usize) -> Option<Decimal> {
6138 if n == 0 || self.bars.len() < n { return None; }
6139 let start = self.bars.len() - n;
6140 let peak = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6141 if peak.is_zero() { return None; }
6142 let current = self.bars.last()?.close.value();
6143 Some((current - peak) / peak * Decimal::ONE_HUNDRED)
6144 }
6145
6146 pub fn price_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
6151 if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow { return None; }
6152 let n = self.bars.len();
6153 let fast_start = n - fast;
6154 let slow_start = n - slow;
6155 let fast_sma = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6156 / Decimal::from(fast);
6157 let slow_sma = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6158 / Decimal::from(slow);
6159 if slow_sma.is_zero() { return None; }
6160 Some((fast_sma - slow_sma) / slow_sma * Decimal::ONE_HUNDRED)
6161 }
6162
6163 pub fn close_below_prev_low(&self, n: usize) -> Option<usize> {
6167 if n == 0 || self.bars.len() <= n { return None; }
6168 let start = self.bars.len() - n;
6169 let count = self.bars[start..].iter().enumerate()
6170 .filter(|(i, b)| b.close.value() < self.bars[start - 1 + i].low.value())
6171 .count();
6172 Some(count)
6173 }
6174
6175 pub fn bars_above_ma(&self, n: usize, period: usize) -> Option<usize> {
6179 if n == 0 || period == 0 || self.bars.len() < n.max(period) { return None; }
6180 let sma_start = self.bars.len() - period;
6181 let sma = self.bars[sma_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6182 / Decimal::from(period);
6183 let bar_start = self.bars.len() - n;
6184 let count = self.bars[bar_start..].iter()
6185 .filter(|b| b.close.value() > sma)
6186 .count();
6187 Some(count)
6188 }
6189
6190 pub fn price_contraction(&self, n: usize) -> Option<Decimal> {
6194 if n == 0 || self.bars.len() < 2 * n { return None; }
6195 let len = self.bars.len();
6196 let recent_high = self.bars[len - n..].iter().map(|b| b.high.value()).max()?;
6197 let recent_low = self.bars[len - n..].iter().map(|b| b.low.value()).min()?;
6198 let prior_high = self.bars[len - 2 * n..len - n].iter().map(|b| b.high.value()).max()?;
6199 let prior_low = self.bars[len - 2 * n..len - n].iter().map(|b| b.low.value()).min()?;
6200 let recent_range = recent_high - recent_low;
6201 let prior_range = prior_high - prior_low;
6202 if prior_range.is_zero() { return None; }
6203 Some(recent_range / prior_range)
6204 }
6205
6206 pub fn bars_since_new_low(&self) -> Option<usize> {
6210 if self.bars.is_empty() { return None; }
6211 let mut last_low_idx = 0;
6212 let mut trough = self.bars[0].close.value();
6213 for (i, b) in self.bars.iter().enumerate() {
6214 if b.close.value() <= trough {
6215 trough = b.close.value();
6216 last_low_idx = i;
6217 }
6218 }
6219 Some(self.bars.len() - 1 - last_low_idx)
6220 }
6221
6222 pub fn volume_per_range(&self, n: usize) -> Option<Decimal> {
6227 if n == 0 || self.bars.len() < n { return None; }
6228 let start = self.bars.len() - n;
6229 let mut sum = Decimal::ZERO;
6230 let mut count = 0u32;
6231 for b in &self.bars[start..] {
6232 let range = b.range();
6233 if range.is_zero() { continue; }
6234 sum += b.volume.value() / range;
6235 count += 1;
6236 }
6237 if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6238 }
6239
6240 pub fn price_volatility_ratio(&self, fast: usize, slow: usize) -> Option<f64> {
6246 use rust_decimal::prelude::ToPrimitive;
6247 if fast < 2 || slow < 2 || fast >= slow || self.bars.len() < slow { return None; }
6248 let n = self.bars.len();
6249 let std_dev = |bars: &[crate::ohlcv::OhlcvBar]| -> Option<f64> {
6250 let m = bars.len() as f64;
6251 let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.value().to_f64()).collect();
6252 if vals.len() < 2 { return None; }
6253 let mean = vals.iter().sum::<f64>() / m;
6254 let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
6255 Some(var.sqrt())
6256 };
6257 let fast_vol = std_dev(&self.bars[n - fast..])?;
6258 let slow_vol = std_dev(&self.bars[n - slow..])?;
6259 if slow_vol == 0.0 { return None; }
6260 Some(fast_vol / slow_vol)
6261 }
6262
6263 pub fn last_bar(&self) -> Option<&OhlcvBar> {
6265 self.bars.last()
6266 }
6267
6268 pub fn close_distance_from_high(&self, n: usize) -> Option<Decimal> {
6272 if n == 0 || self.bars.len() < n { return None; }
6273 let start = self.bars.len() - n;
6274 let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6275 Some((max_high - self.bars.last()?.close.value()).abs())
6276 }
6277
6278 pub fn pct_from_low(&self, n: usize) -> Option<Decimal> {
6282 if n == 0 || self.bars.len() < n { return None; }
6283 let start = self.bars.len() - n;
6284 let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
6285 if min_low.is_zero() { return None; }
6286 Some((self.bars.last()?.close.value() - min_low) / min_low * Decimal::ONE_HUNDRED)
6287 }
6288
6289 pub fn is_breakout_up(&self, n: usize) -> bool {
6291 if n == 0 || self.bars.len() <= n { return false; }
6292 let len = self.bars.len();
6293 let prior_high = self.bars[len - 1 - n..len - 1].iter().map(|b| b.close.value()).max();
6294 match (prior_high, self.bars.last()) {
6295 (Some(ph), Some(last)) => last.close.value() > ph,
6296 _ => false,
6297 }
6298 }
6299
6300 pub fn consecutive_closes_above(&self, price: Decimal) -> usize {
6305 self.bars.iter().rev().take_while(|b| b.close.value() > price).count()
6306 }
6307
6308 pub fn open_range_pct(&self, n: usize) -> Option<f64> {
6313 use rust_decimal::prelude::ToPrimitive;
6314 if n == 0 || self.bars.len() < n { return None; }
6315 let start = self.bars.len() - n;
6316 let vals: Vec<f64> = self.bars[start..].iter().filter_map(|b| {
6317 let range = b.range();
6318 if range.is_zero() { return None; }
6319 let num = (b.open.value() - b.low.value()).to_f64()?;
6320 let den = range.to_f64()?;
6321 Some(num / den * 100.0)
6322 }).collect();
6323 if vals.is_empty() { return None; }
6324 Some(vals.iter().sum::<f64>() / vals.len() as f64)
6325 }
6326
6327 pub fn skewness_of_returns(&self, n: usize) -> Option<f64> {
6336 use rust_decimal::prelude::ToPrimitive;
6337 if n < 3 || self.bars.len() < n { return None; }
6338 let start = self.bars.len() - n;
6339 let slice = &self.bars[start..];
6340 let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
6341 let prev_c = w[0].close.value().to_f64()?;
6342 if prev_c == 0.0 { return None; }
6343 let curr_c = w[1].close.value().to_f64()?;
6344 Some((curr_c - prev_c) / prev_c)
6345 }).collect();
6346 let m = returns.len();
6347 if m < 2 { return None; }
6348 let mean = returns.iter().sum::<f64>() / m as f64;
6349 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m as f64;
6350 let std_dev = variance.sqrt();
6351 if std_dev == 0.0 { return None; }
6352 let skew = returns.iter().map(|r| ((r - mean) / std_dev).powi(3)).sum::<f64>() / m as f64;
6353 Some(skew)
6354 }
6355
6356 pub fn kurtosis_of_returns(&self, n: usize) -> Option<f64> {
6365 use rust_decimal::prelude::ToPrimitive;
6366 if n < 4 || self.bars.len() < n { return None; }
6367 let start = self.bars.len() - n;
6368 let slice = &self.bars[start..];
6369 let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
6370 let prev_c = w[0].close.value().to_f64()?;
6371 if prev_c == 0.0 { return None; }
6372 let curr_c = w[1].close.value().to_f64()?;
6373 Some((curr_c - prev_c) / prev_c)
6374 }).collect();
6375 let m = returns.len();
6376 if m < 3 { return None; }
6377 let mean = returns.iter().sum::<f64>() / m as f64;
6378 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m as f64;
6379 let std_dev = variance.sqrt();
6380 if std_dev == 0.0 { return None; }
6381 let kurt = returns.iter().map(|r| ((r - mean) / std_dev).powi(4)).sum::<f64>() / m as f64 - 3.0;
6382 Some(kurt)
6383 }
6384
6385 pub fn autocorrelation_of_returns(&self, n: usize, lag: usize) -> Option<f64> {
6394 use rust_decimal::prelude::ToPrimitive;
6395 if lag == 0 || n < lag + 2 || self.bars.len() < n { return None; }
6396 let start = self.bars.len() - n;
6397 let slice = &self.bars[start..];
6398 let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
6399 let prev_c = w[0].close.value().to_f64()?;
6400 if prev_c == 0.0 { return None; }
6401 let curr_c = w[1].close.value().to_f64()?;
6402 Some((curr_c - prev_c) / prev_c)
6403 }).collect();
6404 if returns.len() <= lag { return None; }
6405 let x = &returns[..returns.len() - lag];
6406 let y = &returns[lag..];
6407 let m = x.len();
6408 if m == 0 { return None; }
6409 let mean_x = x.iter().sum::<f64>() / m as f64;
6410 let mean_y = y.iter().sum::<f64>() / m as f64;
6411 let cov: f64 = x.iter().zip(y.iter()).map(|(a, b)| (a - mean_x) * (b - mean_y)).sum::<f64>() / m as f64;
6412 let std_x = (x.iter().map(|a| (a - mean_x).powi(2)).sum::<f64>() / m as f64).sqrt();
6413 let std_y = (y.iter().map(|b| (b - mean_y).powi(2)).sum::<f64>() / m as f64).sqrt();
6414 if std_x == 0.0 || std_y == 0.0 { return None; }
6415 Some(cov / (std_x * std_y))
6416 }
6417
6418 pub fn median_volume(&self, n: usize) -> Option<Decimal> {
6422 use rust_decimal::prelude::ToPrimitive;
6423 if n == 0 || self.bars.len() < n { return None; }
6424 let start = self.bars.len() - n;
6425 let mut vols: Vec<f64> = self.bars[start..]
6426 .iter()
6427 .filter_map(|b| b.volume.value().to_f64())
6428 .collect();
6429 if vols.is_empty() { return None; }
6430 vols.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
6431 let mid = vols.len() / 2;
6432 let median = if vols.len() % 2 == 0 {
6433 (vols[mid - 1] + vols[mid]) / 2.0
6434 } else {
6435 vols[mid]
6436 };
6437 Decimal::try_from(median).ok()
6438 }
6439
6440 pub fn avg_true_range(&self, n: usize) -> Option<Decimal> {
6446 if n == 0 || self.bars.len() < n { return None; }
6447 let start = self.bars.len() - n;
6448 let slice = &self.bars[start..];
6449 let mut sum = Decimal::ZERO;
6450 for (i, bar) in slice.iter().enumerate() {
6451 let prev = if i == 0 { None } else { Some(&slice[i - 1]) };
6452 sum += bar.true_range(prev);
6453 }
6454 sum.checked_div(Decimal::from(n as u32))
6455 }
6456
6457 pub fn omega_ratio(&self, n: usize, threshold: Decimal) -> Option<Decimal> {
6468 if n < 2 || self.bars.len() < n { return None; }
6469 let start = self.bars.len() - n;
6470 let slice = &self.bars[start..];
6471 let mut gain_sum = Decimal::ZERO;
6472 let mut loss_sum = Decimal::ZERO;
6473 let mut count = 0u32;
6474 for w in slice.windows(2) {
6475 let prev_c = w[0].close.value();
6476 if prev_c.is_zero() { continue; }
6477 let ret = (w[1].close.value() - prev_c) / prev_c;
6478 gain_sum += (ret - threshold).max(Decimal::ZERO);
6479 loss_sum += (threshold - ret).max(Decimal::ZERO);
6480 count += 1;
6481 }
6482 if count == 0 || loss_sum.is_zero() { return None; }
6483 gain_sum.checked_div(loss_sum)
6484 }
6485
6486 pub fn kelly_fraction(&self, n: usize) -> Option<Decimal> {
6497 if n < 2 || self.bars.len() < n { return None; }
6498 let start = self.bars.len() - n;
6499 let slice = &self.bars[start..];
6500 let mut gains = Vec::new();
6501 let mut losses = Vec::new();
6502 for w in slice.windows(2) {
6503 let prev_c = w[0].close.value();
6504 if prev_c.is_zero() { continue; }
6505 let ret = (w[1].close.value() - prev_c) / prev_c;
6506 if ret > Decimal::ZERO { gains.push(ret); } else if ret < Decimal::ZERO { losses.push(-ret); }
6507 }
6508 let total = gains.len() + losses.len();
6509 if gains.is_empty() || losses.is_empty() || total == 0 { return None; }
6510 #[allow(clippy::cast_possible_truncation)]
6511 let win_rate = Decimal::from(gains.len() as u32) / Decimal::from(total as u32);
6512 let avg_gain: Decimal = gains.iter().copied().sum::<Decimal>() / Decimal::from(gains.len() as u32);
6513 let avg_loss: Decimal = losses.iter().copied().sum::<Decimal>() / Decimal::from(losses.len() as u32);
6514 if avg_loss.is_zero() || avg_gain.is_zero() { return None; }
6515 let kelly = win_rate / avg_loss - (Decimal::ONE - win_rate) / avg_gain;
6516 Some(kelly.clamp(Decimal::NEGATIVE_ONE, Decimal::ONE))
6517 }
6518
6519 pub fn profit_factor(&self, n: usize) -> Option<Decimal> {
6529 if n < 2 || self.bars.len() < n { return None; }
6530 let start = self.bars.len() - n;
6531 let slice = &self.bars[start..];
6532 let mut gross_gain = Decimal::ZERO;
6533 let mut gross_loss = Decimal::ZERO;
6534 for w in slice.windows(2) {
6535 let prev_c = w[0].close.value();
6536 if prev_c.is_zero() { continue; }
6537 let ret = w[1].close.value() - prev_c;
6538 if ret > Decimal::ZERO { gross_gain += ret; } else { gross_loss += -ret; }
6539 }
6540 if gross_loss.is_zero() { return None; }
6541 gross_gain.checked_div(gross_loss)
6542 }
6543
6544 pub fn recovery_factor(&self, n: usize) -> Option<Decimal> {
6553 if n < 2 || self.bars.len() < n { return None; }
6554 let start = self.bars.len() - n;
6555 let slice = &self.bars[start..];
6556 let first_close = slice.first()?.close.value();
6557 let last_close = slice.last()?.close.value();
6558 if first_close.is_zero() { return None; }
6559 let net_return = (last_close - first_close) / first_close;
6560 let mut peak = Decimal::MIN;
6562 let mut max_dd = Decimal::ZERO;
6563 for bar in slice {
6564 let c = bar.close.value();
6565 if c > peak { peak = c; }
6566 let dd = if peak.is_zero() { Decimal::ZERO } else { (peak - c) / peak };
6567 if dd > max_dd { max_dd = dd; }
6568 }
6569 if max_dd.is_zero() { return None; }
6570 net_return.checked_div(max_dd)
6571 }
6572
6573 pub fn avg_max_adverse_excursion(&self, n: usize) -> Option<Decimal> {
6582 if n == 0 || self.bars.len() < n { return None; }
6583 let start = self.bars.len() - n;
6584 let slice = &self.bars[start..];
6585 let mut sum = Decimal::ZERO;
6586 let mut count = 0u32;
6587 for bar in slice {
6588 let o = bar.open.value();
6589 if o.is_zero() { continue; }
6590 let mae = if bar.is_bullish() {
6591 (o - bar.low.value()).abs() / o
6592 } else {
6593 (bar.high.value() - o).abs() / o
6594 };
6595 sum += mae;
6596 count += 1;
6597 }
6598 if count == 0 { return None; }
6599 sum.checked_div(Decimal::from(count))
6600 }
6601
6602 pub fn half_life_of_mean_reversion(&self, n: usize) -> Option<f64> {
6616 use rust_decimal::prelude::ToPrimitive;
6617 if n < 3 || self.bars.len() < n { return None; }
6618 let start = self.bars.len() - n;
6619 let slice = &self.bars[start..];
6620 let prices: Vec<f64> = slice.iter().filter_map(|b| b.close.value().to_f64()).collect();
6621 let m = prices.len();
6622 if m < 3 { return None; }
6623 let lagged: Vec<f64> = prices[..m - 1].to_vec();
6625 let delta: Vec<f64> = prices[1..].iter().zip(prices[..m-1].iter()).map(|(a, b)| a - b).collect();
6626 let n_obs = lagged.len() as f64;
6627 let mean_x = lagged.iter().sum::<f64>() / n_obs;
6628 let mean_y = delta.iter().sum::<f64>() / n_obs;
6629 let cov_xy = lagged.iter().zip(delta.iter()).map(|(x, y)| (x - mean_x) * (y - mean_y)).sum::<f64>();
6630 let var_x = lagged.iter().map(|x| (x - mean_x).powi(2)).sum::<f64>();
6631 if var_x == 0.0 { return None; }
6632 let lambda = cov_xy / var_x;
6633 if lambda >= 0.0 { return None; } Some(-std::f64::consts::LN_2 / lambda)
6635 }
6636
6637 pub fn treynor_ratio(&self, market: &OhlcvSeries, n: usize, risk_free_rate: f64) -> Option<f64> {
6644 use rust_decimal::prelude::ToPrimitive;
6645 if n < 3 || self.bars.len() < n || market.bars.len() < n { return None; }
6646 let beta = self.beta(market, n)?;
6647 if beta == 0.0 { return None; }
6648 let start = self.bars.len() - n;
6650 let first_c = self.bars[start].close.value().to_f64()?;
6651 let last_c = self.bars.last()?.close.value().to_f64()?;
6652 if first_c == 0.0 { return None; }
6653 let total_return = (last_c - first_c) / first_c;
6654 Some((total_return - risk_free_rate) / beta)
6655 }
6656
6657 pub fn tracking_error(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
6664 use rust_decimal::prelude::ToPrimitive;
6665 if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
6666 let p_start = self.bars.len() - n;
6667 let b_start = benchmark.bars.len() - n;
6668 let p_slice = &self.bars[p_start..];
6669 let b_slice = &benchmark.bars[b_start..];
6670 let diffs: Vec<f64> = p_slice.windows(2).zip(b_slice.windows(2)).filter_map(|(pw, bw)| {
6671 let pc0 = pw[0].close.value().to_f64()?;
6672 let bc0 = bw[0].close.value().to_f64()?;
6673 if pc0 == 0.0 || bc0 == 0.0 { return None; }
6674 let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
6675 let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
6676 Some(pr - br)
6677 }).collect();
6678 let m = diffs.len() as f64;
6679 if m < 1.0 { return None; }
6680 let mean = diffs.iter().sum::<f64>() / m;
6681 let var = diffs.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / m;
6682 Some(var.sqrt())
6683 }
6684
6685 pub fn up_capture(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
6694 use rust_decimal::prelude::ToPrimitive;
6695 if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
6696 let p_start = self.bars.len() - n;
6697 let b_start = benchmark.bars.len() - n;
6698 let p_slice = &self.bars[p_start..];
6699 let b_slice = &benchmark.bars[b_start..];
6700 let mut p_up_sum = 0.0f64;
6701 let mut b_up_sum = 0.0f64;
6702 let mut count = 0u32;
6703 for (pw, bw) in p_slice.windows(2).zip(b_slice.windows(2)) {
6704 let bc0 = bw[0].close.value().to_f64()?;
6705 let pc0 = pw[0].close.value().to_f64()?;
6706 if bc0 == 0.0 || pc0 == 0.0 { continue; }
6707 let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
6708 if br <= 0.0 { continue; }
6709 let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
6710 p_up_sum += pr;
6711 b_up_sum += br;
6712 count += 1;
6713 }
6714 if count == 0 || b_up_sum == 0.0 { return None; }
6715 Some(p_up_sum / b_up_sum)
6716 }
6717
6718 pub fn down_capture(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
6729 use rust_decimal::prelude::ToPrimitive;
6730 if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
6731 let p_start = self.bars.len() - n;
6732 let b_start = benchmark.bars.len() - n;
6733 let p_slice = &self.bars[p_start..];
6734 let b_slice = &benchmark.bars[b_start..];
6735 let mut p_dn_sum = 0.0f64;
6736 let mut b_dn_sum = 0.0f64;
6737 let mut count = 0u32;
6738 for (pw, bw) in p_slice.windows(2).zip(b_slice.windows(2)) {
6739 let bc0 = bw[0].close.value().to_f64()?;
6740 let pc0 = pw[0].close.value().to_f64()?;
6741 if bc0 == 0.0 || pc0 == 0.0 { continue; }
6742 let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
6743 if br >= 0.0 { continue; }
6744 let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
6745 p_dn_sum += pr;
6746 b_dn_sum += br;
6747 count += 1;
6748 }
6749 if count == 0 || b_dn_sum == 0.0 { return None; }
6750 Some(p_dn_sum / b_dn_sum)
6751 }
6752
6753 pub fn payoff_ratio(&self, n: usize) -> Option<Decimal> {
6761 if n < 2 || self.bars.len() < n { return None; }
6762 let start = self.bars.len() - n;
6763 let slice = &self.bars[start..];
6764 let mut win_sum = Decimal::ZERO;
6765 let mut loss_sum = Decimal::ZERO;
6766 let mut win_cnt = 0u32;
6767 let mut loss_cnt = 0u32;
6768 for w in slice.windows(2) {
6769 let pc = w[0].close.value();
6770 if pc.is_zero() { continue; }
6771 let ret = (w[1].close.value() - pc) / pc;
6772 if ret > Decimal::ZERO { win_sum += ret; win_cnt += 1; }
6773 else if ret < Decimal::ZERO { loss_sum += -ret; loss_cnt += 1; }
6774 }
6775 if win_cnt == 0 || loss_cnt == 0 { return None; }
6776 let avg_win = win_sum / Decimal::from(win_cnt);
6777 let avg_loss = loss_sum / Decimal::from(loss_cnt);
6778 avg_win.checked_div(avg_loss)
6779 }
6780
6781 pub fn expected_value(&self, n: usize) -> Option<Decimal> {
6790 if n < 2 || self.bars.len() < n { return None; }
6791 let start = self.bars.len() - n;
6792 let slice = &self.bars[start..];
6793 let mut wins = Vec::new();
6794 let mut losses = Vec::new();
6795 for w in slice.windows(2) {
6796 let pc = w[0].close.value();
6797 if pc.is_zero() { continue; }
6798 let ret = (w[1].close.value() - pc) / pc;
6799 if ret > Decimal::ZERO { wins.push(ret); }
6800 else if ret < Decimal::ZERO { losses.push(-ret); }
6801 }
6802 let total = wins.len() + losses.len();
6803 if total == 0 { return None; }
6804 #[allow(clippy::cast_possible_truncation)]
6805 let total_d = Decimal::from(total as u32);
6806 let win_rate = Decimal::from(wins.len() as u32) / total_d;
6807 let loss_rate = Decimal::ONE - win_rate;
6808 let avg_win = if wins.is_empty() { Decimal::ZERO } else { wins.iter().copied().sum::<Decimal>() / Decimal::from(wins.len() as u32) };
6809 let avg_loss = if losses.is_empty() { Decimal::ZERO } else { losses.iter().copied().sum::<Decimal>() / Decimal::from(losses.len() as u32) };
6810 Some(win_rate * avg_win - loss_rate * avg_loss)
6811 }
6812
6813 pub fn breakout_count(&self, n: usize, lookback: usize) -> Option<usize> {
6819 if n == 0 || lookback == 0 { return None; }
6820 let required = n + lookback;
6821 if self.bars.len() < required { return None; }
6822 let slice = &self.bars[self.bars.len() - required..];
6823 let count = (lookback..slice.len()).filter(|&i| {
6824 let current_close = slice[i].close.value();
6825 let prior_high = slice[i - lookback..i].iter().map(|b| b.close.value()).fold(Decimal::MIN, Decimal::max);
6826 current_close > prior_high
6827 }).count();
6828 Some(count)
6829 }
6830
6831 pub fn pct_close_above_ema(&self, n: usize, period: usize) -> Option<Decimal> {
6838 if n == 0 || period == 0 { return None; }
6839 let required = n + period - 1;
6840 if self.bars.len() < required { return None; }
6841 let slice = &self.bars[self.bars.len() - required..];
6842 #[allow(clippy::cast_possible_truncation)]
6844 let k = Decimal::TWO / Decimal::from((period + 1) as u32);
6845 let seed_sum: Decimal = slice[..period].iter().map(|b| b.close.value()).sum();
6846 let seed_avg = seed_sum / Decimal::from(period as u32);
6847 let mut ema = seed_avg;
6848 let mut above = 0u32;
6849 for bar in &slice[period..] {
6850 let c = bar.close.value();
6851 ema = c * k + ema * (Decimal::ONE - k);
6852 if c > ema { above += 1; }
6853 }
6854 let n_d = Decimal::from(n as u32);
6855 Some(Decimal::from(above) / n_d * Decimal::ONE_HUNDRED)
6856 }
6857
6858 pub fn avg_volume_imbalance(&self, n: usize) -> Option<Decimal> {
6869 if n == 0 || self.bars.len() < n { return None; }
6870 let start = self.bars.len() - n;
6871 let mut sum = Decimal::ZERO;
6872 let mut count = 0u32;
6873 for bar in &self.bars[start..] {
6874 let h = bar.high.value();
6875 let l = bar.low.value();
6876 let c = bar.close.value();
6877 let range = h - l;
6878 if range.is_zero() { continue; }
6879 let clv = ((c - l) - (h - c)) / range;
6880 sum += clv * bar.volume.value();
6881 count += 1;
6882 }
6883 if count == 0 { return None; }
6884 sum.checked_div(Decimal::from(count))
6885 }
6886
6887 pub fn avg_clv(&self, n: usize) -> Option<Decimal> {
6897 if n == 0 || self.bars.len() < n { return None; }
6898 let start = self.bars.len() - n;
6899 let mut sum = Decimal::ZERO;
6900 let mut count = 0u32;
6901 for bar in &self.bars[start..] {
6902 let h = bar.high.value();
6903 let l = bar.low.value();
6904 let c = bar.close.value();
6905 let range = h - l;
6906 if range.is_zero() { continue; }
6907 sum += ((c - l) - (h - c)) / range;
6908 count += 1;
6909 }
6910 if count == 0 { return None; }
6911 sum.checked_div(Decimal::from(count))
6912 }
6913
6914 pub fn bars_in_drawdown(&self, n: usize) -> Option<usize> {
6919 if n == 0 || self.bars.len() < n { return None; }
6920 let start = self.bars.len() - n;
6921 let slice = &self.bars[start..];
6922 let mut peak = Decimal::MIN;
6923 let mut count = 0usize;
6924 for bar in slice {
6925 let c = bar.close.value();
6926 if c > peak { peak = c; } else { count += 1; }
6927 }
6928 Some(count)
6929 }
6930
6931 pub fn resistance_breakout_pct(&self, n: usize, lookback: usize) -> Option<Decimal> {
6936 if n == 0 || lookback == 0 { return None; }
6937 let count = self.breakout_count(n, lookback)?;
6938 #[allow(clippy::cast_possible_truncation)]
6939 Some(Decimal::from(count as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
6940 }
6941
6942 pub fn avg_abs_open_gap(&self, n: usize) -> Option<Decimal> {
6948 if n == 0 || self.bars.len() < n + 1 { return None; }
6949 let start = self.bars.len() - n;
6950 let slice = &self.bars[start - 1..]; let mut sum = Decimal::ZERO;
6952 for w in slice.windows(2) {
6953 let gap = (w[1].open.value() - w[0].close.value()).abs();
6954 sum += gap;
6955 }
6956 sum.checked_div(Decimal::from(n as u32))
6957 }
6958
6959 pub fn avg_wicks_to_body(&self, n: usize) -> Option<Decimal> {
6965 if n == 0 || self.bars.len() < n { return None; }
6966 let start = self.bars.len() - n;
6967 let mut sum = Decimal::ZERO;
6968 let mut count = 0u32;
6969 for bar in &self.bars[start..] {
6970 let body = bar.body_size();
6971 if body.is_zero() { continue; }
6972 sum += (bar.upper_shadow() + bar.lower_shadow()) / body;
6973 count += 1;
6974 }
6975 if count == 0 { return None; }
6976 sum.checked_div(Decimal::from(count))
6977 }
6978
6979 pub fn volume_trend_correlation(&self, n: usize) -> Option<f64> {
6989 use rust_decimal::prelude::ToPrimitive;
6990 if n < 2 || self.bars.len() < n { return None; }
6991 let start = self.bars.len() - n;
6992 let slice = &self.bars[start..];
6993 let vols: Vec<f64> = slice.iter().filter_map(|b| b.volume.value().to_f64()).collect();
6994 let dirs: Vec<f64> = slice.iter().map(|b| {
6995 if b.is_bullish() { 1.0 } else if b.is_bearish() { -1.0 } else { 0.0 }
6996 }).collect();
6997 let m = vols.len().min(dirs.len()) as f64;
6998 if m < 2.0 { return None; }
6999 let mv = vols.iter().sum::<f64>() / m;
7000 let md = dirs.iter().sum::<f64>() / m;
7001 let cov = vols.iter().zip(dirs.iter()).map(|(v, d)| (v - mv) * (d - md)).sum::<f64>() / m;
7002 let sv = (vols.iter().map(|v| (v - mv).powi(2)).sum::<f64>() / m).sqrt();
7003 let sd = (dirs.iter().map(|d| (d - md).powi(2)).sum::<f64>() / m).sqrt();
7004 if sv == 0.0 || sd == 0.0 { return None; }
7005 Some(cov / (sv * sd))
7006 }
7007
7008 pub fn candle_consistency(&self, n: usize) -> Option<Decimal> {
7016 if n < 2 || self.bars.len() < n { return None; }
7017 let start = self.bars.len() - n;
7018 let slice = &self.bars[start..];
7019 let dirs: Vec<i8> = slice.iter().map(|b| {
7020 if b.is_bullish() { 1 } else if b.is_bearish() { -1 } else { 0 }
7021 }).collect();
7022 let consistent = dirs.windows(2).filter(|w| w[0] != 0 && w[1] != 0 && w[0] == w[1]).count();
7023 let total = dirs.windows(2).filter(|w| w[0] != 0 && w[1] != 0).count();
7024 if total == 0 { return None; }
7025 #[allow(clippy::cast_possible_truncation)]
7026 Some(Decimal::from(consistent as u32) / Decimal::from(total as u32) * Decimal::ONE_HUNDRED)
7027 }
7028
7029 pub fn avg_open_close_spread(&self, n: usize) -> Option<Decimal> {
7036 if n == 0 || self.bars.len() < n { return None; }
7037 let start = self.bars.len() - n;
7038 let sum: Decimal = self.bars[start..].iter()
7039 .map(|b| b.body_size())
7040 .sum();
7041 sum.checked_div(Decimal::from(n as u32))
7042 }
7043
7044 pub fn avg_range_to_prev_close(&self, n: usize) -> Option<Decimal> {
7052 if n == 0 || self.bars.len() < n + 1 { return None; }
7053 let start = self.bars.len() - n - 1;
7054 let slice = &self.bars[start..];
7055 let mut sum = Decimal::ZERO;
7056 let mut count = 0u32;
7057 for w in slice.windows(2) {
7058 let pc = w[0].close.value();
7059 if pc.is_zero() { continue; }
7060 let range = w[1].range();
7061 sum += range / pc;
7062 count += 1;
7063 }
7064 if count == 0 { return None; }
7065 sum.checked_div(Decimal::from(count))
7066 }
7067 pub fn volume_weighted_std_dev(&self, n: usize) -> Option<Decimal> {
7072 use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
7073 if n == 0 || self.bars.len() < n { return None; }
7074 let slice = &self.bars[self.bars.len() - n..];
7075 let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
7076 if total_vol.is_zero() { return None; }
7077 let vwap: Decimal = slice.iter()
7078 .map(|b| b.close.value() * b.volume.value())
7079 .sum::<Decimal>() / total_vol;
7080 let vw_var: Decimal = slice.iter()
7081 .map(|b| { let d = b.close.value() - vwap; b.volume.value() * d * d })
7082 .sum::<Decimal>() / total_vol;
7083 let vf = vw_var.to_f64()?;
7084 Decimal::from_f64(vf.sqrt())
7085 }
7086
7087 pub fn pct_inside_bars(&self, n: usize) -> Option<Decimal> {
7092 if n == 0 || self.bars.len() < n + 1 { return None; }
7093 let slice = &self.bars[self.bars.len() - n - 1..];
7094 let count = slice.windows(2)
7095 .filter(|w| w[1].high.value() < w[0].high.value() && w[1].low.value() > w[0].low.value())
7096 .count();
7097 Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
7098 }
7099
7100 pub fn avg_bar_polarity(&self, n: usize) -> Option<Decimal> {
7106 if n == 0 || self.bars.len() < n { return None; }
7107 let slice = &self.bars[self.bars.len() - n..];
7108 let sum: Decimal = slice.iter().map(|b| {
7109 let c = b.close.value();
7110 let o = b.open.value();
7111 if c > o { Decimal::ONE } else if c < o { Decimal::NEGATIVE_ONE } else { Decimal::ZERO }
7112 }).sum();
7113 sum.checked_div(Decimal::from(n as u32))
7114 }
7115
7116 pub fn return_tail_ratio(&self, n: usize) -> Option<Decimal> {
7123 if n < 2 || self.bars.len() < n + 1 { return None; }
7124 let slice = &self.bars[self.bars.len() - n - 1..];
7125 let mut returns: Vec<Decimal> = slice.windows(2)
7126 .filter_map(|w| {
7127 let pc = w[0].close.value();
7128 if pc.is_zero() { return None; }
7129 Some((w[1].close.value() - pc) / pc)
7130 })
7131 .collect();
7132 if returns.is_empty() { return None; }
7133 returns.sort();
7134 let len = returns.len();
7135 let p95_idx = ((len as f64 * 0.95) as usize).min(len - 1);
7136 let p05_idx = ((len as f64 * 0.05) as usize).min(len - 1);
7137 let p95 = returns[p95_idx];
7138 let p05 = returns[p05_idx].abs();
7139 if p05.is_zero() { return None; }
7140 p95.checked_div(p05)
7141 }
7142
7143 pub fn signed_gap_sum(&self, n: usize) -> Option<Decimal> {
7149 if n == 0 || self.bars.len() < n + 1 { return None; }
7150 let slice = &self.bars[self.bars.len() - n - 1..];
7151 let sum: Decimal = slice.windows(2)
7152 .map(|w| w[1].open.value() - w[0].close.value())
7153 .sum();
7154 Some(sum)
7155 }
7156
7157 pub fn bull_bar_fraction(&self, n: usize) -> Option<Decimal> {
7161 if n == 0 || self.bars.len() < n { return None; }
7162 let slice = &self.bars[self.bars.len() - n..];
7163 let count = slice.iter().filter(|b| b.is_bullish()).count();
7164 #[allow(clippy::cast_possible_truncation)]
7165 Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
7166 }
7167
7168 pub fn cumulative_delta(&self, n: usize) -> Option<Decimal> {
7173 if n == 0 || self.bars.len() < n { return None; }
7174 let sum: Decimal = self.bars[self.bars.len() - n..]
7175 .iter()
7176 .map(|b| b.close.value() - b.open.value())
7177 .sum();
7178 Some(sum)
7179 }
7180
7181 pub fn avg_body_to_atr(&self, n: usize) -> Option<Decimal> {
7187 if n == 0 || self.bars.len() < n + 1 { return None; }
7188 let slice = &self.bars[self.bars.len() - n - 1..];
7189 let trs: Vec<Decimal> = slice.windows(2).map(|w| {
7190 let h = w[1].high.value();
7191 let l = w[1].low.value();
7192 let pc = w[0].close.value();
7193 (h - l).max((h - pc).abs()).max((l - pc).abs())
7194 }).collect();
7195 let atr: Decimal = trs.iter().sum::<Decimal>();
7196 if atr.is_zero() { return None; }
7197 #[allow(clippy::cast_possible_truncation)]
7198 let n_d = Decimal::from(n as u32);
7199 let avg_atr = atr / n_d;
7200 let avg_body: Decimal = slice[1..].iter()
7201 .map(|b| b.body_size())
7202 .sum::<Decimal>() / n_d;
7203 avg_body.checked_div(avg_atr)
7204 }
7205
7206 pub fn candle_direction_changes(&self, n: usize) -> Option<usize> {
7212 if n < 2 || self.bars.len() < n { return None; }
7213 let slice = &self.bars[self.bars.len() - n..];
7214 let directions: Vec<i32> = slice.iter()
7215 .map(|b| {
7216 let d = b.close.value() - b.open.value();
7217 if d > Decimal::ZERO { 1 } else if d < Decimal::ZERO { -1 } else { 0 }
7218 })
7219 .filter(|d| *d != 0)
7220 .collect();
7221 let changes = directions.windows(2).filter(|w| w[0] != w[1]).count();
7222 Some(changes)
7223 }
7224
7225 pub fn win_rate(&self, n: usize) -> Option<Decimal> {
7229 if n == 0 || self.bars.len() < n + 1 { return None; }
7230 let slice = &self.bars[self.bars.len() - n - 1..];
7231 let wins = slice.windows(2)
7232 .filter(|w| w[1].close.value() > w[0].close.value())
7233 .count();
7234 #[allow(clippy::cast_possible_truncation)]
7235 Decimal::from(wins as u32).checked_div(Decimal::from(n as u32))
7236 }
7237
7238 pub fn best_return(&self, n: usize) -> Option<Decimal> {
7242 if n == 0 || self.bars.len() < n + 1 { return None; }
7243 let slice = &self.bars[self.bars.len() - n - 1..];
7244 slice.windows(2).filter_map(|w| {
7245 let pc = w[0].close.value();
7246 if pc.is_zero() { return None; }
7247 Some((w[1].close.value() - pc) / pc)
7248 }).reduce(|a, b| if a > b { a } else { b })
7249 }
7250
7251 pub fn worst_return(&self, n: usize) -> Option<Decimal> {
7255 if n == 0 || self.bars.len() < n + 1 { return None; }
7256 let slice = &self.bars[self.bars.len() - n - 1..];
7257 slice.windows(2).filter_map(|w| {
7258 let pc = w[0].close.value();
7259 if pc.is_zero() { return None; }
7260 Some((w[1].close.value() - pc) / pc)
7261 }).reduce(|a, b| if a < b { a } else { b })
7262 }
7263
7264 pub fn median_return(&self, n: usize) -> Option<Decimal> {
7268 if n == 0 || self.bars.len() < n + 1 { return None; }
7269 let slice = &self.bars[self.bars.len() - n - 1..];
7270 let mut returns: Vec<Decimal> = slice.windows(2).filter_map(|w| {
7271 let pc = w[0].close.value();
7272 if pc.is_zero() { return None; }
7273 Some((w[1].close.value() - pc) / pc)
7274 }).collect();
7275 if returns.is_empty() { return None; }
7276 returns.sort();
7277 let m = returns.len();
7278 if m % 2 == 1 { Some(returns[m / 2]) }
7279 else { (returns[m / 2 - 1] + returns[m / 2]).checked_div(Decimal::TWO) }
7280 }
7281
7282 pub fn price_vs_median(&self, n: usize) -> Option<Decimal> {
7288 if n == 0 || self.bars.len() < n { return None; }
7289 let slice = &self.bars[self.bars.len() - n..];
7290 let mut closes: Vec<Decimal> = slice.iter().map(|b| b.close.value()).collect();
7291 closes.sort();
7292 let m = closes.len();
7293 let median = if m % 2 == 1 { closes[m / 2] }
7294 else { (closes[m / 2 - 1] + closes[m / 2]) / Decimal::TWO };
7295 if median.is_zero() { return None; }
7296 let current = self.bars.last()?.close.value();
7297 (current - median).checked_div(median).map(|r| r * Decimal::ONE_HUNDRED)
7298 }
7299
7300 pub fn close_win_rate(&self, n: usize) -> Option<Decimal> {
7305 if n == 0 || self.bars.len() < n + 1 { return None; }
7306 let slice = &self.bars[self.bars.len() - n - 1..];
7307 let wins = slice.windows(2).filter(|w| w[1].close.value() > w[0].close.value()).count();
7308 #[allow(clippy::cast_possible_truncation)]
7309 Decimal::from(wins as u32).checked_div(Decimal::from(n as u32))
7310 }
7311
7312 pub fn rolling_vwap(&self, n: usize) -> Option<Decimal> {
7316 if n == 0 || self.bars.len() < n { return None; }
7317 let slice = &self.bars[self.bars.len() - n..];
7318 let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
7319 if total_vol.is_zero() { return None; }
7320 let vwap = slice.iter().map(|b| b.close.value() * b.volume.value()).sum::<Decimal>() / total_vol;
7321 Some(vwap)
7322 }
7323
7324 pub fn engulfing_count(&self, n: usize) -> Option<usize> {
7329 if n == 0 || self.bars.len() < n + 1 { return None; }
7330 let slice = &self.bars[self.bars.len() - n - 1..];
7331 let count = slice.windows(2).filter(|w| {
7332 let (po, pc) = (w[0].open.value(), w[0].close.value());
7333 let (co, cc) = (w[1].open.value(), w[1].close.value());
7334 let prev_lo = po.min(pc);
7335 let prev_hi = po.max(pc);
7336 let curr_lo = co.min(cc);
7337 let curr_hi = co.max(cc);
7338 curr_lo <= prev_lo && curr_hi >= prev_hi && prev_lo != prev_hi
7339 }).count();
7340 Some(count)
7341 }
7342
7343 pub fn rolling_velocity(&self, n: usize) -> Option<Decimal> {
7347 let len = self.bars.len();
7348 if n == 0 || len < n + 1 { return None; }
7349 Some(self.bars[len - 1].close.value() - self.bars[len - 1 - n].close.value())
7350 }
7351
7352 pub fn avg_body_ratio(&self, n: usize) -> Option<Decimal> {
7358 if n == 0 || self.bars.len() < n { return None; }
7359 let slice = &self.bars[self.bars.len() - n..];
7360 let (sum, count) = slice.iter().fold((Decimal::ZERO, 0u32), |(s, c), b| {
7361 let range = b.range();
7362 if range.is_zero() { (s, c) }
7363 else { (s + b.body_size() / range, c + 1) }
7364 });
7365 if count == 0 { return None; }
7366 sum.checked_div(Decimal::from(count))
7367 }
7368
7369 pub fn avg_upper_shadow_fraction(&self, n: usize) -> Option<Decimal> {
7372 if n == 0 || self.bars.len() < n { return None; }
7373 let bars = &self.bars[self.bars.len() - n..];
7374 let sum: Decimal = bars.iter().map(|b| {
7375 let range = b.range();
7376 if range.is_zero() { Decimal::ZERO }
7377 else {
7378 let body_hi = b.close.value().max(b.open.value());
7379 (b.high.value() - body_hi) / range
7380 }
7381 }).sum();
7382 Some(sum / Decimal::from(n as u32))
7383 }
7384
7385 pub fn avg_lower_shadow_fraction(&self, n: usize) -> Option<Decimal> {
7388 if n == 0 || self.bars.len() < n { return None; }
7389 let bars = &self.bars[self.bars.len() - n..];
7390 let sum: Decimal = bars.iter().map(|b| {
7391 let range = b.range();
7392 if range.is_zero() { Decimal::ZERO }
7393 else {
7394 let body_lo = b.close.value().min(b.open.value());
7395 (body_lo - b.low.value()) / range
7396 }
7397 }).sum();
7398 Some(sum / Decimal::from(n as u32))
7399 }
7400
7401 pub fn avg_intrabar_return(&self, n: usize) -> Option<Decimal> {
7404 if n == 0 || self.bars.len() < n { return None; }
7405 let bars = &self.bars[self.bars.len() - n..];
7406 let vals: Vec<Decimal> = bars.iter().filter_map(|b| {
7407 if b.open.value().is_zero() { None }
7408 else {
7409 Some((b.close.value() - b.open.value()) / b.open.value() * Decimal::ONE_HUNDRED)
7410 }
7411 }).collect();
7412 if vals.is_empty() { return None; }
7413 let sum: Decimal = vals.iter().sum();
7414 Some(sum / Decimal::from(vals.len() as u32))
7415 }
7416
7417 pub fn avg_close_position(&self, n: usize) -> Option<Decimal> {
7420 if n == 0 || self.bars.len() < n { return None; }
7421 let bars = &self.bars[self.bars.len() - n..];
7422 let half = Decimal::new(5, 1);
7423 let sum: Decimal = bars.iter().map(|b| {
7424 let range = b.range();
7425 if range.is_zero() { half }
7426 else { (b.close.value() - b.low.value()) / range }
7427 }).sum();
7428 Some(sum / Decimal::from(n as u32))
7429 }
7430
7431 pub fn avg_shadow_imbalance(&self, n: usize) -> Option<Decimal> {
7434 if n == 0 || self.bars.len() < n { return None; }
7435 let bars = &self.bars[self.bars.len() - n..];
7436 let sum: Decimal = bars.iter().map(|b| {
7437 let range = b.range();
7438 if range.is_zero() { Decimal::ZERO }
7439 else {
7440 let body_hi = b.close.value().max(b.open.value());
7441 let body_lo = b.close.value().min(b.open.value());
7442 let upper = b.high.value() - body_hi;
7443 let lower = body_lo - b.low.value();
7444 (upper - lower) / range
7445 }
7446 }).sum();
7447 Some(sum / Decimal::from(n as u32))
7448 }
7449
7450 pub fn avg_normalized_range(&self, n: usize) -> Option<Decimal> {
7453 if n == 0 || self.bars.len() < n { return None; }
7454 let bars = &self.bars[self.bars.len() - n..];
7455 let vals: Vec<Decimal> = bars.iter().filter_map(|b| {
7456 if b.close.value().is_zero() { None }
7457 else { Some((b.range()) / b.close.value()) }
7458 }).collect();
7459 if vals.is_empty() { return None; }
7460 let sum: Decimal = vals.iter().sum();
7461 Some(sum / Decimal::from(vals.len() as u32))
7462 }
7463
7464}
7465
7466#[cfg(test)]
7467mod tests {
7468 use super::*;
7469 use crate::types::Side;
7470 use rust_decimal_macros::dec;
7471
7472 fn make_price(s: &str) -> Price {
7473 Price::new(s.parse().unwrap()).unwrap()
7474 }
7475
7476 fn make_qty(s: &str) -> Quantity {
7477 Quantity::new(s.parse().unwrap()).unwrap()
7478 }
7479
7480 fn make_bar(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
7481 OhlcvBar {
7482 symbol: Symbol::new("X").unwrap(),
7483 open: make_price(o),
7484 high: make_price(h),
7485 low: make_price(l),
7486 close: make_price(c),
7487 volume: make_qty("100"),
7488 ts_open: NanoTimestamp::new(0),
7489 ts_close: NanoTimestamp::new(1),
7490 tick_count: 1,
7491 }
7492 }
7493
7494 fn bar(close: &str) -> OhlcvBar {
7496 make_bar(close, close, close, close)
7497 }
7498
7499 fn make_tick(sym: &str, price: &str, qty: &str, ts: i64) -> Tick {
7500 Tick::new(
7501 Symbol::new(sym).unwrap(),
7502 make_price(price),
7503 make_qty(qty),
7504 Side::Ask,
7505 NanoTimestamp::new(ts),
7506 )
7507 }
7508
7509 #[test]
7512 fn test_ohlcv_bar_validate_ok() {
7513 let bar = make_bar("100", "110", "90", "105");
7514 assert!(bar.validate().is_ok());
7515 }
7516
7517 #[test]
7518 fn test_ohlcv_bar_validate_high_less_than_close_fails() {
7519 let bar = make_bar("100", "104", "90", "110");
7520 assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
7521 }
7522
7523 #[test]
7524 fn test_ohlcv_bar_validate_low_greater_than_open_fails() {
7525 let bar = make_bar("80", "110", "90", "105");
7526 assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
7527 }
7528
7529 #[test]
7530 fn test_ohlcv_bar_validate_high_less_than_open_fails() {
7531 let bar = make_bar("115", "110", "90", "105");
7532 assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
7533 }
7534
7535 #[test]
7536 fn test_ohlcv_bar_typical_price() {
7537 let bar = make_bar("100", "120", "80", "110");
7538 let expected = dec!(310) / Decimal::from(3u32);
7539 assert_eq!(bar.typical_price(), expected);
7540 }
7541
7542 #[test]
7543 fn test_ohlcv_bar_range() {
7544 let bar = make_bar("100", "120", "80", "110");
7545 assert_eq!(bar.range(), dec!(40));
7546 }
7547
7548 #[test]
7549 fn test_ohlcv_bar_is_bullish_true() {
7550 let bar = make_bar("100", "110", "95", "105");
7551 assert!(bar.is_bullish());
7552 }
7553
7554 #[test]
7555 fn test_ohlcv_bar_is_bullish_false() {
7556 let bar = make_bar("105", "110", "95", "100");
7557 assert!(!bar.is_bullish());
7558 }
7559
7560 #[test]
7561 fn test_ohlcv_bar_midpoint() {
7562 let bar = make_bar("100", "120", "80", "110");
7563 assert_eq!(bar.midpoint(), dec!(100)); }
7565
7566 #[test]
7567 fn test_ohlcv_bar_body_size_bullish() {
7568 let bar = make_bar("100", "120", "80", "110");
7569 assert_eq!(bar.body_size(), dec!(10)); }
7571
7572 #[test]
7573 fn test_ohlcv_bar_body_size_bearish() {
7574 let bar = make_bar("110", "120", "80", "100");
7575 assert_eq!(bar.body_size(), dec!(10)); }
7577
7578 #[test]
7579 fn test_ohlcv_bar_is_long_candle_flat() {
7580 let bar = make_bar("100", "100", "100", "100");
7582 assert!(!bar.is_long_candle(dec!(0.7)));
7583 }
7584
7585 #[test]
7586 fn test_ohlcv_bar_is_long_candle_true() {
7587 let bar = make_bar("100", "112", "98", "110");
7589 assert!(bar.is_long_candle(dec!(0.7)));
7590 }
7591
7592 #[test]
7593 fn test_ohlcv_bar_is_long_candle_false() {
7594 let bar = make_bar("100", "110", "90", "101");
7596 assert!(!bar.is_long_candle(dec!(0.7)));
7597 }
7598
7599 #[test]
7600 fn test_ohlcv_bar_is_doji_flat_range() {
7601 let bar = make_bar("100", "100", "100", "100");
7602 assert!(bar.is_doji(dec!(0.1)));
7603 assert!(!bar.is_doji(dec!(0)));
7604 }
7605
7606 #[test]
7607 fn test_ohlcv_bar_is_doji_small_body() {
7608 let bar = make_bar("100", "110", "90", "101");
7610 assert!(bar.is_doji(dec!(0.1)));
7611 assert!(!bar.is_doji(dec!(0.04)));
7612 }
7613
7614 #[test]
7615 fn test_ohlcv_bar_partial_eq() {
7616 let a = make_bar("100", "110", "90", "105");
7617 let b = make_bar("100", "110", "90", "105");
7618 assert_eq!(a, b);
7619 let c = make_bar("100", "110", "90", "106");
7620 assert_ne!(a, c);
7621 }
7622
7623 #[test]
7626 fn test_timeframe_seconds_to_nanos() {
7627 let tf = Timeframe::Seconds(5);
7628 assert_eq!(tf.to_nanos().unwrap(), 5_000_000_000);
7629 }
7630
7631 #[test]
7632 fn test_timeframe_minutes_to_nanos() {
7633 let tf = Timeframe::Minutes(1);
7634 assert_eq!(tf.to_nanos().unwrap(), 60_000_000_000);
7635 }
7636
7637 #[test]
7638 fn test_timeframe_zero_seconds_fails() {
7639 let tf = Timeframe::Seconds(0);
7640 assert!(matches!(tf.to_nanos(), Err(FinError::InvalidTimeframe)));
7641 }
7642
7643 #[test]
7644 fn test_timeframe_weeks_to_nanos() {
7645 let tf = Timeframe::Weeks(1);
7646 assert_eq!(tf.to_nanos().unwrap(), 7 * 86_400 * 1_000_000_000_i64);
7647 }
7648
7649 #[test]
7650 fn test_timeframe_bucket_start() {
7651 let tf = Timeframe::Seconds(60);
7652 let nanos_per_min = 60_000_000_000_i64;
7653 let ts = NanoTimestamp::new(nanos_per_min + 500_000_000);
7654 let bucket = tf.bucket_start(ts).unwrap();
7655 assert_eq!(bucket.nanos(), nanos_per_min);
7656 }
7657
7658 #[test]
7661 fn test_ohlcv_aggregator_new_invalid_timeframe_fails() {
7662 let sym = Symbol::new("X").unwrap();
7663 let result = OhlcvAggregator::new(sym, Timeframe::Seconds(0));
7664 assert!(matches!(result, Err(FinError::InvalidTimeframe)));
7665 }
7666
7667 #[test]
7668 fn test_ohlcv_aggregator_completes_bar_on_boundary() {
7669 let sym = Symbol::new("X").unwrap();
7670 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7671 let nanos_per_min = 60_000_000_000_i64;
7672
7673 let t1 = make_tick("X", "100", "1", 0);
7674 let t2 = make_tick("X", "105", "2", nanos_per_min / 2);
7675 let t3 = make_tick("X", "110", "1", nanos_per_min + 1);
7676
7677 let r1 = agg.push_tick(&t1).unwrap();
7678 assert!(r1.is_empty());
7679 let r2 = agg.push_tick(&t2).unwrap();
7680 assert!(r2.is_empty());
7681 let r3 = agg.push_tick(&t3).unwrap();
7682 assert_eq!(r3.len(), 1);
7683 let bar = &r3[0];
7684 assert_eq!(bar.open.value(), dec!(100));
7685 assert_eq!(bar.high.value(), dec!(105));
7686 assert_eq!(bar.close.value(), dec!(105));
7687 assert_eq!(bar.tick_count, 2);
7688 }
7689
7690 #[test]
7691 fn test_ohlcv_aggregator_gap_fills_empty_buckets() {
7692 let sym = Symbol::new("X").unwrap();
7693 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7694 let nanos_per_min = 60_000_000_000_i64;
7695
7696 agg.push_tick(&make_tick("X", "100", "1", 0)).unwrap();
7698 let out = agg
7700 .push_tick(&make_tick("X", "200", "1", 3 * nanos_per_min + 1))
7701 .unwrap();
7702 assert_eq!(out.len(), 3, "expected 1 completed + 2 gap bars, got {}", out.len());
7704 assert_eq!(out[0].tick_count, 1);
7706 assert_eq!(out[1].tick_count, 0);
7708 assert_eq!(out[1].volume.value(), dec!(0));
7709 assert_eq!(out[2].tick_count, 0);
7710 assert_eq!(out[1].close, out[0].close);
7712 }
7713
7714 #[test]
7715 fn test_ohlcv_aggregator_flush_returns_partial() {
7716 let sym = Symbol::new("X").unwrap();
7717 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7718 let t1 = make_tick("X", "100", "1", 0);
7719 agg.push_tick(&t1).unwrap();
7720 let bar = agg.flush().unwrap();
7721 assert_eq!(bar.open.value(), dec!(100));
7722 assert!(agg.flush().is_none());
7723 }
7724
7725 #[test]
7726 fn test_ohlcv_aggregator_symbol_getter() {
7727 let sym = Symbol::new("BTC").unwrap();
7728 let agg = OhlcvAggregator::new(sym.clone(), Timeframe::Seconds(60)).unwrap();
7729 assert_eq!(agg.symbol(), &sym);
7730 }
7731
7732 #[test]
7733 fn test_ohlcv_aggregator_ignores_different_symbol() {
7734 let sym = Symbol::new("X").unwrap();
7735 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7736 let t = make_tick("Y", "100", "1", 0);
7737 let result = agg.push_tick(&t).unwrap();
7738 assert!(result.is_empty());
7739 assert!(agg.current_bar().is_none());
7740 }
7741
7742 #[test]
7745 fn test_ohlcv_series_push_valid() {
7746 let mut series = OhlcvSeries::new();
7747 let bar = make_bar("100", "110", "90", "105");
7748 assert!(series.push(bar).is_ok());
7749 assert_eq!(series.len(), 1);
7750 }
7751
7752 #[test]
7753 fn test_ohlcv_series_push_invalid_fails() {
7754 let mut series = OhlcvSeries::new();
7755 let bar = make_bar("100", "95", "90", "105");
7756 assert!(matches!(series.push(bar), Err(FinError::BarInvariant(_))));
7757 }
7758
7759 #[test]
7760 fn test_ohlcv_series_window_returns_last_n() {
7761 let mut series = OhlcvSeries::new();
7762 for i in 1u32..=5 {
7763 let p = format!("{}", 100 + i);
7764 let h = format!("{}", 110 + i);
7765 let l = format!("{}", 90 + i);
7766 let c = format!("{}", 105 + i);
7767 series.push(make_bar(&p, &h, &l, &c)).unwrap();
7768 }
7769 let w = series.window(3);
7770 assert_eq!(w.len(), 3);
7771 assert_eq!(w[0].open.value(), dec!(103));
7772 }
7773
7774 #[test]
7775 fn test_ohlcv_series_window_larger_than_len() {
7776 let mut series = OhlcvSeries::new();
7777 series.push(make_bar("100", "110", "90", "105")).unwrap();
7778 let w = series.window(10);
7779 assert_eq!(w.len(), 1);
7780 }
7781
7782 #[test]
7783 fn test_ohlcv_series_opens() {
7784 let mut series = OhlcvSeries::new();
7785 series.push(make_bar("100", "110", "90", "105")).unwrap();
7786 series.push(make_bar("105", "115", "95", "110")).unwrap();
7787 assert_eq!(series.opens(), vec![dec!(100), dec!(105)]);
7788 }
7789
7790 #[test]
7791 fn test_ohlcv_series_highs() {
7792 let mut series = OhlcvSeries::new();
7793 series.push(make_bar("100", "110", "90", "105")).unwrap();
7794 series.push(make_bar("105", "115", "95", "110")).unwrap();
7795 assert_eq!(series.highs(), vec![dec!(110), dec!(115)]);
7796 }
7797
7798 #[test]
7799 fn test_ohlcv_series_lows() {
7800 let mut series = OhlcvSeries::new();
7801 series.push(make_bar("100", "110", "90", "105")).unwrap();
7802 series.push(make_bar("105", "115", "95", "110")).unwrap();
7803 assert_eq!(series.lows(), vec![dec!(90), dec!(95)]);
7804 }
7805
7806 #[test]
7807 fn test_ohlcv_series_closes() {
7808 let mut series = OhlcvSeries::new();
7809 series.push(make_bar("100", "110", "90", "105")).unwrap();
7810 series.push(make_bar("105", "115", "95", "110")).unwrap();
7811 let closes = series.closes();
7812 assert_eq!(closes, vec![dec!(105), dec!(110)]);
7813 }
7814
7815 #[test]
7816 fn test_ohlcv_series_is_empty() {
7817 let series = OhlcvSeries::new();
7818 assert!(series.is_empty());
7819 }
7820
7821 #[test]
7822 fn test_ohlcv_series_into_iterator() {
7823 let mut series = OhlcvSeries::new();
7824 series.push(make_bar("100", "110", "90", "105")).unwrap();
7825 series.push(make_bar("105", "115", "95", "110")).unwrap();
7826 let count = (&series).into_iter().count();
7827 assert_eq!(count, 2);
7828 }
7829
7830 #[test]
7831 fn test_ohlcv_series_iter() {
7832 let mut series = OhlcvSeries::new();
7833 series.push(make_bar("100", "110", "90", "105")).unwrap();
7834 let bar = series.iter().next().unwrap();
7835 assert_eq!(bar.open.value(), dec!(100));
7836 }
7837
7838 #[test]
7839 fn test_ohlcv_bar_upper_shadow() {
7840 let b = make_bar("100", "115", "90", "108");
7842 assert_eq!(b.upper_shadow(), dec!(7));
7843 }
7844
7845 #[test]
7846 fn test_ohlcv_bar_lower_shadow() {
7847 let b = make_bar("100", "115", "90", "108");
7849 assert_eq!(b.lower_shadow(), dec!(10));
7850 }
7851
7852 #[test]
7853 fn test_ohlcv_bar_from_tick() {
7854 let tick = make_tick("AAPL", "150", "5", 1_000);
7855 let bar = OhlcvBar::from_tick(&tick);
7856 assert_eq!(bar.open.value(), dec!(150));
7857 assert_eq!(bar.high.value(), dec!(150));
7858 assert_eq!(bar.low.value(), dec!(150));
7859 assert_eq!(bar.close.value(), dec!(150));
7860 assert_eq!(bar.volume.value(), dec!(5));
7861 assert_eq!(bar.tick_count, 1);
7862 assert_eq!(bar.ts_open.nanos(), 1_000);
7863 }
7864
7865 #[test]
7866 fn test_ohlcv_series_bars_slice() {
7867 let mut series = OhlcvSeries::new();
7868 series.push(make_bar("100", "110", "90", "105")).unwrap();
7869 series.push(make_bar("105", "115", "95", "110")).unwrap();
7870 assert_eq!(series.bars().len(), 2);
7871 }
7872
7873 #[test]
7874 fn test_ohlcv_series_max_high_min_low() {
7875 let mut series = OhlcvSeries::new();
7876 series.push(make_bar("100", "110", "90", "105")).unwrap();
7877 series.push(make_bar("105", "120", "85", "110")).unwrap();
7878 assert_eq!(series.max_high().unwrap(), dec!(120));
7879 assert_eq!(series.min_low().unwrap(), dec!(85));
7880 }
7881
7882 #[test]
7883 fn test_ohlcv_series_max_high_empty() {
7884 let series = OhlcvSeries::new();
7885 assert!(series.max_high().is_none());
7886 assert!(series.min_low().is_none());
7887 }
7888
7889 #[test]
7890 fn test_ohlcv_series_slice() {
7891 let mut series = OhlcvSeries::new();
7892 series.push(make_bar("100", "110", "90", "105")).unwrap();
7893 series.push(make_bar("105", "115", "95", "110")).unwrap();
7894 series.push(make_bar("110", "120", "100", "115")).unwrap();
7895 let s = series.slice(1, 3).unwrap();
7896 assert_eq!(s.len(), 2);
7897 assert_eq!(s[0].open.value(), dec!(105));
7898 }
7899
7900 #[test]
7901 fn test_ohlcv_series_slice_out_of_bounds() {
7902 let series = OhlcvSeries::new();
7903 assert!(series.slice(0, 1).is_none());
7904 }
7905
7906 #[test]
7907 fn test_ohlcv_series_truncate_keeps_last_n() {
7908 let mut series = OhlcvSeries::new();
7909 for _ in 0..5 {
7910 series.push(make_bar("100", "110", "90", "105")).unwrap();
7911 }
7912 series.truncate(3);
7913 assert_eq!(series.len(), 3);
7914 }
7915
7916 #[test]
7917 fn test_ohlcv_series_truncate_noop_when_n_ge_len() {
7918 let mut series = OhlcvSeries::new();
7919 series.push(make_bar("100", "110", "90", "105")).unwrap();
7920 series.push(make_bar("105", "115", "95", "110")).unwrap();
7921 series.truncate(5);
7922 assert_eq!(series.len(), 2);
7923 }
7924
7925 #[test]
7926 fn test_ohlcv_series_truncate_to_zero() {
7927 let mut series = OhlcvSeries::new();
7928 series.push(make_bar("100", "110", "90", "105")).unwrap();
7929 series.push(make_bar("105", "115", "95", "110")).unwrap();
7930 series.truncate(0);
7931 assert!(series.is_empty());
7932 }
7933
7934 #[test]
7935 fn test_ohlcv_bar_serde_roundtrip() {
7936 let bar = make_bar("100", "110", "90", "105");
7937 let json = serde_json::to_string(&bar).unwrap();
7938 let back: OhlcvBar = serde_json::from_str(&json).unwrap();
7939 assert_eq!(back.open, bar.open);
7940 assert_eq!(back.high, bar.high);
7941 assert_eq!(back.low, bar.low);
7942 assert_eq!(back.close, bar.close);
7943 assert_eq!(back.tick_count, bar.tick_count);
7944 }
7945
7946 #[test]
7947 fn test_ohlcv_bar_duration_nanos() {
7948 let mut bar = make_bar("100", "110", "90", "105");
7949 bar.ts_open = NanoTimestamp::new(1_000_000_000);
7950 bar.ts_close = NanoTimestamp::new(1_060_000_000_000);
7951 assert_eq!(bar.duration_nanos(), 1_059_000_000_000);
7952 }
7953
7954 #[test]
7955 fn test_ohlcv_bar_duration_nanos_same_timestamps() {
7956 let mut bar = make_bar("100", "110", "90", "100");
7957 bar.ts_open = NanoTimestamp::new(5_000);
7958 bar.ts_close = NanoTimestamp::new(5_000);
7959 assert_eq!(bar.duration_nanos(), 0);
7960 }
7961
7962 #[test]
7963 fn test_ohlcv_series_extend_valid() {
7964 let mut series = OhlcvSeries::new();
7965 let bars = vec![
7966 make_bar("100", "110", "90", "105"),
7967 make_bar("105", "115", "95", "110"),
7968 ];
7969 series.extend(bars).unwrap();
7970 assert_eq!(series.len(), 2);
7971 }
7972
7973 #[test]
7974 fn test_ohlcv_series_extend_stops_on_invalid_bar() {
7975 let mut series = OhlcvSeries::new();
7976 let valid = make_bar("100", "110", "90", "105");
7977 let mut invalid = make_bar("100", "110", "90", "105");
7978 invalid.high = make_price("80");
7980 invalid.low = make_price("110");
7981 let result = series.extend([valid, invalid]);
7982 assert!(result.is_err());
7983 assert_eq!(series.len(), 1, "valid bar added before error");
7984 }
7985
7986 #[test]
7987 fn test_ohlcv_bar_to_bar_input_fields_match() {
7988 let bar = make_bar("100", "110", "90", "105");
7989 let input = bar.to_bar_input();
7990 assert_eq!(input.open, bar.open.value());
7991 assert_eq!(input.high, bar.high.value());
7992 assert_eq!(input.low, bar.low.value());
7993 assert_eq!(input.close, bar.close.value());
7994 assert_eq!(input.volume, bar.volume.value());
7995 }
7996
7997 #[test]
7998 fn test_ohlcv_series_retain_removes_gap_fills() {
7999 let mut series = OhlcvSeries::new();
8000 series.push(make_bar("100", "110", "90", "105")).unwrap();
8001 let mut gap = make_bar("105", "105", "105", "105");
8003 gap.tick_count = 0;
8004 series.push(gap).unwrap();
8005 series.push(make_bar("105", "115", "95", "110")).unwrap();
8006 series.retain(|b| !b.is_gap_fill());
8007 assert_eq!(series.len(), 2);
8008 }
8009
8010 #[test]
8011 fn test_ohlcv_series_retain_keeps_all() {
8012 let mut series = OhlcvSeries::new();
8013 series.push(make_bar("100", "110", "90", "105")).unwrap();
8014 series.push(make_bar("105", "115", "95", "110")).unwrap();
8015 series.retain(|_| true);
8016 assert_eq!(series.len(), 2);
8017 }
8018
8019 #[test]
8020 fn test_ohlcv_bar_is_bearish() {
8021 let bar = make_bar("110", "115", "95", "100");
8022 assert!(bar.is_bearish());
8023 assert!(!bar.is_bullish());
8024 }
8025
8026 #[test]
8027 fn test_ohlcv_bar_is_hammer() {
8028 let not_hammer = make_bar("100", "111", "80", "105");
8030 assert!(!not_hammer.is_hammer());
8031 let hammer = make_bar("95", "100", "75", "100");
8033 assert!(hammer.is_hammer());
8034 }
8035
8036 #[test]
8037 fn test_ohlcv_bar_is_shooting_star() {
8038 let star = make_bar("100", "125", "100", "105");
8040 assert!(star.is_shooting_star());
8041 let not_star = make_bar("100", "110", "80", "105");
8043 assert!(!not_star.is_shooting_star());
8044 }
8045
8046 #[test]
8047 fn test_ohlcv_bar_bar_return_positive() {
8048 let bar = make_bar("100", "110", "90", "110");
8049 assert_eq!(bar.bar_return().unwrap(), dec!(10));
8050 }
8051
8052 #[test]
8053 fn test_ohlcv_bar_bar_return_negative() {
8054 let bar = make_bar("100", "105", "85", "90");
8055 assert_eq!(bar.bar_return().unwrap(), dec!(-10));
8056 }
8057
8058 #[test]
8059 fn test_ohlcv_series_highest_high() {
8060 let mut series = OhlcvSeries::new();
8061 series.push(make_bar("100", "150", "90", "105")).unwrap();
8062 series.push(make_bar("105", "130", "95", "110")).unwrap();
8063 series.push(make_bar("110", "120", "100", "115")).unwrap();
8064 assert_eq!(series.highest_high(2).unwrap(), dec!(130));
8065 assert_eq!(series.highest_high(10).unwrap(), dec!(150));
8066 }
8067
8068 #[test]
8069 fn test_ohlcv_series_lowest_low() {
8070 let mut series = OhlcvSeries::new();
8071 series.push(make_bar("100", "110", "70", "105")).unwrap();
8072 series.push(make_bar("105", "115", "85", "110")).unwrap();
8073 series.push(make_bar("110", "120", "90", "115")).unwrap();
8074 assert_eq!(series.lowest_low(2).unwrap(), dec!(85));
8075 assert_eq!(series.lowest_low(10).unwrap(), dec!(70));
8076 }
8077
8078 #[test]
8079 fn test_ohlcv_series_extend_from_series() {
8080 let mut a = OhlcvSeries::new();
8081 a.push(make_bar("100", "110", "90", "105")).unwrap();
8082 let mut b = OhlcvSeries::new();
8083 b.push(make_bar("105", "115", "95", "110")).unwrap();
8084 b.push(make_bar("110", "120", "100", "115")).unwrap();
8085 a.extend_from_series(&b).unwrap();
8086 assert_eq!(a.len(), 3);
8087 }
8088
8089 #[test]
8090 fn test_ohlcv_aggregator_bar_count() {
8091 let sym = Symbol::new("AAPL").unwrap();
8092 let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(1)).unwrap();
8093 assert_eq!(agg.bar_count(), 0);
8094 agg.push_tick(&make_tick("AAPL", "100", "1", 0)).unwrap();
8095 agg.push_tick(&make_tick("AAPL", "101", "1", 2_000_000_000))
8097 .unwrap();
8098 assert_eq!(agg.bar_count(), 2);
8099 agg.flush();
8100 assert_eq!(agg.bar_count(), 3);
8101 agg.reset();
8102 assert_eq!(agg.bar_count(), 0);
8103 }
8104
8105 #[test]
8106 fn test_ohlcv_series_vwap_empty_returns_none() {
8107 assert!(OhlcvSeries::new().vwap().is_none());
8108 }
8109
8110 #[test]
8111 fn test_ohlcv_series_vwap_zero_volume_returns_none() {
8112 let mut series = OhlcvSeries::new();
8113 let mut bar = make_bar("100", "110", "90", "100");
8114 bar.volume = Quantity::zero();
8115 series.push(bar).unwrap();
8116 assert!(series.vwap().is_none());
8117 }
8118
8119 #[test]
8120 fn test_ohlcv_series_vwap_constant_price() {
8121 let mut series = OhlcvSeries::new();
8122 series.push(make_bar("100", "100", "100", "100")).unwrap();
8123 series.push(make_bar("100", "100", "100", "100")).unwrap();
8124 assert_eq!(series.vwap().unwrap(), dec!(100));
8125 }
8126
8127 #[test]
8128 fn test_ohlcv_series_sum_volume_empty() {
8129 assert_eq!(OhlcvSeries::new().sum_volume(), dec!(0));
8130 }
8131
8132 #[test]
8133 fn test_ohlcv_series_sum_volume_multiple_bars() {
8134 let mut series = OhlcvSeries::new();
8135 series.push(make_bar("100", "110", "90", "105")).unwrap();
8136 series.push(make_bar("105", "115", "95", "110")).unwrap();
8137 series.push(make_bar("110", "120", "100", "115")).unwrap();
8138 assert_eq!(series.sum_volume(), dec!(300));
8140 }
8141
8142 #[test]
8143 fn test_ohlcv_series_avg_volume_none_when_empty() {
8144 assert!(OhlcvSeries::new().avg_volume(3).is_none());
8145 }
8146
8147 #[test]
8148 fn test_ohlcv_series_avg_volume_none_when_n_zero() {
8149 let mut series = OhlcvSeries::new();
8150 series.push(make_bar("100", "110", "90", "105")).unwrap();
8151 assert!(series.avg_volume(0).is_none());
8152 }
8153
8154 #[test]
8155 fn test_ohlcv_series_avg_volume_correct() {
8156 let mut series = OhlcvSeries::new();
8158 series.push(make_bar("100", "110", "90", "105")).unwrap();
8159 series.push(make_bar("105", "115", "95", "110")).unwrap();
8160 series.push(make_bar("110", "120", "100", "115")).unwrap();
8161 assert_eq!(series.avg_volume(3).unwrap(), dec!(100));
8163 }
8164
8165 #[test]
8166 fn test_ohlcv_series_avg_volume_partial_window() {
8167 let mut series = OhlcvSeries::new();
8169 series.push(make_bar("100", "110", "90", "105")).unwrap();
8170 series.push(make_bar("105", "115", "95", "110")).unwrap();
8171 assert!(series.avg_volume(5).is_none());
8172 }
8173
8174 #[test]
8175 fn test_ohlcv_series_price_range_none_when_insufficient() {
8176 let mut series = OhlcvSeries::new();
8177 series.push(make_bar("100", "110", "90", "105")).unwrap();
8178 assert!(series.price_range(0).is_none());
8179 assert!(series.price_range(2).is_none());
8180 }
8181
8182 #[test]
8183 fn test_ohlcv_series_price_range_correct() {
8184 let mut series = OhlcvSeries::new();
8186 series.push(make_bar("100", "110", "90", "100")).unwrap();
8187 series.push(make_bar("100", "120", "80", "100")).unwrap();
8188 assert_eq!(series.price_range(2).unwrap(), dec!(40));
8189 }
8190
8191 #[test]
8192 fn test_ohlcv_series_above_ema_false_when_insufficient() {
8193 assert!(!OhlcvSeries::new().above_ema(3));
8194 }
8195
8196 #[test]
8197 fn test_ohlcv_series_above_ema_rising_close() {
8198 let mut series = OhlcvSeries::new();
8199 for c in ["100", "100", "100", "100", "200"] {
8200 series.push(make_bar(c, "210", "90", c)).unwrap();
8201 }
8202 assert!(series.above_ema(3));
8203 }
8204
8205 #[test]
8206 fn test_ohlcv_series_bullish_engulfing_count_zero_when_short() {
8207 assert_eq!(OhlcvSeries::new().bullish_engulfing_count(5), 0);
8208 }
8209
8210 #[test]
8211 fn test_ohlcv_series_bullish_engulfing_count_detects_pattern() {
8212 let mut series = OhlcvSeries::new();
8213 series.push(make_bar("105", "110", "90", "95")).unwrap();
8215 series.push(make_bar("90", "120", "88", "110")).unwrap();
8217 assert_eq!(series.bullish_engulfing_count(2), 1);
8218 }
8219
8220 #[test]
8221 fn test_ohlcv_series_range_expansion_none_when_insufficient() {
8222 assert!(OhlcvSeries::new().range_expansion(3).is_none());
8223 }
8224
8225 #[test]
8226 fn test_ohlcv_series_range_expansion_constant_returns_one() {
8227 let mut series = OhlcvSeries::new();
8228 for _ in 0..5 {
8229 series.push(make_bar("100", "110", "90", "100")).unwrap();
8230 }
8231 assert_eq!(series.range_expansion(5).unwrap(), dec!(1));
8233 }
8234
8235 #[test]
8236 fn test_ohlcv_series_bearish_engulfing_count_zero_when_short() {
8237 assert_eq!(OhlcvSeries::new().bearish_engulfing_count(5), 0);
8238 }
8239
8240 #[test]
8241 fn test_ohlcv_series_bearish_engulfing_count_detects_pattern() {
8242 let mut series = OhlcvSeries::new();
8243 series.push(make_bar("95", "110", "90", "105")).unwrap();
8245 series.push(make_bar("110", "115", "88", "90")).unwrap();
8247 assert_eq!(series.bearish_engulfing_count(2), 1);
8248 }
8249
8250 #[test]
8251 fn test_ohlcv_series_trend_strength_none_when_insufficient() {
8252 let mut series = OhlcvSeries::new();
8253 series.push(make_bar("100", "110", "90", "100")).unwrap();
8254 assert!(series.trend_strength(2).is_none());
8255 }
8256
8257 #[test]
8258 fn test_ohlcv_series_trend_strength_pure_trend_is_one() {
8259 let mut series = OhlcvSeries::new();
8261 for c in ["100", "110", "120", "130"] {
8262 series.push(make_bar(c, "135", "95", c)).unwrap();
8263 }
8264 assert_eq!(series.trend_strength(4).unwrap(), dec!(1));
8265 }
8266
8267 #[test]
8268 fn test_ohlcv_series_close_location_value_none_when_insufficient() {
8269 assert!(OhlcvSeries::new().close_location_value(1).is_none());
8270 }
8271
8272 #[test]
8273 fn test_ohlcv_series_close_location_value_close_at_high() {
8274 let mut series = OhlcvSeries::new();
8276 series.push(make_bar("100", "110", "90", "110")).unwrap();
8277 assert_eq!(series.close_location_value(1).unwrap(), dec!(1));
8278 }
8279
8280 #[test]
8281 fn test_ohlcv_series_close_location_value_close_at_midpoint() {
8282 let mut series = OhlcvSeries::new();
8284 series.push(make_bar("100", "110", "90", "100")).unwrap();
8285 assert_eq!(series.close_location_value(1).unwrap(), dec!(0));
8286 }
8287
8288 #[test]
8289 fn test_ohlcv_series_mean_close_empty_returns_none() {
8290 assert!(OhlcvSeries::new().mean_close(5).is_none());
8291 }
8292
8293 #[test]
8294 fn test_ohlcv_series_mean_close_equal_prices() {
8295 let mut series = OhlcvSeries::new();
8296 series.push(make_bar("100", "110", "90", "100")).unwrap();
8297 series.push(make_bar("100", "110", "90", "100")).unwrap();
8298 series.push(make_bar("100", "110", "90", "100")).unwrap();
8299 assert_eq!(series.mean_close(3).unwrap(), dec!(100));
8300 }
8301
8302 #[test]
8303 fn test_ohlcv_series_mean_close_windowed() {
8304 let mut series = OhlcvSeries::new();
8306 series.push(make_bar("100", "100", "100", "100")).unwrap();
8307 series.push(make_bar("110", "110", "110", "110")).unwrap();
8308 series.push(make_bar("120", "120", "120", "120")).unwrap();
8309 assert_eq!(series.mean_close(2).unwrap(), dec!(115));
8310 }
8311
8312 #[test]
8313 fn test_ohlcv_series_std_dev_less_than_two_bars_returns_none() {
8314 let mut series = OhlcvSeries::new();
8315 series.push(make_bar("100", "110", "90", "100")).unwrap();
8316 assert!(series.std_dev(5).is_none());
8317 }
8318
8319 #[test]
8320 fn test_ohlcv_series_std_dev_constant_prices_is_zero() {
8321 let mut series = OhlcvSeries::new();
8322 for _ in 0..4 {
8323 series.push(make_bar("100", "100", "100", "100")).unwrap();
8324 }
8325 assert_eq!(series.std_dev(4).unwrap(), dec!(0));
8326 }
8327
8328 #[test]
8329 fn test_ohlcv_bar_gap_pct_upward_gap() {
8330 let prev = make_bar("100", "110", "90", "100");
8331 let curr = make_bar("110", "120", "105", "115");
8332 assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(10));
8334 }
8335
8336 #[test]
8337 fn test_ohlcv_bar_gap_pct_downward_gap() {
8338 let prev = make_bar("100", "110", "90", "100");
8339 let curr = make_bar("90", "95", "85", "92");
8340 assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(-10));
8342 }
8343
8344 #[test]
8345 fn test_ohlcv_bar_gap_pct_no_gap() {
8346 let prev = make_bar("100", "110", "90", "100");
8347 let curr = make_bar("100", "110", "90", "105");
8348 assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(0));
8349 }
8350
8351 #[test]
8352 fn test_ohlcv_series_n_bars_ago_returns_correct_bar() {
8353 let mut series = OhlcvSeries::new();
8354 series.push(make_bar("100", "110", "90", "105")).unwrap();
8355 series.push(make_bar("105", "115", "95", "110")).unwrap();
8356 series.push(make_bar("110", "120", "100", "115")).unwrap();
8357 assert_eq!(series.n_bars_ago(0).unwrap().close.value(), dec!(115));
8358 assert_eq!(series.n_bars_ago(1).unwrap().close.value(), dec!(110));
8359 assert_eq!(series.n_bars_ago(2).unwrap().close.value(), dec!(105));
8360 }
8361
8362 #[test]
8363 fn test_ohlcv_series_n_bars_ago_out_of_bounds() {
8364 let mut series = OhlcvSeries::new();
8365 series.push(make_bar("100", "110", "90", "105")).unwrap();
8366 assert!(series.n_bars_ago(1).is_none());
8367 assert!(OhlcvSeries::new().n_bars_ago(0).is_none());
8368 }
8369
8370 #[test]
8371 fn test_ohlcv_bar_is_outside_bar_true() {
8372 let prev = make_bar("100", "110", "90", "105");
8373 let outside = make_bar("100", "120", "80", "110");
8374 assert!(outside.is_outside_bar(&prev));
8375 }
8376
8377 #[test]
8378 fn test_ohlcv_bar_is_outside_bar_false_for_inside() {
8379 let prev = make_bar("100", "120", "80", "110");
8380 let inside = make_bar("100", "110", "90", "105");
8381 assert!(!inside.is_outside_bar(&prev));
8382 }
8383
8384 #[test]
8385 fn test_ohlcv_bar_is_outside_bar_false_partial() {
8386 let prev = make_bar("100", "110", "90", "105");
8387 let partial = make_bar("100", "115", "92", "110");
8388 assert!(!partial.is_outside_bar(&prev));
8389 }
8390
8391 #[test]
8392 fn test_ohlcv_series_from_bars_valid() {
8393 let bars = vec![
8394 make_bar("100", "110", "90", "105"),
8395 make_bar("105", "115", "95", "110"),
8396 ];
8397 let series = OhlcvSeries::from_bars(bars).unwrap();
8398 assert_eq!(series.len(), 2);
8399 }
8400
8401 #[test]
8402 fn test_ohlcv_series_from_bars_empty() {
8403 let series = OhlcvSeries::from_bars(vec![]).unwrap();
8404 assert!(series.is_empty());
8405 }
8406
8407 #[test]
8408 fn test_ohlcv_series_count_bullish() {
8409 let mut series = OhlcvSeries::new();
8410 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);
8414 assert_eq!(series.count_bullish(1), 1); }
8416
8417 #[test]
8418 fn test_ohlcv_series_count_bearish() {
8419 let mut series = OhlcvSeries::new();
8420 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);
8423 assert_eq!(series.count_bearish(1), 0); }
8425
8426 #[test]
8427 fn test_ohlcv_series_count_bullish_exceeds_len() {
8428 let mut series = OhlcvSeries::new();
8429 series.push(make_bar("100", "110", "90", "105")).unwrap();
8430 assert_eq!(series.count_bullish(100), 1);
8431 }
8432
8433 #[test]
8434 fn test_ohlcv_series_median_close_empty() {
8435 assert!(OhlcvSeries::new().median_close(5).is_none());
8436 }
8437
8438 #[test]
8439 fn test_ohlcv_series_median_close_odd_count() {
8440 let mut series = OhlcvSeries::new();
8442 series.push(make_bar("100", "100", "100", "100")).unwrap();
8443 series.push(make_bar("110", "110", "110", "110")).unwrap();
8444 series.push(make_bar("120", "120", "120", "120")).unwrap();
8445 assert_eq!(series.median_close(3).unwrap(), dec!(110));
8446 }
8447
8448 #[test]
8449 fn test_ohlcv_series_median_close_even_count() {
8450 let mut series = OhlcvSeries::new();
8452 series.push(make_bar("100", "100", "100", "100")).unwrap();
8453 series.push(make_bar("110", "110", "110", "110")).unwrap();
8454 assert_eq!(series.median_close(2).unwrap(), dec!(105));
8455 }
8456
8457 #[test]
8458 fn test_ohlcv_series_percentile_rank_empty() {
8459 assert!(OhlcvSeries::new().percentile_rank(dec!(100), 5).is_none());
8460 }
8461
8462 #[test]
8463 fn test_ohlcv_series_percentile_rank_above_all() {
8464 let mut series = OhlcvSeries::new();
8466 for _ in 0..4 {
8467 series.push(make_bar("100", "100", "100", "100")).unwrap();
8468 }
8469 assert_eq!(series.percentile_rank(dec!(101), 4).unwrap(), dec!(100));
8470 }
8471
8472 #[test]
8473 fn test_ohlcv_series_percentile_rank_below_all() {
8474 let mut series = OhlcvSeries::new();
8476 for _ in 0..4 {
8477 series.push(make_bar("100", "100", "100", "100")).unwrap();
8478 }
8479 assert_eq!(series.percentile_rank(dec!(99), 4).unwrap(), dec!(0));
8480 }
8481
8482 #[test]
8483 fn test_ohlcv_series_consecutive_ups_empty() {
8484 assert_eq!(OhlcvSeries::new().consecutive_ups(), 0);
8485 }
8486
8487 #[test]
8488 fn test_ohlcv_series_consecutive_ups_all_bullish() {
8489 let mut series = OhlcvSeries::new();
8490 series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "95", "110")).unwrap(); assert_eq!(series.consecutive_ups(), 2);
8494 }
8495
8496 #[test]
8497 fn test_ohlcv_series_consecutive_ups_broken_by_bearish() {
8498 let mut series = OhlcvSeries::new();
8499 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);
8503 }
8504
8505 #[test]
8506 fn test_ohlcv_series_consecutive_downs_counts_bearish_tail() {
8507 let mut series = OhlcvSeries::new();
8508 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);
8512 assert_eq!(series.consecutive_ups(), 0);
8513 }
8514
8515 #[test]
8516 fn test_ohlcv_bar_is_marubozu_full_body() {
8517 let bar = make_bar("100", "110", "100", "110");
8519 assert!(bar.is_marubozu());
8520 }
8521
8522 #[test]
8523 fn test_ohlcv_bar_is_marubozu_false_with_shadows() {
8524 let bar = make_bar("100", "115", "95", "110");
8525 assert!(!bar.is_marubozu());
8526 }
8527
8528 #[test]
8529 fn test_ohlcv_bar_is_spinning_top_true() {
8530 let bar = make_bar("100", "120", "80", "102");
8532 assert!(bar.is_spinning_top());
8533 }
8534
8535 #[test]
8536 fn test_ohlcv_bar_is_spinning_top_false_large_body() {
8537 let bar = make_bar("100", "115", "95", "114");
8539 assert!(!bar.is_spinning_top());
8540 }
8541
8542 #[test]
8543 fn test_ohlcv_series_average_volume_all_same() {
8544 let mut series = OhlcvSeries::new();
8546 series.push(make_bar("100", "110", "90", "105")).unwrap();
8547 series.push(make_bar("105", "115", "95", "110")).unwrap();
8548 assert_eq!(series.average_volume(2).unwrap(), dec!(100));
8549 }
8550
8551 #[test]
8552 fn test_ohlcv_series_average_range() {
8553 let mut series = OhlcvSeries::new();
8554 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));
8557 }
8558
8559 #[test]
8560 fn test_ohlcv_series_average_volume_empty_returns_none() {
8561 let series = OhlcvSeries::new();
8562 assert!(series.average_volume(5).is_none());
8563 }
8564
8565 #[test]
8566 fn test_ohlcv_series_typical_price_mean_single_bar() {
8567 let mut series = OhlcvSeries::new();
8568 let bar = make_bar("100", "120", "80", "110");
8570 series.push(bar).unwrap();
8571 let tp = series.typical_price_mean(1).unwrap();
8572 let expected = (dec!(120) + dec!(80) + dec!(110)) / dec!(3);
8574 assert_eq!(tp, expected);
8575 }
8576
8577 #[test]
8578 fn test_ohlcv_series_below_sma_zero_when_all_above() {
8579 let mut series = OhlcvSeries::new();
8580 for _ in 0..3 { series.push(make_bar("100", "110", "90", "100")).unwrap(); }
8581 assert_eq!(series.below_sma(3, 3), 0);
8583 }
8584
8585 #[test]
8586 fn test_ohlcv_series_sortino_ratio_insufficient_data() {
8587 let mut series = OhlcvSeries::new();
8588 series.push(make_bar("100", "110", "90", "105")).unwrap();
8589 assert!(series.sortino_ratio(0.0, 252.0).is_none());
8590 }
8591
8592 #[test]
8593 fn test_ohlcv_bar_weighted_close_equals_hlcc4() {
8594 let bar = make_bar("100", "120", "80", "110");
8595 assert_eq!(bar.weighted_close(), bar.hlcc4());
8596 }
8597
8598 #[test]
8599 fn test_ohlcv_bar_weighted_close_value() {
8600 let bar = make_bar("100", "120", "80", "110");
8602 assert_eq!(bar.weighted_close(), dec!(105));
8603 }
8604
8605 #[test]
8606 fn test_close_above_open_streak_three_bullish() {
8607 let mut series = OhlcvSeries::new();
8608 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);
8613 }
8614
8615 #[test]
8616 fn test_close_above_open_streak_last_bearish_returns_zero() {
8617 let mut series = OhlcvSeries::new();
8618 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);
8621 }
8622
8623 #[test]
8624 fn test_close_above_open_streak_empty_series_returns_zero() {
8625 assert_eq!(OhlcvSeries::new().close_above_open_streak(), 0);
8626 }
8627
8628 #[test]
8629 fn test_max_drawdown_pct_declining_series() {
8630 let mut series = OhlcvSeries::new();
8631 series.push(make_bar("100", "110", "90", "100")).unwrap();
8632 series.push(make_bar("100", "105", "75", "80")).unwrap(); series.push(make_bar("80", "85", "75", "84")).unwrap();
8634 let dd = series.max_drawdown_pct(10).unwrap();
8635 assert!((dd - 20.0).abs() < 1e-6, "expected ~20, got {dd}");
8636 }
8637
8638 #[test]
8639 fn test_max_drawdown_pct_flat_returns_zero() {
8640 let mut series = OhlcvSeries::new();
8641 series.push(make_bar("100", "110", "90", "100")).unwrap();
8642 series.push(make_bar("100", "110", "90", "100")).unwrap();
8643 assert_eq!(series.max_drawdown_pct(10).unwrap(), 0.0);
8644 }
8645
8646 #[test]
8647 fn test_max_drawdown_pct_single_bar_returns_none() {
8648 let mut series = OhlcvSeries::new();
8649 series.push(make_bar("100", "110", "90", "100")).unwrap();
8650 assert!(series.max_drawdown_pct(10).is_none());
8651 }
8652
8653 #[test]
8654 fn test_ohlcv_bar_gap_up_from_prev() {
8655 let prev = make_bar("100", "105", "95", "103");
8656 let curr = make_bar("107", "115", "106", "112"); assert!(curr.gap_up_from(&prev));
8658 }
8659
8660 #[test]
8661 fn test_ohlcv_bar_no_gap_up() {
8662 let prev = make_bar("100", "110", "90", "105");
8663 let curr = make_bar("105", "112", "104", "108"); assert!(!curr.gap_up_from(&prev));
8665 }
8666
8667 #[test]
8668 fn test_ohlcv_bar_gap_down_from_prev() {
8669 let prev = make_bar("100", "105", "95", "97");
8670 let curr = make_bar("93", "94", "88", "90"); assert!(curr.gap_down_from(&prev));
8672 }
8673
8674 #[test]
8675 fn test_ohlcv_bar_no_gap_down() {
8676 let prev = make_bar("100", "110", "90", "95");
8677 let curr = make_bar("96", "100", "92", "98"); assert!(!curr.gap_down_from(&prev));
8679 }
8680
8681 #[test]
8682 fn test_ohlcv_series_last_n_closes_returns_n() {
8683 let mut series = OhlcvSeries::new();
8684 for close in &["100", "102", "104", "106", "108"] {
8685 series.push(make_bar(close, "115", "95", close)).unwrap();
8686 }
8687 let closes = series.last_n_closes(3);
8688 assert_eq!(closes.len(), 3);
8689 assert_eq!(closes[2], dec!(108));
8690 }
8691
8692 #[test]
8693 fn test_ohlcv_series_last_n_closes_fewer_than_n() {
8694 let mut series = OhlcvSeries::new();
8695 series.push(make_bar("100", "110", "90", "100")).unwrap();
8696 let closes = series.last_n_closes(5);
8697 assert_eq!(closes.len(), 1);
8698 }
8699
8700 #[test]
8701 fn test_ohlcv_series_volume_spike_detects_spike() {
8702 use crate::types::{NanoTimestamp, Quantity, Symbol};
8703 let sym = Symbol::new("X").unwrap();
8704 let p = crate::types::Price::new(dec!(100)).unwrap();
8705 let mut series = OhlcvSeries::new();
8706 for _ in 0..3 {
8708 series.push(OhlcvBar {
8709 symbol: sym.clone(), open: p, high: p, low: p, close: p,
8710 volume: Quantity::new(dec!(100)).unwrap(),
8711 ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
8712 }).unwrap();
8713 }
8714 series.push(OhlcvBar {
8716 symbol: sym.clone(), open: p, high: p, low: p, close: p,
8717 volume: Quantity::new(dec!(500)).unwrap(),
8718 ts_open: NanoTimestamp::new(2), ts_close: NanoTimestamp::new(3), tick_count: 1,
8719 }).unwrap();
8720 assert!(series.volume_spike(3, dec!(3)));
8721 }
8722
8723 #[test]
8724 fn test_ohlcv_series_volume_spike_false_for_normal_volume() {
8725 use crate::types::{NanoTimestamp, Quantity, Symbol};
8726 let sym = Symbol::new("X").unwrap();
8727 let p = crate::types::Price::new(dec!(100)).unwrap();
8728 let mut series = OhlcvSeries::new();
8729 for _ in 0..4 {
8730 series.push(OhlcvBar {
8731 symbol: sym.clone(), open: p, high: p, low: p, close: p,
8732 volume: Quantity::new(dec!(100)).unwrap(),
8733 ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
8734 }).unwrap();
8735 }
8736 assert!(!series.volume_spike(3, dec!(3)));
8737 }
8738
8739 #[test]
8740 fn test_efficiency_ratio_trending() {
8741 let mut series = OhlcvSeries::new();
8742 for i in 0..6u32 {
8744 series.push(make_bar(&format!("{}", 100 + i), &format!("{}", 105 + i), &format!("{}", 99 + i), &format!("{}", 100 + i))).unwrap();
8745 }
8746 let er = series.efficiency_ratio(5).unwrap();
8747 assert_eq!(er, dec!(1));
8748 }
8749
8750 #[test]
8751 fn test_efficiency_ratio_none_when_not_enough_bars() {
8752 let mut series = OhlcvSeries::new();
8753 series.push(make_bar("100", "110", "90", "100")).unwrap();
8754 assert!(series.efficiency_ratio(5).is_none());
8755 }
8756
8757 #[test]
8758 fn test_efficiency_ratio_zero_period_returns_none() {
8759 let series = OhlcvSeries::new();
8760 assert!(series.efficiency_ratio(0).is_none());
8761 }
8762
8763 #[test]
8764 fn test_body_pct_series_full_body() {
8765 let mut series = OhlcvSeries::new();
8766 series.push(make_bar("90", "110", "90", "110")).unwrap();
8768 let v = series.body_pct_series(1);
8769 assert_eq!(v.len(), 1);
8770 assert_eq!(v[0], Some(dec!(100)));
8771 }
8772
8773 #[test]
8774 fn test_body_pct_series_zero_range_returns_none() {
8775 let mut series = OhlcvSeries::new();
8776 series.push(make_bar("100", "100", "100", "100")).unwrap();
8777 let v = series.body_pct_series(1);
8778 assert_eq!(v[0], None);
8779 }
8780
8781 #[test]
8782 fn test_candle_color_changes_alternating() {
8783 let mut series = OhlcvSeries::new();
8784 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);
8789 }
8790
8791 #[test]
8792 fn test_candle_color_changes_no_changes() {
8793 let mut series = OhlcvSeries::new();
8794 for _ in 0..3 {
8796 series.push(make_bar("95", "110", "90", "105")).unwrap();
8797 }
8798 assert_eq!(series.candle_color_changes(3), 0);
8799 }
8800
8801 #[test]
8802 fn test_typical_price_series_values() {
8803 let mut series = OhlcvSeries::new();
8804 series.push(make_bar("95", "110", "90", "100")).unwrap();
8806 let v = series.typical_price_series(1);
8807 assert_eq!(v.len(), 1);
8808 assert_eq!(v[0], dec!(100));
8809 }
8810
8811 #[test]
8812 fn test_typical_price_series_empty_series_returns_empty() {
8813 let series = OhlcvSeries::new();
8814 assert!(series.typical_price_series(3).is_empty());
8815 }
8816
8817 #[test]
8818 fn test_bar_at_index_valid() {
8819 let bars = vec![bar("100"), bar("101"), bar("102")];
8820 let series = OhlcvSeries::from_bars(bars).unwrap();
8821 assert!(series.bar_at_index(0).is_some());
8822 assert_eq!(series.bar_at_index(2).unwrap().close.value(), dec!(102));
8823 }
8824
8825 #[test]
8826 fn test_bar_at_index_out_of_bounds() {
8827 let bars = vec![bar("100")];
8828 let series = OhlcvSeries::from_bars(bars).unwrap();
8829 assert!(series.bar_at_index(5).is_none());
8830 }
8831
8832 #[test]
8833 fn test_rolling_close_std_returns_none_for_fewer_than_two() {
8834 let bars = vec![bar("100")];
8835 let series = OhlcvSeries::from_bars(bars).unwrap();
8836 assert!(series.rolling_close_std(1).is_none());
8837 }
8838
8839 #[test]
8840 fn test_rolling_close_std_constant_prices_is_zero() {
8841 let bars = vec![bar("100"), bar("100"), bar("100")];
8842 let series = OhlcvSeries::from_bars(bars).unwrap();
8843 let std = series.rolling_close_std(3).unwrap();
8844 assert_eq!(std, Decimal::ZERO);
8845 }
8846
8847 #[test]
8848 fn test_rolling_close_std_varying_prices_positive() {
8849 let bars = vec![bar("100"), bar("110"), bar("120"), bar("130")];
8850 let series = OhlcvSeries::from_bars(bars).unwrap();
8851 let std = series.rolling_close_std(4).unwrap();
8852 assert!(std > Decimal::ZERO);
8853 }
8854
8855 #[test]
8856 fn test_gap_direction_series_empty_for_single_bar() {
8857 let bars = vec![bar("100")];
8858 let series = OhlcvSeries::from_bars(bars).unwrap();
8859 assert!(series.gap_direction_series(3).is_empty());
8860 }
8861
8862 #[test]
8863 fn test_gap_direction_series_flat_on_equal_prices() {
8864 let bars = vec![bar("100"), bar("100"), bar("100")];
8865 let series = OhlcvSeries::from_bars(bars).unwrap();
8866 let gaps = series.gap_direction_series(3);
8867 assert!(gaps.iter().all(|&g| g == 0));
8868 }
8869
8870 #[test]
8871 fn test_gap_direction_series_detects_gap_up() {
8872 let p1 = Price::new(dec!(100)).unwrap();
8874 let p2 = Price::new(dec!(110)).unwrap();
8875 let b1 = OhlcvBar {
8876 symbol: Symbol::new("X").unwrap(),
8877 open: p1, high: p1, low: p1, close: p1,
8878 volume: Quantity::zero(),
8879 ts_open: NanoTimestamp::new(0),
8880 ts_close: NanoTimestamp::new(1),
8881 tick_count: 1,
8882 };
8883 let b2 = OhlcvBar {
8884 symbol: Symbol::new("X").unwrap(),
8885 open: p2, high: p2, low: p2, close: p2,
8886 volume: Quantity::zero(),
8887 ts_open: NanoTimestamp::new(2),
8888 ts_close: NanoTimestamp::new(3),
8889 tick_count: 1,
8890 };
8891 let series = OhlcvSeries::from_bars(vec![b1, b2]).unwrap();
8892 let gaps = series.gap_direction_series(2);
8893 assert_eq!(gaps, vec![1i8]);
8894 }
8895
8896 #[test]
8897 fn test_bullish_candle_pct_all_bullish() {
8898 let bars = vec![
8900 make_bar("95", "105", "94", "100"),
8901 make_bar("99", "110", "98", "108"),
8902 make_bar("107", "115", "106", "112"),
8903 ];
8904 let series = OhlcvSeries::from_bars(bars).unwrap();
8905 assert_eq!(series.bullish_candle_pct(3).unwrap(), 1.0);
8906 }
8907
8908 #[test]
8909 fn test_bullish_candle_pct_none_for_zero_n() {
8910 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8911 assert!(series.bullish_candle_pct(0).is_none());
8912 }
8913
8914 #[test]
8915 fn test_price_above_ma_pct_all_above() {
8916 let bars = vec![
8918 bar("100"), bar("102"), bar("104"), bar("106"), bar("108"),
8919 ];
8920 let series = OhlcvSeries::from_bars(bars).unwrap();
8921 let pct = series.price_above_ma_pct(3, 2).unwrap();
8923 assert!(pct > 0.0);
8924 }
8925
8926 #[test]
8927 fn test_price_above_ma_pct_insufficient_bars() {
8928 let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
8929 assert!(series.price_above_ma_pct(2, 3).is_none());
8930 }
8931
8932 #[test]
8933 fn test_avg_body_size_flat() {
8934 let bars = vec![bar("100"), bar("100"), bar("100")];
8936 let series = OhlcvSeries::from_bars(bars).unwrap();
8937 assert_eq!(series.avg_body_size(3).unwrap(), dec!(0));
8938 }
8939
8940 #[test]
8941 fn test_avg_body_size_none_for_zero_n() {
8942 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8943 assert!(series.avg_body_size(0).is_none());
8944 }
8945
8946 #[test]
8947 fn test_true_range_series_flat() {
8948 let bars = vec![bar("100"), bar("100"), bar("100")];
8949 let series = OhlcvSeries::from_bars(bars).unwrap();
8950 let trs = series.true_range_series(3).unwrap();
8951 assert_eq!(trs.len(), 3);
8952 for tr in trs {
8954 assert_eq!(tr, dec!(0));
8955 }
8956 }
8957
8958 #[test]
8959 fn test_true_range_series_none_when_insufficient() {
8960 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8961 assert!(series.true_range_series(0).is_none());
8962 assert!(series.true_range_series(2).is_none());
8963 }
8964
8965 #[test]
8966 fn test_intraday_return_pct_positive() {
8967 let make_bar = |o: &str, c: &str| {
8969 let op = Price::new(o.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
8970 let cl = Price::new(c.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
8971 OhlcvBar {
8972 symbol: Symbol::new("X").unwrap(),
8973 open: op, high: cl, low: op, close: cl,
8974 volume: Quantity::zero(),
8975 ts_open: NanoTimestamp::new(0),
8976 ts_close: NanoTimestamp::new(1),
8977 tick_count: 1,
8978 }
8979 };
8980 let series = OhlcvSeries::from_bars(vec![make_bar("100", "110")]).unwrap();
8981 assert_eq!(series.intraday_return_pct().unwrap(), dec!(10));
8983 }
8984
8985 #[test]
8986 fn test_intraday_return_pct_empty() {
8987 assert!(OhlcvSeries::new().intraday_return_pct().is_none());
8988 }
8989
8990 #[test]
8991 fn test_bearish_bar_count_all_flat() {
8992 let bars = vec![bar("100"), bar("100"), bar("100")];
8993 let series = OhlcvSeries::from_bars(bars).unwrap();
8994 assert_eq!(series.bearish_bar_count(3).unwrap(), 0);
8996 }
8997
8998 #[test]
8999 fn test_bearish_bar_count_none_insufficient() {
9000 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9001 assert!(series.bearish_bar_count(0).is_none());
9002 assert!(series.bearish_bar_count(2).is_none());
9003 }
9004
9005 #[test]
9006 fn test_hl_midpoint_flat() {
9007 let bars = vec![bar("100"), bar("100"), bar("100")];
9008 let series = OhlcvSeries::from_bars(bars).unwrap();
9009 assert_eq!(series.hl_midpoint(3).unwrap(), dec!(100));
9010 }
9011
9012 #[test]
9013 fn test_hl_midpoint_none_when_insufficient() {
9014 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9015 assert!(series.hl_midpoint(0).is_none());
9016 assert!(series.hl_midpoint(2).is_none());
9017 }
9018
9019 #[test]
9020 fn test_up_volume_ratio_flat_bars() {
9021 let bars = vec![bar("100"), bar("100"), bar("100")];
9023 let series = OhlcvSeries::from_bars(bars).unwrap();
9024 let ratio = series.up_volume_ratio(3);
9026 if let Some(r) = ratio {
9027 assert_eq!(r, dec!(0));
9028 }
9029 }
9031
9032 #[test]
9033 fn test_price_efficiency_trending() {
9034 let bars: Vec<_> = (100..106u32).map(|i| bar(&i.to_string())).collect();
9036 let series = OhlcvSeries::from_bars(bars).unwrap();
9037 assert_eq!(series.price_efficiency(5).unwrap(), dec!(1));
9038 }
9039
9040 #[test]
9041 fn test_price_efficiency_none_insufficient() {
9042 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9043 assert!(series.price_efficiency(1).is_none());
9044 assert!(series.price_efficiency(3).is_none());
9045 }
9046
9047 #[test]
9048 fn test_avg_gap_zero_when_no_jumps() {
9049 let bars = vec![bar("100"), bar("100"), bar("100")];
9050 let series = OhlcvSeries::from_bars(bars).unwrap();
9051 assert_eq!(series.avg_gap(2).unwrap(), dec!(0));
9052 }
9053
9054 #[test]
9055 fn test_avg_gap_none_when_insufficient() {
9056 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9057 assert!(series.avg_gap(0).is_none());
9058 assert!(series.avg_gap(1).is_none());
9059 }
9060
9061 #[test]
9062 fn test_largest_gap_pct_no_gap() {
9063 let bars: Vec<_> = (0..5).map(|_| bar("100")).collect();
9065 let series = OhlcvSeries::from_bars(bars).unwrap();
9066 assert_eq!(series.largest_gap_pct(4).unwrap(), dec!(0));
9067 }
9068
9069 #[test]
9070 fn test_largest_gap_pct_none_insufficient() {
9071 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9072 assert!(series.largest_gap_pct(2).is_none());
9073 assert!(series.largest_gap_pct(1).is_none());
9074 }
9075
9076 #[test]
9077 fn test_close_momentum_flat_zero() {
9078 let bars: Vec<_> = (0..6).map(|_| bar("100")).collect();
9079 let series = OhlcvSeries::from_bars(bars).unwrap();
9080 assert_eq!(series.close_momentum(3).unwrap(), dec!(0));
9081 }
9082
9083 #[test]
9084 fn test_close_momentum_none_insufficient() {
9085 let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
9087 assert!(series.close_momentum(2).is_none()); assert!(series.close_momentum(0).is_none());
9089 }
9090
9091 #[test]
9092 fn test_swing_high_count_none_when_insufficient() {
9093 let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
9094 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()); }
9098
9099 #[test]
9100 fn test_swing_high_count_detects_peak() {
9101 let bars = vec![bar("100"), bar("110"), bar("100"), bar("100"), bar("100")];
9103 let series = OhlcvSeries::from_bars(bars).unwrap();
9104 let count = series.swing_high_count(5, 1).unwrap();
9105 assert_eq!(count, 1);
9106 }
9107
9108 #[test]
9109 fn test_swing_high_count_flat_no_highs() {
9110 let bars: Vec<_> = (0..7).map(|_| bar("100")).collect();
9111 let series = OhlcvSeries::from_bars(bars).unwrap();
9112 assert_eq!(series.swing_high_count(7, 1).unwrap(), 0);
9113 }
9114
9115 #[test]
9116 fn test_avg_wick_pct_none_when_zero_range() {
9117 let bars = vec![bar("100"), bar("100")];
9118 let series = OhlcvSeries::from_bars(bars).unwrap();
9119 assert!(series.avg_wick_pct(2).is_none()); }
9121
9122 #[test]
9123 fn test_avg_wick_pct_none_insufficient() {
9124 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9125 assert!(series.avg_wick_pct(0).is_none());
9126 assert!(series.avg_wick_pct(2).is_none());
9127 }
9128
9129 #[test]
9130 fn test_trend_continuation_pct_none_insufficient() {
9131 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9132 assert!(series.trend_continuation_pct(0).is_none());
9133 assert!(series.trend_continuation_pct(1).is_none()); }
9135
9136 fn make_bar_vol(o: &str, h: &str, l: &str, c: &str, vol: &str) -> OhlcvBar {
9137 OhlcvBar {
9138 symbol: Symbol::new("X").unwrap(),
9139 open: make_price(o),
9140 high: make_price(h),
9141 low: make_price(l),
9142 close: make_price(c),
9143 volume: make_qty(vol),
9144 ts_open: NanoTimestamp::new(0),
9145 ts_close: NanoTimestamp::new(1),
9146 tick_count: 1,
9147 }
9148 }
9149
9150 #[test]
9151 fn test_close_to_open_ratio_bullish() {
9152 let bars = vec![
9154 make_bar_vol("100", "110", "95", "110", "1000"), make_bar_vol("105", "115", "100", "115", "1000"), ];
9157 let series = OhlcvSeries::from_bars(bars).unwrap();
9158 let ratio = series.close_to_open_ratio(2).unwrap();
9159 assert!(ratio > dec!(1), "bullish bars: ratio > 1, got {ratio}");
9160 }
9161
9162 #[test]
9163 fn test_close_to_open_ratio_none_zero_n() {
9164 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9165 assert!(series.close_to_open_ratio(0).is_none());
9166 }
9167
9168 #[test]
9169 fn test_volume_trend_rising() {
9170 let bars: Vec<OhlcvBar> = (1..=5u32).map(|i| {
9171 make_bar_vol("100", "100", "100", "100", &(i * 100).to_string())
9172 }).collect();
9173 let series = OhlcvSeries::from_bars(bars).unwrap();
9174 let slope = series.volume_trend(5).unwrap();
9175 assert!(slope > 0.0_f64, "rising volume: positive slope, got {slope}");
9176 }
9177
9178 #[test]
9179 fn test_volume_trend_none_insufficient() {
9180 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9181 assert!(series.volume_trend(0).is_none());
9182 assert!(series.volume_trend(2).is_none()); }
9184
9185 #[test]
9186 fn test_high_volume_price_returns_close_of_max_vol_bar() {
9187 let bars = vec![
9188 make_bar_vol("100", "100", "100", "100", "500"),
9189 make_bar_vol("200", "200", "200", "200", "1000"), make_bar_vol("150", "150", "150", "150", "300"),
9191 ];
9192 let series = OhlcvSeries::from_bars(bars).unwrap();
9193 assert_eq!(series.high_volume_price(3), Some(dec!(200)));
9194 }
9195
9196 #[test]
9197 fn test_high_volume_price_none_zero_n() {
9198 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9199 assert!(series.high_volume_price(0).is_none());
9200 }
9201
9202 #[test]
9203 fn test_avg_close_minus_open_bullish() {
9204 let bars = vec![
9205 make_bar_vol("100", "110", "95", "105", "1000"), make_bar_vol("105", "115", "100", "108", "1000"), ];
9208 let series = OhlcvSeries::from_bars(bars).unwrap();
9209 let avg = series.avg_close_minus_open(2).unwrap();
9210 assert_eq!(avg, dec!(4)); }
9212
9213 #[test]
9214 fn test_avg_close_minus_open_none_zero_n() {
9215 let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9216 assert!(series.avg_close_minus_open(0).is_none());
9217 }
9218}