1use crate::error::StreamError;
12use crate::tick::NormalizedTick;
13use rust_decimal::Decimal;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
17pub enum Timeframe {
18 Seconds(u64),
20 Minutes(u64),
22 Hours(u64),
24}
25
26impl Timeframe {
27 pub fn duration_ms(self) -> u64 {
29 match self {
30 Timeframe::Seconds(s) => s * 1_000,
31 Timeframe::Minutes(m) => m * 60 * 1_000,
32 Timeframe::Hours(h) => h * 3600 * 1_000,
33 }
34 }
35
36 pub fn bar_start_ms(self, ts_ms: u64) -> u64 {
38 let dur = self.duration_ms();
39 (ts_ms / dur) * dur
40 }
41
42 pub fn from_duration_ms(ms: u64) -> Option<Timeframe> {
48 if ms == 0 {
49 return None;
50 }
51 if ms % 3_600_000 == 0 {
52 return Some(Timeframe::Hours(ms / 3_600_000));
53 }
54 if ms % 60_000 == 0 {
55 return Some(Timeframe::Minutes(ms / 60_000));
56 }
57 if ms % 1_000 == 0 {
58 return Some(Timeframe::Seconds(ms / 1_000));
59 }
60 None
61 }
62}
63
64impl std::fmt::Display for Timeframe {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Timeframe::Seconds(s) => write!(f, "{s}s"),
68 Timeframe::Minutes(m) => write!(f, "{m}m"),
69 Timeframe::Hours(h) => write!(f, "{h}h"),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum BarDirection {
77 Bullish,
79 Bearish,
81 Neutral,
83}
84
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
87pub struct OhlcvBar {
88 pub symbol: String,
90 pub timeframe: Timeframe,
92 pub bar_start_ms: u64,
94 pub open: Decimal,
96 pub high: Decimal,
98 pub low: Decimal,
100 pub close: Decimal,
102 pub volume: Decimal,
104 pub trade_count: u64,
106 pub is_complete: bool,
108 pub is_gap_fill: bool,
113 pub vwap: Option<Decimal>,
115}
116
117impl OhlcvBar {
118 pub fn range(&self) -> Decimal {
120 self.high - self.low
121 }
122
123 pub fn body(&self) -> Decimal {
127 (self.close - self.open).abs()
128 }
129
130 pub fn is_bullish(&self) -> bool {
132 self.close > self.open
133 }
134
135 pub fn is_bearish(&self) -> bool {
137 self.close < self.open
138 }
139
140 pub fn has_upper_wick(&self) -> bool {
142 self.wick_upper() > Decimal::ZERO
143 }
144
145 pub fn has_lower_wick(&self) -> bool {
147 self.wick_lower() > Decimal::ZERO
148 }
149
150 pub fn body_direction(&self) -> BarDirection {
155 use std::cmp::Ordering;
156 match self.close.cmp(&self.open) {
157 Ordering::Greater => BarDirection::Bullish,
158 Ordering::Less => BarDirection::Bearish,
159 Ordering::Equal => BarDirection::Neutral,
160 }
161 }
162
163 pub fn is_doji(&self, epsilon: Decimal) -> bool {
168 self.body() <= epsilon
169 }
170
171 pub fn wick_upper(&self) -> Decimal {
175 self.high - self.open.max(self.close)
176 }
177
178 pub fn wick_lower(&self) -> Decimal {
182 self.open.min(self.close) - self.low
183 }
184
185 pub fn price_change(&self) -> Decimal {
190 self.close - self.open
191 }
192
193 pub fn typical_price(&self) -> Decimal {
198 (self.high + self.low + self.close) / Decimal::from(3)
199 }
200
201 pub fn close_location_value(&self) -> Option<f64> {
209 use rust_decimal::prelude::ToPrimitive;
210 let range = self.range();
211 if range.is_zero() {
212 return None;
213 }
214 ((self.close - self.low - (self.high - self.close)) / range).to_f64()
215 }
216
217 pub fn median_price(&self) -> Decimal {
221 (self.high + self.low) / Decimal::from(2)
222 }
223
224 pub fn weighted_close(&self) -> Decimal {
229 (self.high + self.low + self.close + self.close) / Decimal::from(4)
230 }
231
232 pub fn price_change_pct(&self) -> Option<f64> {
237 use rust_decimal::prelude::ToPrimitive;
238 if self.open.is_zero() {
239 return None;
240 }
241 let pct = (self.close - self.open) / self.open * Decimal::from(100);
242 pct.to_f64()
243 }
244
245 pub fn body_ratio(&self) -> Option<f64> {
251 use rust_decimal::prelude::ToPrimitive;
252 let range = self.range();
253 if range.is_zero() {
254 return None;
255 }
256 (self.body() / range).to_f64()
257 }
258
259 pub fn true_range(&self, prev_close: Decimal) -> Decimal {
264 let hl = self.high - self.low;
265 let hpc = (self.high - prev_close).abs();
266 let lpc = (self.low - prev_close).abs();
267 hl.max(hpc).max(lpc)
268 }
269
270 pub fn inside_bar(&self, prev: &OhlcvBar) -> bool {
276 self.high < prev.high && self.low > prev.low
277 }
278
279 pub fn outside_bar(&self, prev: &OhlcvBar) -> bool {
284 self.high > prev.high && self.low < prev.low
285 }
286
287 pub fn wick_ratio(&self) -> Option<f64> {
292 use rust_decimal::prelude::ToPrimitive;
293 let range = self.range();
294 if range.is_zero() {
295 return None;
296 }
297 ((self.wick_upper() + self.wick_lower()) / range).to_f64()
298 }
299
300 pub fn is_hammer(&self) -> bool {
309 let range = self.range();
310 if range.is_zero() {
311 return false;
312 }
313 let body = self.body();
314 let wick_lo = self.wick_lower();
315 let wick_hi = self.wick_upper();
316 let three = Decimal::from(3);
317 let six = Decimal::from(6);
318 let ten = Decimal::from(10);
319 body * ten <= range * three
323 && wick_lo * ten >= range * six
324 && wick_hi * ten <= range
325 }
326
327 pub fn is_shooting_star(&self) -> bool {
337 let range = self.range();
338 if range.is_zero() {
339 return false;
340 }
341 let body = self.body();
342 let wick_lo = self.wick_lower();
343 let wick_hi = self.wick_upper();
344 let three = Decimal::from(3);
345 let six = Decimal::from(6);
346 let ten = Decimal::from(10);
347 body * ten <= range * three
351 && wick_hi * ten >= range * six
352 && wick_lo * ten <= range
353 }
354
355 pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
360 self.open - prev.close
361 }
362
363 pub fn is_gap_up(&self, prev: &OhlcvBar) -> bool {
365 self.open > prev.close
366 }
367
368 pub fn is_gap_down(&self, prev: &OhlcvBar) -> bool {
370 self.open < prev.close
371 }
372
373 pub fn bar_midpoint(&self) -> Decimal {
378 (self.open + self.close) / Decimal::from(2)
379 }
380
381 pub fn body_to_range_ratio(&self) -> Option<Decimal> {
385 let r = self.range();
386 if r.is_zero() {
387 return None;
388 }
389 Some(self.body() / r)
390 }
391
392 pub fn is_long_upper_wick(&self) -> bool {
396 self.wick_upper() > self.body()
397 }
398
399 pub fn price_change_abs(&self) -> Decimal {
401 (self.close - self.open).abs()
402 }
403
404 pub fn upper_shadow(&self) -> Decimal {
408 self.wick_upper()
409 }
410
411 pub fn lower_shadow(&self) -> Decimal {
415 self.wick_lower()
416 }
417
418 pub fn is_spinning_top(&self, body_pct: Decimal) -> bool {
427 let range = self.range();
428 if range.is_zero() {
429 return false;
430 }
431 let body = self.body();
432 let max_body = range * body_pct;
433 body <= max_body && self.wick_upper() > body && self.wick_lower() > body
434 }
435
436 pub fn hlc3(&self) -> Decimal {
438 self.typical_price()
439 }
440
441 pub fn ohlc4(&self) -> Decimal {
446 (self.open + self.high + self.low + self.close) / Decimal::from(4)
447 }
448
449 pub fn is_marubozu(&self) -> bool {
456 self.wick_upper().is_zero() && self.wick_lower().is_zero()
457 }
458
459 pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
470 let self_lo = self.open.min(self.close);
471 let self_hi = self.open.max(self.close);
472 let prev_lo = prev.open.min(prev.close);
473 let prev_hi = prev.open.max(prev.close);
474 self_lo < prev_lo && self_hi > prev_hi
475 }
476
477 pub fn is_harami(&self, prev: &OhlcvBar) -> bool {
483 let self_lo = self.open.min(self.close);
484 let self_hi = self.open.max(self.close);
485 let prev_lo = prev.open.min(prev.close);
486 let prev_hi = prev.open.max(prev.close);
487 self_lo > prev_lo && self_hi < prev_hi
488 }
489
490 pub fn tail_length(&self) -> Decimal {
495 self.wick_upper().max(self.wick_lower())
496 }
497
498 pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
504 self.high < prev.high && self.low > prev.low
505 }
506
507 pub fn gap_up(&self, prev: &OhlcvBar) -> bool {
509 self.open > prev.high
510 }
511
512 pub fn gap_down(&self, prev: &OhlcvBar) -> bool {
514 self.open < prev.low
515 }
516
517 pub fn body_size(&self) -> Decimal {
519 (self.close - self.open).abs()
520 }
521
522 pub fn volume_delta(&self, prev: &OhlcvBar) -> Decimal {
524 self.volume - prev.volume
525 }
526
527 pub fn is_consolidating(&self, prev: &OhlcvBar) -> bool {
531 let prev_range = prev.high - prev.low;
532 if prev_range.is_zero() {
533 return false;
534 }
535 let this_range = self.high - self.low;
536 this_range < prev_range / Decimal::TWO
537 }
538
539 pub fn mean_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
543 if bars.is_empty() {
544 return None;
545 }
546 let sum: Decimal = bars.iter().map(|b| b.volume).sum();
547 Some(sum / Decimal::from(bars.len() as u64))
548 }
549
550 pub fn vwap_deviation(&self) -> Option<f64> {
554 use rust_decimal::prelude::ToPrimitive;
555 let vwap = self.vwap?;
556 if vwap.is_zero() {
557 return None;
558 }
559 ((self.close - vwap).abs() / vwap).to_f64()
560 }
561
562 pub fn relative_volume(&self, avg_volume: Decimal) -> Option<f64> {
566 use rust_decimal::prelude::ToPrimitive;
567 if avg_volume.is_zero() {
568 return None;
569 }
570 (self.volume / avg_volume).to_f64()
571 }
572
573 pub fn intraday_reversal(&self, prev: &OhlcvBar) -> bool {
579 let prev_bullish = prev.close > prev.open;
580 let this_bearish = self.close < self.open;
581 let prev_bearish = prev.close < prev.open;
582 let this_bullish = self.close > self.open;
583 (prev_bullish && this_bearish && self.open >= prev.close)
584 || (prev_bearish && this_bullish && self.open <= prev.close)
585 }
586
587 pub fn range_pct(&self) -> Option<f64> {
591 use rust_decimal::prelude::ToPrimitive;
592 if self.open.is_zero() {
593 return None;
594 }
595 let range = (self.high - self.low) / self.open;
596 range.to_f64().map(|v| v * 100.0)
597 }
598
599 pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
603 self.high > prev.high && self.low < prev.low
604 }
605
606 pub fn high_low_midpoint(&self) -> Decimal {
608 (self.high + self.low) / Decimal::TWO
609 }
610
611 pub fn high_close_ratio(&self) -> Option<f64> {
616 use rust_decimal::prelude::ToPrimitive;
617 if self.high.is_zero() {
618 return None;
619 }
620 (self.close / self.high).to_f64()
621 }
622
623 pub fn lower_shadow_pct(&self) -> Option<f64> {
627 use rust_decimal::prelude::ToPrimitive;
628 let range = self.high - self.low;
629 if range.is_zero() {
630 return None;
631 }
632 (self.lower_shadow() / range).to_f64()
633 }
634
635 pub fn open_close_ratio(&self) -> Option<f64> {
639 use rust_decimal::prelude::ToPrimitive;
640 if self.open.is_zero() {
641 return None;
642 }
643 (self.close / self.open).to_f64()
644 }
645
646 pub fn is_wide_range_bar(&self, threshold: Decimal) -> bool {
648 (self.high - self.low) > threshold
649 }
650
651 pub fn close_to_low_ratio(&self) -> Option<f64> {
657 use rust_decimal::prelude::ToPrimitive;
658 let range = self.high - self.low;
659 if range.is_zero() {
660 return None;
661 }
662 ((self.close - self.low) / range).to_f64()
663 }
664
665 pub fn volume_per_trade(&self) -> Option<Decimal> {
669 if self.trade_count == 0 {
670 return None;
671 }
672 Some(self.volume / Decimal::from(self.trade_count as u64))
673 }
674
675 pub fn price_range_overlap(&self, other: &OhlcvBar) -> bool {
679 self.high >= other.low && other.high >= self.low
680 }
681
682 pub fn bar_height_pct(&self) -> Option<f64> {
687 use rust_decimal::prelude::ToPrimitive;
688 if self.open.is_zero() {
689 return None;
690 }
691 ((self.high - self.low) / self.open).to_f64()
692 }
693
694 pub fn bar_type(&self) -> &'static str {
699 if self.close == self.open {
700 "doji"
701 } else if self.close > self.open {
702 "bullish"
703 } else {
704 "bearish"
705 }
706 }
707
708 pub fn body_pct(&self) -> Option<Decimal> {
713 let range = self.range();
714 if range.is_zero() {
715 return None;
716 }
717 Some(self.body() / range * Decimal::ONE_HUNDRED)
718 }
719
720 pub fn is_bullish_hammer(&self) -> bool {
726 let body = self.body();
727 if body.is_zero() {
728 return false;
729 }
730 let lower = self.wick_lower();
731 let upper = self.wick_upper();
732 lower >= body * Decimal::TWO && upper <= body
733 }
734
735 pub fn upper_wick_pct(&self) -> Option<Decimal> {
739 let range = self.range();
740 if range.is_zero() {
741 return None;
742 }
743 Some(self.wick_upper() / range * Decimal::ONE_HUNDRED)
744 }
745
746 pub fn lower_wick_pct(&self) -> Option<Decimal> {
750 let range = self.range();
751 if range.is_zero() {
752 return None;
753 }
754 Some(self.wick_lower() / range * Decimal::ONE_HUNDRED)
755 }
756
757 pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
761 self.is_bearish() && self.is_engulfing(prev)
762 }
763
764 pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
768 self.is_bullish() && self.is_engulfing(prev)
769 }
770
771 pub fn close_gap(&self, prev: &OhlcvBar) -> Decimal {
775 self.open - prev.close
776 }
777
778 pub fn close_above_midpoint(&self) -> bool {
780 self.close > self.high_low_midpoint()
781 }
782
783 pub fn close_momentum(&self, prev: &OhlcvBar) -> Decimal {
787 self.close - prev.close
788 }
789
790 pub fn bar_range(&self) -> Decimal {
792 self.high - self.low
793 }
794
795 pub fn bar_duration_ms(&self) -> u64 {
797 self.timeframe.duration_ms()
798 }
799
800 pub fn is_gravestone_doji(&self, epsilon: Decimal) -> bool {
805 self.body() <= epsilon && (self.close - self.low).abs() <= epsilon
806 }
807
808 pub fn is_dragonfly_doji(&self, epsilon: Decimal) -> bool {
813 self.body() <= epsilon && (self.high - self.close).abs() <= epsilon
814 }
815
816 pub fn is_flat(&self) -> bool {
818 self.open == self.close && self.high == self.low && self.open == self.high
819 }
820
821 pub fn true_range_with_prev(&self, prev_close: Decimal) -> Decimal {
825 let hl = self.high - self.low;
826 let hc = (self.high - prev_close).abs();
827 let lc = (self.low - prev_close).abs();
828 hl.max(hc).max(lc)
829 }
830
831 pub fn close_to_high_ratio(&self) -> Option<f64> {
833 use rust_decimal::prelude::ToPrimitive;
834 if self.high.is_zero() { return None; }
835 (self.close / self.high).to_f64()
836 }
837
838 pub fn close_open_ratio(&self) -> Option<f64> {
840 use rust_decimal::prelude::ToPrimitive;
841 if self.open.is_zero() { return None; }
842 (self.close / self.open).to_f64()
843 }
844
845 pub fn price_at_pct(&self, pct: f64) -> Decimal {
850 use rust_decimal::prelude::FromPrimitive;
851 let pct_clamped = pct.clamp(0.0, 1.0);
852 let factor = Decimal::from_f64(pct_clamped).unwrap_or(Decimal::ZERO);
853 self.low + self.range() * factor
854 }
855}
856
857impl std::fmt::Display for OhlcvBar {
858 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
859 write!(
860 f,
861 "{} {} [{}/{}/{}/{} v={}]",
862 self.symbol, self.timeframe, self.open, self.high, self.low, self.close, self.volume
863 )
864 }
865}
866
867pub struct OhlcvAggregator {
869 symbol: String,
870 timeframe: Timeframe,
871 current_bar: Option<OhlcvBar>,
872 last_bar: Option<OhlcvBar>,
874 emit_empty_bars: bool,
878 bars_emitted: u64,
880 price_volume_sum: Decimal,
882 total_volume: Decimal,
884 peak_volume: Option<Decimal>,
886 min_volume: Option<Decimal>,
888}
889
890impl OhlcvAggregator {
891 pub fn new(symbol: impl Into<String>, timeframe: Timeframe) -> Result<Self, StreamError> {
896 let tf_dur = timeframe.duration_ms();
897 if tf_dur == 0 {
898 return Err(StreamError::ConfigError {
899 reason: "OhlcvAggregator timeframe duration must be > 0".into(),
900 });
901 }
902 Ok(Self {
903 symbol: symbol.into(),
904 timeframe,
905 current_bar: None,
906 last_bar: None,
907 emit_empty_bars: false,
908 bars_emitted: 0,
909 price_volume_sum: Decimal::ZERO,
910 total_volume: Decimal::ZERO,
911 peak_volume: None,
912 min_volume: None,
913 })
914 }
915
916 pub fn with_emit_empty_bars(mut self, enabled: bool) -> Self {
918 self.emit_empty_bars = enabled;
919 self
920 }
921
922 #[must_use = "completed bars are returned; ignoring them loses bar data"]
931 #[inline]
932 pub fn feed(&mut self, tick: &NormalizedTick) -> Result<Vec<OhlcvBar>, StreamError> {
933 if tick.symbol != self.symbol {
934 return Err(StreamError::AggregationError {
935 reason: format!(
936 "tick symbol '{}' does not match aggregator '{}'",
937 tick.symbol, self.symbol
938 ),
939 });
940 }
941
942 let tick_ts = tick.exchange_ts_ms.unwrap_or(tick.received_at_ms);
944 let bar_start = self.timeframe.bar_start_ms(tick_ts);
945 let mut emitted: Vec<OhlcvBar> = Vec::new();
946
947 let bar_window_changed = self
949 .current_bar
950 .as_ref()
951 .map(|b| b.bar_start_ms != bar_start)
952 .unwrap_or(false);
953
954 if bar_window_changed {
955 let mut completed = self.current_bar.take().unwrap_or_else(|| unreachable!());
957 completed.is_complete = true;
958 let prev_close = completed.close;
959 let prev_start = completed.bar_start_ms;
960 emitted.push(completed);
961
962 if self.emit_empty_bars {
964 let dur = self.timeframe.duration_ms();
965 let mut gap_start = prev_start + dur;
966 while gap_start < bar_start {
967 emitted.push(OhlcvBar {
968 symbol: self.symbol.clone(),
969 timeframe: self.timeframe,
970 bar_start_ms: gap_start,
971 open: prev_close,
972 high: prev_close,
973 low: prev_close,
974 close: prev_close,
975 volume: Decimal::ZERO,
976 trade_count: 0,
977 is_complete: true,
978 is_gap_fill: true,
979 vwap: None,
980 });
981 gap_start += dur;
982 }
983 }
984 }
985
986 let tick_value = tick.price * tick.quantity;
988 if self.current_bar.is_some() {
989 self.price_volume_sum += tick_value;
990 } else {
991 self.price_volume_sum = tick_value;
992 }
993
994 match &mut self.current_bar {
995 Some(bar) => {
996 if tick.price > bar.high {
997 bar.high = tick.price;
998 }
999 if tick.price < bar.low {
1000 bar.low = tick.price;
1001 }
1002 bar.close = tick.price;
1003 bar.volume += tick.quantity;
1004 bar.trade_count += 1;
1005 bar.vwap = if bar.volume.is_zero() {
1006 None
1007 } else {
1008 Some(self.price_volume_sum / bar.volume)
1009 };
1010 }
1011 None => {
1012 self.current_bar = Some(OhlcvBar {
1013 symbol: self.symbol.clone(),
1014 timeframe: self.timeframe,
1015 bar_start_ms: bar_start,
1016 open: tick.price,
1017 high: tick.price,
1018 low: tick.price,
1019 close: tick.price,
1020 volume: tick.quantity,
1021 trade_count: 1,
1022 is_complete: false,
1023 is_gap_fill: false,
1024 vwap: Some(tick.price), });
1026 }
1027 }
1028 self.bars_emitted += emitted.len() as u64;
1029 for b in &emitted {
1030 self.total_volume += b.volume;
1031 self.peak_volume = Some(match self.peak_volume {
1032 Some(prev) => prev.max(b.volume),
1033 None => b.volume,
1034 });
1035 self.min_volume = Some(match self.min_volume {
1036 Some(prev) => prev.min(b.volume),
1037 None => b.volume,
1038 });
1039 }
1040 if let Some(b) = emitted.last() {
1041 self.last_bar = Some(b.clone());
1042 }
1043 Ok(emitted)
1044 }
1045
1046 pub fn current_bar(&self) -> Option<&OhlcvBar> {
1048 self.current_bar.as_ref()
1049 }
1050
1051 #[must_use = "the flushed bar is returned; ignoring it loses the partial bar"]
1053 pub fn flush(&mut self) -> Option<OhlcvBar> {
1054 let mut bar = self.current_bar.take()?;
1055 bar.is_complete = true;
1056 self.bars_emitted += 1;
1057 self.total_volume += bar.volume;
1058 self.peak_volume = Some(match self.peak_volume {
1059 Some(prev) => prev.max(bar.volume),
1060 None => bar.volume,
1061 });
1062 self.min_volume = Some(match self.min_volume {
1063 Some(prev) => prev.min(bar.volume),
1064 None => bar.volume,
1065 });
1066 self.last_bar = Some(bar.clone());
1067 Some(bar)
1068 }
1069
1070 pub fn last_bar(&self) -> Option<&OhlcvBar> {
1075 self.last_bar.as_ref()
1076 }
1077
1078 pub fn bar_count(&self) -> u64 {
1080 self.bars_emitted
1081 }
1082
1083 pub fn reset(&mut self) {
1088 self.current_bar = None;
1089 self.last_bar = None;
1090 self.bars_emitted = 0;
1091 self.price_volume_sum = Decimal::ZERO;
1092 self.total_volume = Decimal::ZERO;
1093 self.peak_volume = None;
1094 self.min_volume = None;
1095 }
1096
1097 pub fn total_volume(&self) -> Decimal {
1102 self.total_volume
1103 }
1104
1105 pub fn peak_volume(&self) -> Option<Decimal> {
1110 self.peak_volume
1111 }
1112
1113 pub fn min_volume(&self) -> Option<Decimal> {
1118 self.min_volume
1119 }
1120
1121 pub fn volume_range(&self) -> Option<(Decimal, Decimal)> {
1126 Some((self.min_volume?, self.peak_volume?))
1127 }
1128
1129 pub fn average_volume(&self) -> Option<Decimal> {
1133 if self.bars_emitted == 0 {
1134 return None;
1135 }
1136 Some(self.total_volume / Decimal::from(self.bars_emitted))
1137 }
1138
1139 pub fn symbol(&self) -> &str {
1141 &self.symbol
1142 }
1143
1144 pub fn timeframe(&self) -> Timeframe {
1146 self.timeframe
1147 }
1148
1149 pub fn window_progress(&self, now_ms: u64) -> Option<f64> {
1155 let bar = self.current_bar.as_ref()?;
1156 let elapsed = now_ms.saturating_sub(bar.bar_start_ms);
1157 let duration = self.timeframe.duration_ms();
1158 let progress = elapsed as f64 / duration as f64;
1159 Some(progress.clamp(0.0, 1.0))
1160 }
1161
1162 pub fn is_active(&self) -> bool {
1165 self.current_bar.is_some()
1166 }
1167
1168 pub fn vwap_current(&self) -> Option<Decimal> {
1173 let bar = self.current_bar.as_ref()?;
1174 if bar.volume.is_zero() {
1175 return None;
1176 }
1177 Some(self.price_volume_sum / bar.volume)
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184 use crate::tick::{Exchange, NormalizedTick, TradeSide};
1185 use rust_decimal_macros::dec;
1186
1187 fn make_tick(symbol: &str, price: Decimal, qty: Decimal, ts_ms: u64) -> NormalizedTick {
1188 NormalizedTick {
1189 exchange: Exchange::Binance,
1190 symbol: symbol.to_string(),
1191 price,
1192 quantity: qty,
1193 side: Some(TradeSide::Buy),
1194 trade_id: None,
1195 exchange_ts_ms: None,
1196 received_at_ms: ts_ms,
1197 }
1198 }
1199
1200 fn make_tick_with_exchange_ts(
1201 symbol: &str,
1202 price: Decimal,
1203 qty: Decimal,
1204 exchange_ts_ms: u64,
1205 received_at_ms: u64,
1206 ) -> NormalizedTick {
1207 NormalizedTick {
1208 exchange: Exchange::Binance,
1209 symbol: symbol.to_string(),
1210 price,
1211 quantity: qty,
1212 side: Some(TradeSide::Buy),
1213 trade_id: None,
1214 exchange_ts_ms: Some(exchange_ts_ms),
1215 received_at_ms,
1216 }
1217 }
1218
1219 fn agg(symbol: &str, tf: Timeframe) -> OhlcvAggregator {
1220 OhlcvAggregator::new(symbol, tf).unwrap()
1221 }
1222
1223 #[test]
1224 fn test_timeframe_seconds_duration_ms() {
1225 assert_eq!(Timeframe::Seconds(30).duration_ms(), 30_000);
1226 }
1227
1228 #[test]
1229 fn test_timeframe_minutes_duration_ms() {
1230 assert_eq!(Timeframe::Minutes(5).duration_ms(), 300_000);
1231 }
1232
1233 #[test]
1234 fn test_timeframe_hours_duration_ms() {
1235 assert_eq!(Timeframe::Hours(1).duration_ms(), 3_600_000);
1236 }
1237
1238 #[test]
1239 fn test_timeframe_bar_start_ms_aligns() {
1240 let tf = Timeframe::Minutes(1);
1241 let ts = 61_500; assert_eq!(tf.bar_start_ms(ts), 60_000);
1243 }
1244
1245 #[test]
1246 fn test_timeframe_display() {
1247 assert_eq!(Timeframe::Seconds(30).to_string(), "30s");
1248 assert_eq!(Timeframe::Minutes(5).to_string(), "5m");
1249 assert_eq!(Timeframe::Hours(4).to_string(), "4h");
1250 }
1251
1252 #[test]
1253 fn test_ohlcv_aggregator_first_tick_sets_ohlcv() {
1254 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1255 let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 60_000);
1256 let result = agg.feed(&tick).unwrap();
1257 assert!(result.is_empty()); let bar = agg.current_bar().unwrap();
1259 assert_eq!(bar.open, dec!(50000));
1260 assert_eq!(bar.high, dec!(50000));
1261 assert_eq!(bar.low, dec!(50000));
1262 assert_eq!(bar.close, dec!(50000));
1263 assert_eq!(bar.volume, dec!(1));
1264 assert_eq!(bar.trade_count, 1);
1265 }
1266
1267 #[test]
1268 fn test_ohlcv_aggregator_high_low_tracking() {
1269 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1270 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1271 .unwrap();
1272 agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1273 .unwrap();
1274 agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
1275 .unwrap();
1276 let bar = agg.current_bar().unwrap();
1277 assert_eq!(bar.high, dec!(51000));
1278 assert_eq!(bar.low, dec!(49500));
1279 assert_eq!(bar.close, dec!(49500));
1280 assert_eq!(bar.trade_count, 3);
1281 }
1282
1283 #[test]
1284 fn test_ohlcv_aggregator_bar_completes_on_new_window() {
1285 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1286 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1287 .unwrap();
1288 agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2), 60_500))
1289 .unwrap();
1290 let mut bars = agg
1292 .feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
1293 .unwrap();
1294 assert_eq!(bars.len(), 1);
1295 let bar = bars.remove(0);
1296 assert!(bar.is_complete);
1297 assert_eq!(bar.open, dec!(50000));
1298 assert_eq!(bar.close, dec!(50100));
1299 assert_eq!(bar.volume, dec!(3));
1300 assert_eq!(bar.bar_start_ms, 60_000);
1301 }
1302
1303 #[test]
1304 fn test_ohlcv_aggregator_new_bar_started_after_completion() {
1305 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1306 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1307 .unwrap();
1308 agg.feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
1309 .unwrap();
1310 let bar = agg.current_bar().unwrap();
1311 assert_eq!(bar.open, dec!(50200));
1312 assert_eq!(bar.bar_start_ms, 120_000);
1313 }
1314
1315 #[test]
1316 fn test_ohlcv_aggregator_flush_marks_complete() {
1317 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1318 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1319 .unwrap();
1320 let flushed = agg.flush().unwrap();
1321 assert!(flushed.is_complete);
1322 assert!(agg.current_bar().is_none());
1323 }
1324
1325 #[test]
1326 fn test_ohlcv_aggregator_flush_empty_returns_none() {
1327 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1328 assert!(agg.flush().is_none());
1329 }
1330
1331 #[test]
1332 fn test_ohlcv_aggregator_wrong_symbol_returns_error() {
1333 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1334 let tick = make_tick("ETH-USD", dec!(3000), dec!(1), 60_000);
1335 let result = agg.feed(&tick);
1336 assert!(matches!(result, Err(StreamError::AggregationError { .. })));
1337 }
1338
1339 #[test]
1340 fn test_ohlcv_aggregator_volume_accumulates() {
1341 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1342 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1.5), 60_000))
1343 .unwrap();
1344 agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2.5), 60_100))
1345 .unwrap();
1346 let bar = agg.current_bar().unwrap();
1347 assert_eq!(bar.volume, dec!(4));
1348 }
1349
1350 #[test]
1351 fn test_ohlcv_bar_symbol_and_timeframe() {
1352 let mut agg = agg("BTC-USD", Timeframe::Minutes(5));
1353 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 300_000))
1354 .unwrap();
1355 let bar = agg.current_bar().unwrap();
1356 assert_eq!(bar.symbol, "BTC-USD");
1357 assert_eq!(bar.timeframe, Timeframe::Minutes(5));
1358 }
1359
1360 #[test]
1361 fn test_ohlcv_aggregator_symbol_accessor() {
1362 let agg = agg("ETH-USD", Timeframe::Hours(1));
1363 assert_eq!(agg.symbol(), "ETH-USD");
1364 assert_eq!(agg.timeframe(), Timeframe::Hours(1));
1365 }
1366
1367 #[test]
1368 fn test_bar_aligned_by_exchange_ts_not_received_ts() {
1369 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1373 let tick = make_tick_with_exchange_ts("BTC-USD", dec!(50000), dec!(1), 60_500, 120_100);
1374 agg.feed(&tick).unwrap();
1375 let bar = agg.current_bar().unwrap();
1376 assert_eq!(bar.bar_start_ms, 60_000, "bar should use exchange_ts_ms");
1377 }
1378
1379 #[test]
1380 fn test_bar_falls_back_to_received_ts_when_no_exchange_ts() {
1381 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1382 let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 75_000);
1383 agg.feed(&tick).unwrap();
1384 let bar = agg.current_bar().unwrap();
1385 assert_eq!(bar.bar_start_ms, 60_000);
1386 }
1387
1388 #[test]
1391 fn test_emit_empty_bars_no_gap_no_empties() {
1392 let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1394 .unwrap()
1395 .with_emit_empty_bars(true);
1396 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1397 .unwrap();
1398 let bars = agg
1399 .feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
1400 .unwrap();
1401 assert_eq!(bars.len(), 1);
1403 assert_eq!(bars[0].bar_start_ms, 60_000);
1404 assert_eq!(bars[0].volume, dec!(1));
1405 }
1406
1407 #[test]
1408 fn test_emit_empty_bars_two_skipped_windows() {
1409 let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1412 .unwrap()
1413 .with_emit_empty_bars(true);
1414 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1415 .unwrap();
1416 let bars = agg
1417 .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1418 .unwrap();
1419 assert_eq!(bars.len(), 3);
1421 assert_eq!(bars[0].bar_start_ms, 60_000);
1422 assert!(!bars[0].volume.is_zero()); assert_eq!(bars[1].bar_start_ms, 120_000);
1424 assert!(bars[1].volume.is_zero()); assert_eq!(bars[1].trade_count, 0);
1426 assert_eq!(bars[1].open, dec!(50000)); assert_eq!(bars[2].bar_start_ms, 180_000);
1428 assert!(bars[2].volume.is_zero()); }
1430
1431 #[test]
1432 fn test_emit_empty_bars_disabled_no_empties_on_gap() {
1433 let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1434 .unwrap()
1435 .with_emit_empty_bars(false);
1436 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1437 .unwrap();
1438 let bars = agg
1439 .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1440 .unwrap();
1441 assert_eq!(bars.len(), 1); }
1443
1444 #[test]
1445 fn test_emit_empty_bars_is_complete_true() {
1446 let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1447 .unwrap()
1448 .with_emit_empty_bars(true);
1449 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1450 .unwrap();
1451 let bars = agg
1452 .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1453 .unwrap();
1454 for bar in &bars {
1455 assert!(bar.is_complete, "all emitted bars must be marked complete");
1456 }
1457 }
1458
1459 #[test]
1460 fn test_ohlcv_bar_display() {
1461 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1462 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1463 .unwrap();
1464 let bar = agg.current_bar().unwrap();
1465 let s = bar.to_string();
1466 assert!(s.contains("BTC-USD"));
1467 assert!(s.contains("1m"));
1468 assert!(s.contains("50000"));
1469 }
1470
1471 #[test]
1472 fn test_bar_count_increments_on_feed() {
1473 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1474 assert_eq!(agg.bar_count(), 0);
1475 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1476 .unwrap();
1477 agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
1478 .unwrap();
1479 assert_eq!(agg.bar_count(), 1);
1480 }
1481
1482 #[test]
1483 fn test_bar_count_increments_on_flush() {
1484 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1485 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1486 .unwrap();
1487 agg.flush().unwrap();
1488 assert_eq!(agg.bar_count(), 1);
1489 }
1490
1491 #[test]
1492 fn test_ohlcv_bar_range() {
1493 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1494 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1495 .unwrap();
1496 agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1497 .unwrap();
1498 agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
1499 .unwrap();
1500 let bar = agg.current_bar().unwrap();
1501 assert_eq!(bar.range(), dec!(1500)); }
1503
1504 #[test]
1505 fn test_ohlcv_bar_body_bullish() {
1506 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1507 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1508 .unwrap();
1509 agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_100))
1510 .unwrap();
1511 let bar = agg.current_bar().unwrap();
1512 assert_eq!(bar.body(), dec!(500));
1514 }
1515
1516 #[test]
1517 fn test_ohlcv_bar_body_bearish() {
1518 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1519 agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_000))
1520 .unwrap();
1521 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
1522 .unwrap();
1523 let bar = agg.current_bar().unwrap();
1524 assert_eq!(bar.body(), dec!(500));
1526 }
1527
1528 #[test]
1529 fn test_aggregator_reset_clears_bar_and_count() {
1530 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1531 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1532 .unwrap();
1533 agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
1534 .unwrap();
1535 assert_eq!(agg.bar_count(), 1);
1536 assert!(agg.current_bar().is_some());
1537 agg.reset();
1538 assert_eq!(agg.bar_count(), 0);
1539 assert!(agg.current_bar().is_none());
1540 }
1541
1542 #[test]
1543 fn test_ohlcv_bar_is_bullish_when_close_gt_open() {
1544 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1545 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1546 .unwrap();
1547 agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1548 .unwrap();
1549 let bar = agg.current_bar().unwrap();
1550 assert!(bar.is_bullish());
1551 assert!(!bar.is_bearish());
1552 }
1553
1554 #[test]
1555 fn test_ohlcv_bar_is_bearish_when_close_lt_open() {
1556 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1557 agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_000))
1558 .unwrap();
1559 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
1560 .unwrap();
1561 let bar = agg.current_bar().unwrap();
1562 assert!(bar.is_bearish());
1563 assert!(!bar.is_bullish());
1564 }
1565
1566 #[test]
1567 fn test_ohlcv_bar_neither_bullish_nor_bearish_on_equal_open_close() {
1568 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1569 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1570 .unwrap();
1571 let bar = agg.current_bar().unwrap();
1573 assert!(!bar.is_bullish());
1574 assert!(!bar.is_bearish());
1575 }
1576
1577 #[test]
1578 fn test_ohlcv_bar_vwap_single_tick_equals_price() {
1579 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1580 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(2), 60_000))
1581 .unwrap();
1582 let bar = agg.current_bar().unwrap();
1583 assert_eq!(bar.vwap, Some(dec!(50000)));
1584 }
1585
1586 #[test]
1587 fn test_ohlcv_bar_vwap_two_equal_price_ticks() {
1588 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1589 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1590 .unwrap();
1591 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(3), 60_100))
1592 .unwrap();
1593 let bar = agg.current_bar().unwrap();
1594 assert_eq!(bar.vwap, Some(dec!(50000)));
1596 }
1597
1598 #[test]
1599 fn test_ohlcv_bar_vwap_two_different_price_ticks() {
1600 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1601 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1602 .unwrap();
1603 agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1604 .unwrap();
1605 let bar = agg.current_bar().unwrap();
1606 assert_eq!(bar.vwap, Some(dec!(50500)));
1608 }
1609
1610 #[test]
1611 fn test_ohlcv_bar_vwap_gap_fill_is_none() {
1612 let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1613 .unwrap()
1614 .with_emit_empty_bars(true);
1615 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1616 .unwrap();
1617 let bars = agg
1618 .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1619 .unwrap();
1620 assert!(bars[0].vwap.is_some());
1622 assert!(bars[1].vwap.is_none());
1623 assert!(bars[2].vwap.is_none());
1624 }
1625
1626 #[test]
1627 fn test_aggregator_reset_allows_fresh_start() {
1628 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1629 agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1630 .unwrap();
1631 agg.reset();
1632 agg.feed(&make_tick("BTC-USD", dec!(99999), dec!(2), 60_000))
1633 .unwrap();
1634 let bar = agg.current_bar().unwrap();
1635 assert_eq!(bar.open, dec!(99999));
1636 }
1637
1638 #[test]
1641 fn test_from_duration_ms_hours() {
1642 assert_eq!(Timeframe::from_duration_ms(3_600_000), Some(Timeframe::Hours(1)));
1643 assert_eq!(Timeframe::from_duration_ms(7_200_000), Some(Timeframe::Hours(2)));
1644 }
1645
1646 #[test]
1647 fn test_from_duration_ms_minutes() {
1648 assert_eq!(Timeframe::from_duration_ms(300_000), Some(Timeframe::Minutes(5)));
1649 assert_eq!(Timeframe::from_duration_ms(60_000), Some(Timeframe::Minutes(1)));
1650 }
1651
1652 #[test]
1653 fn test_from_duration_ms_seconds() {
1654 assert_eq!(Timeframe::from_duration_ms(15_000), Some(Timeframe::Seconds(15)));
1655 assert_eq!(Timeframe::from_duration_ms(1_000), Some(Timeframe::Seconds(1)));
1656 }
1657
1658 #[test]
1659 fn test_from_duration_ms_zero_returns_none() {
1660 assert_eq!(Timeframe::from_duration_ms(0), None);
1661 }
1662
1663 #[test]
1664 fn test_from_duration_ms_non_whole_second_returns_none() {
1665 assert_eq!(Timeframe::from_duration_ms(1_500), None);
1666 }
1667
1668 #[test]
1669 fn test_from_duration_ms_roundtrip() {
1670 for tf in [Timeframe::Seconds(30), Timeframe::Minutes(5), Timeframe::Hours(4)] {
1671 assert_eq!(Timeframe::from_duration_ms(tf.duration_ms()), Some(tf));
1672 }
1673 }
1674
1675 #[test]
1678 fn test_is_doji_exact_zero_body() {
1679 let bar = OhlcvBar {
1680 symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1681 bar_start_ms: 0, open: dec!(100), high: dec!(105),
1682 low: dec!(95), close: dec!(100),
1683 volume: dec!(1), trade_count: 1, is_complete: true,
1684 is_gap_fill: false, vwap: None,
1685 };
1686 assert!(bar.is_doji(Decimal::ZERO));
1687 }
1688
1689 #[test]
1690 fn test_is_doji_small_epsilon() {
1691 let bar = OhlcvBar {
1692 symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1693 bar_start_ms: 0, open: dec!(100), high: dec!(105),
1694 low: dec!(95), close: dec!(100.005),
1695 volume: dec!(1), trade_count: 1, is_complete: true,
1696 is_gap_fill: false, vwap: None,
1697 };
1698 assert!(bar.is_doji(dec!(0.01)));
1699 assert!(!bar.is_doji(Decimal::ZERO));
1700 }
1701
1702 #[test]
1703 fn test_wick_upper_bullish() {
1704 let bar = OhlcvBar {
1706 symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1707 bar_start_ms: 0, open: dec!(100), high: dec!(107),
1708 low: dec!(98), close: dec!(104),
1709 volume: dec!(1), trade_count: 1, is_complete: true,
1710 is_gap_fill: false, vwap: None,
1711 };
1712 assert_eq!(bar.wick_upper(), dec!(3));
1713 }
1714
1715 #[test]
1716 fn test_wick_lower_bearish() {
1717 let bar = OhlcvBar {
1719 symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1720 bar_start_ms: 0, open: dec!(104), high: dec!(107),
1721 low: dec!(97), close: dec!(100),
1722 volume: dec!(1), trade_count: 1, is_complete: true,
1723 is_gap_fill: false, vwap: None,
1724 };
1725 assert_eq!(bar.wick_lower(), dec!(3));
1726 }
1727
1728 #[test]
1731 fn test_window_progress_none_when_no_bar() {
1732 let agg = agg("BTC-USD", Timeframe::Minutes(1));
1733 assert!(agg.window_progress(60_000).is_none());
1734 }
1735
1736 #[test]
1737 fn test_window_progress_at_start_is_zero() {
1738 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1739 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1741 assert_eq!(agg.window_progress(60_000), Some(0.0));
1742 }
1743
1744 #[test]
1745 fn test_window_progress_midpoint() {
1746 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1747 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1748 let progress = agg.window_progress(90_000).unwrap();
1750 assert!((progress - 0.5).abs() < 1e-9);
1751 }
1752
1753 #[test]
1754 fn test_window_progress_clamps_at_one() {
1755 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1756 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1757 assert_eq!(agg.window_progress(150_000), Some(1.0));
1759 }
1760
1761 #[test]
1764 fn test_price_change_bullish_is_positive() {
1765 let bar = make_bar(dec!(100), dec!(110), dec!(98), dec!(105));
1766 assert_eq!(bar.price_change(), dec!(5));
1767 }
1768
1769 #[test]
1770 fn test_price_change_bearish_is_negative() {
1771 let bar = make_bar(dec!(105), dec!(110), dec!(98), dec!(100));
1772 assert_eq!(bar.price_change(), dec!(-5));
1773 }
1774
1775 #[test]
1776 fn test_price_change_doji_is_zero() {
1777 let bar = make_bar(dec!(100), dec!(102), dec!(98), dec!(100));
1778 assert_eq!(bar.price_change(), dec!(0));
1779 }
1780
1781 #[test]
1784 fn test_total_volume_zero_before_completion() {
1785 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1786 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
1787 assert_eq!(agg.total_volume(), dec!(0));
1789 }
1790
1791 #[test]
1792 fn test_total_volume_accumulates_across_bars() {
1793 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1794 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
1796 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
1798 assert_eq!(agg.total_volume(), dec!(2));
1800 agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
1802 assert_eq!(agg.total_volume(), dec!(5)); }
1804
1805 #[test]
1806 fn test_total_volume_reset_clears() {
1807 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1808 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
1809 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
1810 agg.reset();
1811 assert_eq!(agg.total_volume(), dec!(0));
1812 }
1813
1814 fn make_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
1817 OhlcvBar {
1818 symbol: "X".into(),
1819 timeframe: Timeframe::Minutes(1),
1820 bar_start_ms: 0,
1821 open,
1822 high,
1823 low,
1824 close,
1825 volume: dec!(1),
1826 trade_count: 1,
1827 is_complete: true,
1828 is_gap_fill: false,
1829 vwap: None,
1830 }
1831 }
1832
1833 #[test]
1834 fn test_typical_price() {
1835 let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
1837 assert_eq!(bar.typical_price(), dec!(10));
1838 }
1839
1840 #[test]
1841 fn test_median_price() {
1842 let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
1844 assert_eq!(bar.median_price(), dec!(10));
1845 }
1846
1847 #[test]
1848 fn test_typical_price_differs_from_median() {
1849 let bar = make_bar(dec!(8), dec!(10), dec!(6), dec!(10));
1851 assert_eq!(bar.median_price(), dec!(8));
1852 assert!(bar.typical_price() > bar.median_price());
1853 }
1854
1855 #[test]
1856 fn test_close_location_value_at_high() {
1857 let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(110));
1859 let clv = bar.close_location_value().unwrap();
1860 assert!((clv - 1.0).abs() < 1e-9, "expected 1.0 got {clv}");
1861 }
1862
1863 #[test]
1864 fn test_close_location_value_at_low() {
1865 let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(90));
1867 let clv = bar.close_location_value().unwrap();
1868 assert!((clv + 1.0).abs() < 1e-9, "expected -1.0 got {clv}");
1869 }
1870
1871 #[test]
1872 fn test_close_location_value_midpoint_is_zero() {
1873 let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(100));
1875 let clv = bar.close_location_value().unwrap();
1876 assert!(clv.abs() < 1e-9, "expected 0.0 got {clv}");
1877 }
1878
1879 #[test]
1880 fn test_close_location_value_zero_range_returns_none() {
1881 let bar = make_bar(dec!(100), dec!(100), dec!(100), dec!(100));
1882 assert!(bar.close_location_value().is_none());
1883 }
1884
1885 #[test]
1886 fn test_body_direction_bullish() {
1887 let bar = make_bar(dec!(90), dec!(110), dec!(85), dec!(105));
1888 assert_eq!(bar.body_direction(), BarDirection::Bullish);
1889 }
1890
1891 #[test]
1892 fn test_body_direction_bearish() {
1893 let bar = make_bar(dec!(105), dec!(110), dec!(85), dec!(90));
1894 assert_eq!(bar.body_direction(), BarDirection::Bearish);
1895 }
1896
1897 #[test]
1898 fn test_body_direction_neutral() {
1899 let bar = make_bar(dec!(100), dec!(110), dec!(85), dec!(100));
1900 assert_eq!(bar.body_direction(), BarDirection::Neutral);
1901 }
1902
1903 #[test]
1906 fn test_last_bar_none_before_completion() {
1907 let agg = agg("BTC-USD", Timeframe::Minutes(1));
1908 assert!(agg.last_bar().is_none());
1909 }
1910
1911 #[test]
1912 fn test_last_bar_set_after_bar_completion() {
1913 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1914 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1916 agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
1918 let last = agg.last_bar().unwrap();
1919 assert!(last.is_complete);
1920 assert_eq!(last.close, dec!(100));
1921 }
1922
1923 #[test]
1924 fn test_last_bar_set_after_flush() {
1925 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1926 agg.feed(&make_tick("BTC-USD", dec!(50), dec!(1), 60_000)).unwrap();
1927 let flushed = agg.flush().unwrap();
1928 assert_eq!(agg.last_bar().unwrap().close, flushed.close);
1929 }
1930
1931 #[test]
1932 fn test_last_bar_cleared_on_reset() {
1933 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1934 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1935 agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
1936 assert!(agg.last_bar().is_some());
1937 agg.reset();
1938 assert!(agg.last_bar().is_none());
1939 }
1940
1941 #[test]
1944 fn test_weighted_close_basic() {
1945 let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
1947 assert_eq!(bar.weighted_close(), dec!(10));
1948 }
1949
1950 #[test]
1951 fn test_weighted_close_weights_close_more_than_typical() {
1952 let bar = make_bar(dec!(50), dec!(100), dec!(0), dec!(80));
1954 assert_eq!(bar.weighted_close(), dec!(65));
1955 }
1956
1957 #[test]
1958 fn test_price_change_pct_bullish() {
1959 let bar = make_bar(dec!(100), dec!(115), dec!(95), dec!(110));
1961 let pct = bar.price_change_pct().unwrap();
1962 assert!((pct - 10.0).abs() < 1e-9, "expected 10.0 got {pct}");
1963 }
1964
1965 #[test]
1966 fn test_price_change_pct_bearish() {
1967 let bar = make_bar(dec!(200), dec!(210), dec!(175), dec!(180));
1969 let pct = bar.price_change_pct().unwrap();
1970 assert!((pct - (-10.0)).abs() < 1e-9, "expected -10.0 got {pct}");
1971 }
1972
1973 #[test]
1974 fn test_price_change_pct_zero_open_returns_none() {
1975 let bar = make_bar(dec!(0), dec!(5), dec!(0), dec!(3));
1976 assert!(bar.price_change_pct().is_none());
1977 }
1978
1979 #[test]
1980 fn test_wick_ratio_all_wicks() {
1981 let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
1983 let r = bar.wick_ratio().unwrap();
1984 assert!((r - 1.0).abs() < 1e-9, "expected 1.0 got {r}");
1985 }
1986
1987 #[test]
1988 fn test_wick_ratio_no_wicks() {
1989 let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
1991 let r = bar.wick_ratio().unwrap();
1992 assert!((r - 0.0).abs() < 1e-9, "expected 0.0 got {r}");
1993 }
1994
1995 #[test]
1996 fn test_wick_ratio_zero_range_returns_none() {
1997 let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
1999 assert!(bar.wick_ratio().is_none());
2000 }
2001
2002 #[test]
2005 fn test_body_ratio_no_wicks_is_one() {
2006 let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
2008 let r = bar.body_ratio().unwrap();
2009 assert!((r - 1.0).abs() < 1e-9);
2010 }
2011
2012 #[test]
2013 fn test_body_ratio_all_wicks_is_zero() {
2014 let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
2016 let r = bar.body_ratio().unwrap();
2017 assert!((r - 0.0).abs() < 1e-9);
2018 }
2019
2020 #[test]
2021 fn test_body_ratio_zero_range_returns_none() {
2022 let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
2023 assert!(bar.body_ratio().is_none());
2024 }
2025
2026 #[test]
2027 fn test_body_ratio_plus_wick_ratio_equals_one() {
2028 let bar = make_bar(dec!(4), dec!(10), dec!(0), dec!(8));
2030 let body = bar.body_ratio().unwrap();
2031 let wick = bar.wick_ratio().unwrap();
2032 assert!((body + wick - 1.0).abs() < 1e-9);
2033 }
2034
2035 #[test]
2038 fn test_average_volume_none_before_bars() {
2039 let agg = agg("BTC-USD", Timeframe::Minutes(1));
2040 assert!(agg.average_volume().is_none());
2041 }
2042
2043 #[test]
2044 fn test_average_volume_one_bar() {
2045 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2046 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
2047 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2048 assert_eq!(agg.average_volume(), Some(dec!(4)));
2050 }
2051
2052 #[test]
2053 fn test_average_volume_two_bars() {
2054 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2055 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
2056 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(6), 120_000)).unwrap();
2057 agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
2058 assert_eq!(agg.average_volume(), Some(dec!(5)));
2060 }
2061
2062 #[test]
2065 fn test_true_range_no_gap() {
2066 let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
2068 assert_eq!(bar.true_range(dec!(10)), dec!(4));
2069 }
2070
2071 #[test]
2072 fn test_true_range_gap_up() {
2073 let bar = make_bar(dec!(12), dec!(15), dec!(12), dec!(13));
2075 assert_eq!(bar.true_range(dec!(10)), dec!(5));
2076 }
2077
2078 #[test]
2079 fn test_true_range_gap_down() {
2080 let bar = make_bar(dec!(7), dec!(8), dec!(5), dec!(6));
2082 assert_eq!(bar.true_range(dec!(12)), dec!(7));
2083 }
2084
2085 #[test]
2086 fn test_inside_bar_true_when_contained() {
2087 let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
2088 let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
2089 assert!(curr.inside_bar(&prev));
2090 }
2091
2092 #[test]
2093 fn test_inside_bar_false_when_not_contained() {
2094 let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
2095 let curr = make_bar(dec!(10), dec!(16), dec!(6), dec!(11));
2096 assert!(!curr.inside_bar(&prev));
2097 }
2098
2099 #[test]
2100 fn test_outside_bar_true_when_engulfing() {
2101 let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
2102 let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
2103 assert!(curr.outside_bar(&prev));
2104 }
2105
2106 #[test]
2107 fn test_outside_bar_false_when_not_engulfing() {
2108 let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
2109 let curr = make_bar(dec!(10), dec!(11), dec!(9), dec!(10));
2110 assert!(!curr.outside_bar(&prev));
2111 }
2112
2113 #[test]
2116 fn test_is_hammer_classic() {
2117 let bar = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
2120 assert!(bar.is_hammer());
2121 }
2122
2123 #[test]
2124 fn test_is_hammer_false_large_upper_wick() {
2125 let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
2127 assert!(!bar.is_hammer());
2128 }
2129
2130 #[test]
2131 fn test_is_hammer_false_zero_range() {
2132 let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
2133 assert!(!bar.is_hammer());
2134 }
2135
2136 #[test]
2139 fn test_peak_volume_none_before_completion() {
2140 let agg = agg("BTC-USD", Timeframe::Minutes(1));
2141 assert!(agg.peak_volume().is_none());
2142 }
2143
2144 #[test]
2145 fn test_peak_volume_tracks_maximum() {
2146 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2147 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
2149 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
2151 assert_eq!(agg.peak_volume(), Some(dec!(3)));
2152 agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
2154 assert_eq!(agg.peak_volume(), Some(dec!(10)));
2155 }
2156
2157 #[test]
2158 fn test_peak_volume_reset_clears() {
2159 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2160 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
2161 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2162 agg.reset();
2163 assert!(agg.peak_volume().is_none());
2164 }
2165
2166 #[test]
2167 fn test_peak_volume_via_flush() {
2168 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2169 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(7), 60_000)).unwrap();
2170 agg.flush();
2171 assert_eq!(agg.peak_volume(), Some(dec!(7)));
2172 }
2173
2174 #[test]
2177 fn test_is_shooting_star_classic() {
2178 let bar = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
2181 assert!(bar.is_shooting_star());
2182 }
2183
2184 #[test]
2185 fn test_is_shooting_star_false_large_lower_wick() {
2186 let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
2188 assert!(!bar.is_shooting_star());
2189 }
2190
2191 #[test]
2192 fn test_is_shooting_star_false_zero_range() {
2193 let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
2194 assert!(!bar.is_shooting_star());
2195 }
2196
2197 #[test]
2198 fn test_hammer_and_shooting_star_are_mutually_exclusive_for_typical_bars() {
2199 let hammer = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
2201 let star = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
2203 assert!(hammer.is_hammer() && !hammer.is_shooting_star());
2204 assert!(star.is_shooting_star() && !star.is_hammer());
2205 }
2206
2207 #[test]
2210 fn test_min_volume_none_before_completion() {
2211 let agg = agg("BTC-USD", Timeframe::Minutes(1));
2212 assert!(agg.min_volume().is_none());
2213 }
2214
2215 #[test]
2216 fn test_min_volume_tracks_minimum() {
2217 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2218 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(10), 60_000)).unwrap();
2220 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2221 assert_eq!(agg.min_volume(), Some(dec!(10)));
2222 agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
2224 assert_eq!(agg.min_volume(), Some(dec!(1)));
2225 }
2226
2227 #[test]
2228 fn test_min_volume_reset_clears() {
2229 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2230 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
2231 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2232 agg.reset();
2233 assert!(agg.min_volume().is_none());
2234 }
2235
2236 #[test]
2239 fn test_is_gap_up_true() {
2240 let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2241 let curr = make_bar(dec!(9), dec!(12), dec!(8), dec!(11)); assert!(curr.is_gap_up(&prev));
2243 }
2244
2245 #[test]
2246 fn test_is_gap_up_false_when_equal() {
2247 let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2248 let curr = make_bar(dec!(8), dec!(12), dec!(7), dec!(11)); assert!(!curr.is_gap_up(&prev));
2250 }
2251
2252 #[test]
2253 fn test_is_gap_down_true() {
2254 let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2255 let curr = make_bar(dec!(7), dec!(8), dec!(6), dec!(7)); assert!(curr.is_gap_down(&prev));
2257 }
2258
2259 #[test]
2260 fn test_is_gap_down_false_when_equal() {
2261 let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2262 let curr = make_bar(dec!(8), dec!(9), dec!(7), dec!(8)); assert!(!curr.is_gap_down(&prev));
2264 }
2265
2266 #[test]
2269 fn test_volume_range_none_before_completion() {
2270 let agg = agg("BTC-USD", Timeframe::Minutes(1));
2271 assert!(agg.volume_range().is_none());
2272 }
2273
2274 #[test]
2275 fn test_volume_range_after_two_bars() {
2276 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2277 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
2278 agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
2279 agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
2280 assert_eq!(agg.volume_range(), Some((dec!(3), dec!(10))));
2282 }
2283
2284 fn make_ohlcv_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
2287 OhlcvBar {
2288 symbol: "X".into(),
2289 timeframe: Timeframe::Minutes(1),
2290 open,
2291 high,
2292 low,
2293 close,
2294 volume: dec!(1),
2295 bar_start_ms: 0,
2296 trade_count: 1,
2297 is_complete: false,
2298 is_gap_fill: false,
2299 vwap: None,
2300 }
2301 }
2302
2303 #[test]
2304 fn test_body_to_range_ratio_bullish_full_body() {
2305 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2307 assert_eq!(bar.body_to_range_ratio(), Some(dec!(1)));
2308 }
2309
2310 #[test]
2311 fn test_body_to_range_ratio_doji_like() {
2312 let bar = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
2314 assert_eq!(bar.body_to_range_ratio(), Some(dec!(0)));
2315 }
2316
2317 #[test]
2318 fn test_body_to_range_ratio_none_when_range_zero() {
2319 let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
2320 assert!(bar.body_to_range_ratio().is_none());
2321 }
2322
2323 #[test]
2326 fn test_is_active_false_before_any_ticks() {
2327 let agg = agg("BTC-USD", Timeframe::Minutes(1));
2328 assert!(!agg.is_active());
2329 }
2330
2331 #[test]
2332 fn test_is_active_true_after_first_tick() {
2333 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2334 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
2335 assert!(agg.is_active());
2336 }
2337
2338 #[test]
2339 fn test_is_active_false_after_flush() {
2340 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2341 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
2342 agg.flush();
2343 assert!(!agg.is_active());
2344 }
2345
2346 #[test]
2349 fn test_is_long_upper_wick_true_when_upper_wick_dominates() {
2350 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(101));
2352 assert!(bar.is_long_upper_wick());
2353 }
2354
2355 #[test]
2356 fn test_is_long_upper_wick_false_for_full_body() {
2357 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2359 assert!(!bar.is_long_upper_wick());
2360 }
2361
2362 #[test]
2363 fn test_is_long_upper_wick_false_when_equal() {
2364 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(105));
2366 assert!(!bar.is_long_upper_wick());
2367 }
2368
2369 #[test]
2372 fn test_price_change_abs_bullish_bar() {
2373 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
2374 assert_eq!(bar.price_change_abs(), dec!(8));
2375 }
2376
2377 #[test]
2378 fn test_price_change_abs_bearish_bar() {
2379 let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(102));
2380 assert_eq!(bar.price_change_abs(), dec!(8));
2381 }
2382
2383 #[test]
2384 fn test_price_change_abs_doji_zero() {
2385 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2386 assert_eq!(bar.price_change_abs(), dec!(0));
2387 }
2388
2389 #[test]
2392 fn test_vwap_current_none_before_any_ticks() {
2393 let agg = agg("BTC-USD", Timeframe::Minutes(1));
2394 assert!(agg.vwap_current().is_none());
2395 }
2396
2397 #[test]
2398 fn test_vwap_current_equals_price_for_single_tick() {
2399 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2400 agg.feed(&make_tick("BTC-USD", dec!(200), dec!(5), 1_000)).unwrap();
2401 assert_eq!(agg.vwap_current(), Some(dec!(200)));
2403 }
2404
2405 #[test]
2406 fn test_vwap_current_weighted_average() {
2407 let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2408 agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
2409 agg.feed(&make_tick("BTC-USD", dec!(200), dec!(3), 2_000)).unwrap();
2410 assert_eq!(agg.vwap_current(), Some(dec!(175)));
2412 }
2413
2414 fn bar(o: i64, h: i64, l: i64, c: i64) -> OhlcvBar {
2417 OhlcvBar {
2418 symbol: "X".into(),
2419 timeframe: Timeframe::Minutes(1),
2420 open: Decimal::from(o),
2421 high: Decimal::from(h),
2422 low: Decimal::from(l),
2423 close: Decimal::from(c),
2424 volume: Decimal::ZERO,
2425 bar_start_ms: 0,
2426 trade_count: 0,
2427 is_complete: false,
2428 is_gap_fill: false,
2429 vwap: None,
2430 }
2431 }
2432
2433 #[test]
2434 fn test_upper_shadow_equals_wick_upper() {
2435 let b = bar(100, 120, 90, 110);
2436 assert_eq!(b.upper_shadow(), b.wick_upper());
2437 assert_eq!(b.upper_shadow(), Decimal::from(10)); }
2439
2440 #[test]
2441 fn test_lower_shadow_equals_wick_lower() {
2442 let b = bar(100, 120, 90, 110);
2443 assert_eq!(b.lower_shadow(), b.wick_lower());
2444 assert_eq!(b.lower_shadow(), Decimal::from(10)); }
2446
2447 #[test]
2448 fn test_is_spinning_top_true_when_small_body_large_wicks() {
2449 let b = bar(100, 130, 80, 110);
2454 assert!(b.is_spinning_top(dec!(0.3)));
2455 }
2456
2457 #[test]
2458 fn test_is_spinning_top_false_when_body_too_large() {
2459 let b = bar(80, 130, 80, 120);
2461 assert!(!b.is_spinning_top(dec!(0.3)));
2462 }
2463
2464 #[test]
2465 fn test_is_spinning_top_false_when_zero_range() {
2466 let b = bar(100, 100, 100, 100);
2467 assert!(!b.is_spinning_top(dec!(0.3)));
2468 }
2469
2470 #[test]
2471 fn test_hlc3_equals_typical_price() {
2472 let b = bar(100, 120, 80, 110);
2473 assert_eq!(b.hlc3(), b.typical_price());
2474 assert_eq!(b.hlc3(), (Decimal::from(120) + Decimal::from(80) + Decimal::from(110)) / Decimal::from(3));
2476 }
2477
2478 #[test]
2481 fn test_is_bearish_true_when_close_below_open() {
2482 let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
2483 assert!(bar.is_bearish());
2484 }
2485
2486 #[test]
2487 fn test_is_bearish_false_when_close_above_open() {
2488 let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
2489 assert!(!bar.is_bearish());
2490 }
2491
2492 #[test]
2493 fn test_is_bearish_false_when_doji() {
2494 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2495 assert!(!bar.is_bearish());
2496 }
2497
2498 #[test]
2501 fn test_wick_ratio_zero_for_full_body_no_wicks() {
2502 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2504 let ratio = bar.wick_ratio().unwrap();
2505 assert!(ratio.abs() < 1e-10);
2506 }
2507
2508 #[test]
2509 fn test_wick_ratio_one_for_pure_wick_doji() {
2510 let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
2512 let ratio = bar.wick_ratio().unwrap();
2513 assert!((ratio - 1.0).abs() < 1e-10);
2514 }
2515
2516 #[test]
2517 fn test_wick_ratio_none_for_zero_range_bar() {
2518 let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
2519 assert!(bar.wick_ratio().is_none());
2520 }
2521
2522 #[test]
2525 fn test_is_bullish_true_when_close_above_open() {
2526 let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
2527 assert!(bar.is_bullish());
2528 }
2529
2530 #[test]
2531 fn test_is_bullish_false_when_close_below_open() {
2532 let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
2533 assert!(!bar.is_bullish());
2534 }
2535
2536 #[test]
2537 fn test_is_bullish_false_when_doji() {
2538 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2539 assert!(!bar.is_bullish());
2540 }
2541
2542 #[test]
2545 fn test_bar_duration_ms_one_minute() {
2546 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2547 assert_eq!(bar.bar_duration_ms(), 60_000);
2548 }
2549
2550 #[test]
2551 fn test_bar_duration_ms_consistent_with_timeframe() {
2552 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2553 bar.timeframe = Timeframe::Hours(1);
2554 assert_eq!(bar.bar_duration_ms(), 3_600_000);
2555 }
2556
2557 #[test]
2558 fn test_bar_duration_ms_seconds_timeframe() {
2559 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2560 bar.timeframe = Timeframe::Seconds(30);
2561 assert_eq!(bar.bar_duration_ms(), 30_000);
2562 }
2563
2564 #[test]
2567 fn test_ohlc4_equals_average_of_all_four_prices() {
2568 let b = bar(100, 120, 80, 110);
2569 let expected = (Decimal::from(100) + Decimal::from(120) + Decimal::from(80) + Decimal::from(110))
2571 / Decimal::from(4);
2572 assert_eq!(b.ohlc4(), expected);
2573 }
2574
2575 #[test]
2576 fn test_is_marubozu_true_when_no_wicks() {
2577 let b = bar(100, 110, 100, 110);
2579 assert!(b.is_marubozu());
2580 }
2581
2582 #[test]
2583 fn test_is_marubozu_false_when_has_upper_wick() {
2584 let b = bar(100, 115, 100, 110);
2585 assert!(!b.is_marubozu());
2586 }
2587
2588 #[test]
2589 fn test_is_marubozu_false_when_has_lower_wick() {
2590 let b = bar(100, 110, 95, 110);
2591 assert!(!b.is_marubozu());
2592 }
2593
2594 #[test]
2597 fn test_is_harami_true_when_body_inside_prev_body() {
2598 let prev = bar(98, 115, 90, 108); let curr = bar(100, 110, 95, 105); assert!(curr.is_harami(&prev));
2601 }
2602
2603 #[test]
2604 fn test_is_harami_false_when_body_engulfs_prev() {
2605 let prev = bar(100, 110, 95, 105); let curr = bar(98, 115, 90, 108); assert!(!curr.is_harami(&prev));
2608 }
2609
2610 #[test]
2611 fn test_is_harami_false_when_bodies_equal() {
2612 let prev = bar(100, 110, 90, 105);
2613 let curr = bar(100, 110, 90, 105); assert!(!curr.is_harami(&prev));
2615 }
2616
2617 #[test]
2618 fn test_tail_length_upper_wick_longer() {
2619 let b = bar(100, 120, 95, 105);
2621 assert_eq!(b.tail_length(), Decimal::from(15));
2622 }
2623
2624 #[test]
2625 fn test_tail_length_lower_wick_longer() {
2626 let b = bar(105, 110, 80, 100);
2628 assert_eq!(b.tail_length(), Decimal::from(20));
2629 }
2630
2631 #[test]
2632 fn test_tail_length_zero_for_marubozu() {
2633 let b = bar(100, 110, 100, 110);
2635 assert!(b.tail_length().is_zero());
2636 }
2637
2638 #[test]
2641 fn test_is_inside_bar_true_when_range_within_prev() {
2642 let prev = bar(90, 120, 80, 110); let curr = bar(95, 115, 85, 100); assert!(curr.is_inside_bar(&prev));
2645 }
2646
2647 #[test]
2648 fn test_is_inside_bar_false_when_high_exceeds_prev_high() {
2649 let prev = bar(90, 110, 80, 100); let curr = bar(95, 112, 85, 100); assert!(!curr.is_inside_bar(&prev));
2652 }
2653
2654 #[test]
2655 fn test_is_inside_bar_false_when_equal_range() {
2656 let prev = bar(90, 110, 80, 100);
2657 let curr = bar(90, 110, 80, 100); assert!(!curr.is_inside_bar(&prev));
2659 }
2660
2661 #[test]
2662 fn test_bar_type_bullish() {
2663 let b = bar(100, 110, 90, 105); assert_eq!(b.bar_type(), "bullish");
2665 }
2666
2667 #[test]
2668 fn test_bar_type_bearish() {
2669 let b = bar(105, 110, 90, 100); assert_eq!(b.bar_type(), "bearish");
2671 }
2672
2673 #[test]
2674 fn test_bar_type_doji() {
2675 let b = bar(100, 110, 90, 100); assert_eq!(b.bar_type(), "doji");
2677 }
2678
2679 #[test]
2682 fn test_body_pct_none_for_zero_range() {
2683 let b = bar(100, 100, 100, 100);
2684 assert!(b.body_pct().is_none());
2685 }
2686
2687 #[test]
2688 fn test_body_pct_100_for_marubozu() {
2689 let b = bar(100, 110, 100, 110);
2691 assert_eq!(b.body_pct().unwrap(), Decimal::ONE_HUNDRED);
2692 }
2693
2694 #[test]
2695 fn test_body_pct_50_for_half_body() {
2696 let b = bar(100, 110, 100, 105);
2698 assert_eq!(b.body_pct().unwrap(), Decimal::from(50));
2699 }
2700
2701 #[test]
2702 fn test_is_bullish_hammer_true_for_classic_hammer() {
2703 let b = bar(108, 110, 100, 109);
2706 assert!(b.is_bullish_hammer());
2707 }
2708
2709 #[test]
2710 fn test_is_bullish_hammer_false_when_lower_wick_not_long_enough() {
2711 let b = bar(100, 110, 98, 108);
2713 assert!(!b.is_bullish_hammer());
2714 }
2715
2716 #[test]
2717 fn test_is_bullish_hammer_false_for_doji() {
2718 let b = bar(100, 110, 90, 100); assert!(!b.is_bullish_hammer());
2720 }
2721
2722 #[test]
2724 fn test_is_marubozu_true_when_full_body() {
2725 let b = bar(100, 110, 100, 110);
2727 assert!(b.is_marubozu());
2728 }
2729
2730 #[test]
2731 fn test_is_marubozu_false_when_large_wicks() {
2732 let b = bar(100, 120, 80, 110);
2734 assert!(!b.is_marubozu());
2735 }
2736
2737 #[test]
2738 fn test_is_marubozu_true_for_zero_range_flat_bar() {
2739 let b = bar(100, 100, 100, 100);
2741 assert!(b.is_marubozu());
2742 }
2743
2744 #[test]
2746 fn test_upper_wick_pct_zero_when_no_upper_wick() {
2747 let b = bar(100, 110, 90, 110);
2749 let pct = b.upper_wick_pct().unwrap();
2750 assert!(pct.is_zero(), "expected 0, got {pct}");
2751 }
2752
2753 #[test]
2754 fn test_upper_wick_pct_50_when_half_range() {
2755 let b = bar(100, 120, 100, 110);
2757 let pct = b.upper_wick_pct().unwrap();
2758 assert_eq!(pct, dec!(50));
2759 }
2760
2761 #[test]
2762 fn test_upper_wick_pct_none_for_zero_range() {
2763 let b = bar(100, 100, 100, 100);
2764 assert!(b.upper_wick_pct().is_none());
2765 }
2766
2767 #[test]
2769 fn test_lower_wick_pct_zero_when_no_lower_wick() {
2770 let b = bar(100, 110, 100, 105);
2772 let pct = b.lower_wick_pct().unwrap();
2773 assert!(pct.is_zero(), "expected 0, got {pct}");
2774 }
2775
2776 #[test]
2777 fn test_lower_wick_pct_50_when_half_range() {
2778 let b = bar(110, 120, 100, 115);
2780 let pct = b.lower_wick_pct().unwrap();
2781 assert_eq!(pct, dec!(50));
2782 }
2783
2784 #[test]
2785 fn test_lower_wick_pct_none_for_zero_range() {
2786 let b = bar(100, 100, 100, 100);
2787 assert!(b.lower_wick_pct().is_none());
2788 }
2789
2790 #[test]
2792 fn test_is_bearish_engulfing_true_for_bearish_engulf() {
2793 let prev = bar(100, 115, 95, 110); let curr = bar(112, 115, 88, 90); assert!(curr.is_bearish_engulfing(&prev));
2796 }
2797
2798 #[test]
2799 fn test_is_bearish_engulfing_false_for_bullish_engulf() {
2800 let prev = bar(110, 115, 95, 100); let curr = bar(98, 120, 95, 115); assert!(!curr.is_bearish_engulfing(&prev));
2803 }
2804
2805 #[test]
2806 fn test_is_engulfing_true_when_body_contains_prev_body() {
2807 let prev = bar(100, 110, 95, 105); let curr = bar(98, 115, 95, 108); assert!(curr.is_engulfing(&prev));
2810 }
2811
2812 #[test]
2813 fn test_is_engulfing_false_when_only_partial_overlap() {
2814 let prev = bar(100, 115, 90, 112); let curr = bar(101, 115, 90, 113); assert!(!curr.is_engulfing(&prev));
2817 }
2818
2819 #[test]
2820 fn test_is_engulfing_false_for_equal_bodies() {
2821 let prev = bar(100, 110, 90, 108);
2822 let curr = bar(100, 110, 90, 108); assert!(!curr.is_engulfing(&prev));
2824 }
2825
2826 #[test]
2829 fn test_has_upper_wick_true_when_high_above_max_oc() {
2830 let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(100), dec!(110));
2832 assert!(bar.has_upper_wick());
2833 }
2834
2835 #[test]
2836 fn test_has_upper_wick_false_for_full_body() {
2837 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2839 assert!(!bar.has_upper_wick());
2840 }
2841
2842 #[test]
2843 fn test_has_lower_wick_true_when_low_below_min_oc() {
2844 let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(110));
2846 assert!(bar.has_lower_wick());
2847 }
2848
2849 #[test]
2850 fn test_has_lower_wick_false_for_full_body() {
2851 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2853 assert!(!bar.has_lower_wick());
2854 }
2855
2856 #[test]
2859 fn test_is_gravestone_doji_true() {
2860 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(100));
2862 assert!(bar.is_gravestone_doji(dec!(0)));
2863 }
2864
2865 #[test]
2866 fn test_is_gravestone_doji_false_when_close_above_low() {
2867 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
2869 assert!(!bar.is_gravestone_doji(dec!(1)));
2870 }
2871
2872 #[test]
2875 fn test_is_dragonfly_doji_true() {
2876 let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(110));
2878 assert!(bar.is_dragonfly_doji(dec!(0)));
2879 }
2880
2881 #[test]
2882 fn test_is_dragonfly_doji_false_when_close_below_high() {
2883 let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
2885 assert!(!bar.is_dragonfly_doji(dec!(1)));
2886 }
2887
2888 #[test]
2891 fn test_is_flat_true() {
2892 let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
2893 assert!(bar.is_flat());
2894 }
2895
2896 #[test]
2897 fn test_is_flat_false_when_range_exists() {
2898 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2899 assert!(!bar.is_flat());
2900 }
2901
2902 #[test]
2903 fn test_close_to_high_ratio_normal() {
2904 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
2905 let r = bar.close_to_high_ratio().unwrap();
2907 assert!((r - 1.0).abs() < 1e-9);
2908 }
2909
2910 #[test]
2911 fn test_close_to_high_ratio_none_when_high_zero() {
2912 let bar = make_ohlcv_bar(dec!(0), dec!(0), dec!(0), dec!(0));
2913 assert!(bar.close_to_high_ratio().is_none());
2914 }
2915
2916 #[test]
2917 fn test_close_open_ratio_normal() {
2918 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
2920 let r = bar.close_open_ratio().unwrap();
2921 assert!((r - 1.1).abs() < 1e-9);
2922 }
2923
2924 #[test]
2925 fn test_close_open_ratio_none_when_open_zero() {
2926 let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
2927 assert!(bar.close_open_ratio().is_none());
2928 }
2929
2930 #[test]
2933 fn test_true_range_simple_hl_dominates() {
2934 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2936 assert_eq!(bar.true_range_with_prev(dec!(100)), dec!(20));
2937 }
2938
2939 #[test]
2940 fn test_true_range_gap_up_dominates() {
2941 let bar = make_ohlcv_bar(dec!(91), dec!(100), dec!(90), dec!(95));
2943 assert_eq!(bar.true_range_with_prev(dec!(80)), dec!(20));
2944 }
2945
2946 #[test]
2947 fn test_true_range_gap_down_dominates() {
2948 let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97));
2950 assert_eq!(bar.true_range_with_prev(dec!(120)), dec!(25));
2951 }
2952
2953 #[test]
2956 fn test_is_outside_bar_true() {
2957 let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2958 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2959 assert!(bar.is_outside_bar(&prev));
2960 }
2961
2962 #[test]
2963 fn test_is_outside_bar_false_when_inside() {
2964 let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2965 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2966 assert!(!bar.is_outside_bar(&prev));
2967 }
2968
2969 #[test]
2970 fn test_high_low_midpoint_correct() {
2971 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2972 assert_eq!(bar.high_low_midpoint(), dec!(100));
2974 }
2975
2976 #[test]
2977 fn test_high_low_midpoint_uneven() {
2978 let bar = make_ohlcv_bar(dec!(100), dec!(111), dec!(90), dec!(100));
2979 assert_eq!(bar.high_low_midpoint(), dec!(100.5));
2981 }
2982
2983 #[test]
2986 fn test_gap_up_true() {
2987 let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
2988 let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(101), dec!(108));
2989 assert!(bar.gap_up(&prev));
2990 }
2991
2992 #[test]
2993 fn test_gap_up_false_when_no_gap() {
2994 let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
2995 let bar = make_ohlcv_bar(dec!(99), dec!(105), dec!(98), dec!(104));
2996 assert!(!bar.gap_up(&prev));
2997 }
2998
2999 #[test]
3000 fn test_gap_down_true() {
3001 let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
3002 let bar = make_ohlcv_bar(dec!(88), dec!(89), dec!(85), dec!(86));
3003 assert!(bar.gap_down(&prev));
3004 }
3005
3006 #[test]
3007 fn test_gap_down_false_when_no_gap() {
3008 let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
3009 let bar = make_ohlcv_bar(dec!(91), dec!(95), dec!(89), dec!(93));
3010 assert!(!bar.gap_down(&prev));
3011 }
3012
3013 #[test]
3016 fn test_range_pct_correct() {
3017 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3019 let pct = bar.range_pct().unwrap();
3020 assert!((pct - 20.0).abs() < 1e-9);
3021 }
3022
3023 #[test]
3024 fn test_range_pct_none_when_open_zero() {
3025 let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
3026 assert!(bar.range_pct().is_none());
3027 }
3028
3029 #[test]
3030 fn test_range_pct_zero_for_flat_bar() {
3031 let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3033 let pct = bar.range_pct().unwrap();
3034 assert_eq!(pct, 0.0);
3035 }
3036
3037 #[test]
3040 fn test_body_size_bullish_bar() {
3041 let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
3043 assert_eq!(bar.body_size(), dec!(10));
3044 }
3045
3046 #[test]
3047 fn test_body_size_bearish_bar() {
3048 let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
3050 assert_eq!(bar.body_size(), dec!(10));
3051 }
3052
3053 #[test]
3054 fn test_body_size_doji() {
3055 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
3057 assert_eq!(bar.body_size(), dec!(0));
3058 }
3059
3060 #[test]
3063 fn test_volume_delta_positive_when_increasing() {
3064 let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3065 prev.volume = dec!(1000);
3066 let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
3067 bar.volume = dec!(1500);
3068 assert_eq!(bar.volume_delta(&prev), dec!(500));
3069 }
3070
3071 #[test]
3072 fn test_volume_delta_negative_when_decreasing() {
3073 let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3074 prev.volume = dec!(1500);
3075 let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
3076 bar.volume = dec!(1000);
3077 assert_eq!(bar.volume_delta(&prev), dec!(-500));
3078 }
3079
3080 #[test]
3081 fn test_is_consolidating_true_when_small_range() {
3082 let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let bar = make_ohlcv_bar(dec!(102), dec!(106), dec!(100), dec!(104)); assert!(bar.is_consolidating(&prev));
3085 }
3086
3087 #[test]
3088 fn test_is_consolidating_false_when_large_range() {
3089 let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let bar = make_ohlcv_bar(dec!(102), dec!(115), dec!(95), dec!(110)); assert!(!bar.is_consolidating(&prev));
3092 }
3093
3094 #[test]
3097 fn test_relative_volume_correct() {
3098 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
3099 let rv = bar.relative_volume(dec!(2)).unwrap();
3101 assert!((rv - 0.5).abs() < 1e-9);
3102 }
3103
3104 #[test]
3105 fn test_relative_volume_none_when_avg_zero() {
3106 let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
3107 assert!(bar.relative_volume(dec!(0)).is_none());
3108 }
3109
3110 #[test]
3111 fn test_intraday_reversal_true_for_bullish_then_bearish() {
3112 let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
3114 let bar = make_ohlcv_bar(dec!(105), dec!(107), dec!(97), dec!(98));
3116 assert!(bar.intraday_reversal(&prev));
3117 }
3118
3119 #[test]
3120 fn test_intraday_reversal_false_for_continuation() {
3121 let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
3123 let bar = make_ohlcv_bar(dec!(104), dec!(115), dec!(103), dec!(113));
3124 assert!(!bar.intraday_reversal(&prev));
3125 }
3126
3127 #[test]
3130 fn test_price_at_pct_zero_returns_low() {
3131 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3132 assert_eq!(bar.price_at_pct(0.0), dec!(90));
3133 }
3134
3135 #[test]
3136 fn test_price_at_pct_one_returns_high() {
3137 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3138 assert_eq!(bar.price_at_pct(1.0), dec!(110));
3139 }
3140
3141 #[test]
3142 fn test_price_at_pct_half_returns_midpoint() {
3143 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3144 assert_eq!(bar.price_at_pct(0.5), dec!(100));
3146 }
3147
3148 #[test]
3149 fn test_price_at_pct_clamped_above_one() {
3150 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3151 assert_eq!(bar.price_at_pct(2.0), dec!(110));
3152 }
3153
3154 #[test]
3157 fn test_mean_volume_none_when_empty() {
3158 assert!(OhlcvBar::mean_volume(&[]).is_none());
3159 }
3160
3161 #[test]
3162 fn test_mean_volume_single_bar() {
3163 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3164 bar.volume = dec!(200);
3165 assert_eq!(OhlcvBar::mean_volume(&[bar]), Some(dec!(200)));
3166 }
3167
3168 #[test]
3169 fn test_mean_volume_multiple_bars() {
3170 let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3171 b1.volume = dec!(100);
3172 let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3173 b2.volume = dec!(200);
3174 let mut b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3175 b3.volume = dec!(300);
3176 assert_eq!(OhlcvBar::mean_volume(&[b1, b2, b3]), Some(dec!(200)));
3177 }
3178
3179 #[test]
3182 fn test_vwap_deviation_none_when_vwap_not_set() {
3183 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3184 assert!(bar.vwap_deviation().is_none());
3185 }
3186
3187 #[test]
3188 fn test_vwap_deviation_zero_when_close_equals_vwap() {
3189 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3190 bar.vwap = Some(dec!(100));
3191 assert_eq!(bar.vwap_deviation(), Some(0.0));
3192 }
3193
3194 #[test]
3195 fn test_vwap_deviation_correct_value() {
3196 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3197 bar.vwap = Some(dec!(100));
3198 let dev = bar.vwap_deviation().unwrap();
3200 assert!((dev - 0.1).abs() < 1e-10);
3201 }
3202
3203 #[test]
3206 fn test_high_close_ratio_none_when_high_zero() {
3207 let bar = OhlcvBar {
3208 symbol: "X".into(),
3209 timeframe: Timeframe::Minutes(1),
3210 open: dec!(0),
3211 high: dec!(0),
3212 low: dec!(0),
3213 close: dec!(0),
3214 volume: dec!(1),
3215 bar_start_ms: 0,
3216 trade_count: 1,
3217 is_complete: false,
3218 is_gap_fill: false,
3219 vwap: None,
3220 };
3221 assert!(bar.high_close_ratio().is_none());
3222 }
3223
3224 #[test]
3225 fn test_high_close_ratio_one_when_close_equals_high() {
3226 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3227 let ratio = bar.high_close_ratio().unwrap();
3228 assert!((ratio - 1.0).abs() < 1e-10);
3229 }
3230
3231 #[test]
3232 fn test_high_close_ratio_less_than_one_when_close_below_high() {
3233 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(99));
3234 let ratio = bar.high_close_ratio().unwrap();
3235 assert!(ratio < 1.0);
3236 }
3237
3238 #[test]
3241 fn test_lower_shadow_pct_none_when_range_zero() {
3242 let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3243 assert!(bar.lower_shadow_pct().is_none());
3244 }
3245
3246 #[test]
3247 fn test_lower_shadow_pct_zero_when_no_lower_shadow() {
3248 let bar = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
3250 let pct = bar.lower_shadow_pct().unwrap();
3251 assert!(pct.abs() < 1e-10);
3252 }
3253
3254 #[test]
3255 fn test_lower_shadow_pct_correct_value() {
3256 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
3258 let pct = bar.lower_shadow_pct().unwrap();
3259 assert!((pct - 0.5).abs() < 1e-10);
3260 }
3261
3262 #[test]
3265 fn test_open_close_ratio_none_when_open_zero() {
3266 let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
3267 assert!(bar.open_close_ratio().is_none());
3268 }
3269
3270 #[test]
3271 fn test_open_close_ratio_one_when_flat() {
3272 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3273 let ratio = bar.open_close_ratio().unwrap();
3274 assert!((ratio - 1.0).abs() < 1e-10);
3275 }
3276
3277 #[test]
3278 fn test_open_close_ratio_above_one_for_bullish_bar() {
3279 let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
3280 let ratio = bar.open_close_ratio().unwrap();
3281 assert!(ratio > 1.0);
3282 }
3283
3284 #[test]
3287 fn test_is_wide_range_bar_true_when_range_exceeds_threshold() {
3288 let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110)); assert!(bar.is_wide_range_bar(dec!(20)));
3290 }
3291
3292 #[test]
3293 fn test_is_wide_range_bar_false_when_range_equals_threshold() {
3294 let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(100), dec!(110)); assert!(!bar.is_wide_range_bar(dec!(20)));
3296 }
3297
3298 #[test]
3301 fn test_close_to_low_ratio_none_when_range_zero() {
3302 let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3303 assert!(bar.close_to_low_ratio().is_none());
3304 }
3305
3306 #[test]
3307 fn test_close_to_low_ratio_one_when_closed_at_high() {
3308 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3309 let ratio = bar.close_to_low_ratio().unwrap();
3310 assert!((ratio - 1.0).abs() < 1e-10);
3311 }
3312
3313 #[test]
3314 fn test_close_to_low_ratio_zero_when_closed_at_low() {
3315 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
3316 let ratio = bar.close_to_low_ratio().unwrap();
3317 assert!(ratio.abs() < 1e-10);
3318 }
3319
3320 #[test]
3321 fn test_close_to_low_ratio_half_at_midpoint() {
3322 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3323 let ratio = bar.close_to_low_ratio().unwrap();
3325 assert!((ratio - 0.5).abs() < 1e-10);
3326 }
3327
3328 #[test]
3331 fn test_volume_per_trade_none_when_trade_count_zero() {
3332 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3333 bar.trade_count = 0;
3334 assert!(bar.volume_per_trade().is_none());
3335 }
3336
3337 #[test]
3338 fn test_volume_per_trade_correct_value() {
3339 let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3340 bar.volume = dec!(500);
3341 bar.trade_count = 5;
3342 assert_eq!(bar.volume_per_trade(), Some(dec!(100)));
3343 }
3344
3345 #[test]
3348 fn test_price_range_overlap_true_when_ranges_overlap() {
3349 let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3350 let b = make_ohlcv_bar(dec!(105), dec!(120), dec!(95), dec!(110));
3351 assert!(a.price_range_overlap(&b));
3352 }
3353
3354 #[test]
3355 fn test_price_range_overlap_false_when_no_overlap() {
3356 let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3357 let b = make_ohlcv_bar(dec!(120), dec!(130), dec!(115), dec!(125));
3358 assert!(!a.price_range_overlap(&b));
3359 }
3360
3361 #[test]
3362 fn test_price_range_overlap_true_at_exact_touch() {
3363 let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3364 let b = make_ohlcv_bar(dec!(115), dec!(125), dec!(110), dec!(120));
3365 assert!(a.price_range_overlap(&b));
3366 }
3367
3368 #[test]
3371 fn test_bar_height_pct_none_when_open_zero() {
3372 let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
3373 assert!(bar.bar_height_pct().is_none());
3374 }
3375
3376 #[test]
3377 fn test_bar_height_pct_correct_value() {
3378 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); let pct = bar.bar_height_pct().unwrap();
3381 assert!((pct - 0.2).abs() < 1e-10);
3382 }
3383
3384 #[test]
3387 fn test_is_bullish_engulfing_true_for_valid_pattern() {
3388 let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
3390 let bar = make_ohlcv_bar(dec!(98), dec!(115), dec!(95), dec!(112));
3391 assert!(bar.is_bullish_engulfing(&prev));
3392 }
3393
3394 #[test]
3395 fn test_is_bullish_engulfing_false_when_bearish() {
3396 let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
3397 let bar = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(95));
3398 assert!(!bar.is_bullish_engulfing(&prev));
3399 }
3400
3401 #[test]
3404 fn test_close_gap_positive_for_gap_up() {
3405 let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3406 let bar = make_ohlcv_bar(dec!(106), dec!(110), dec!(103), dec!(108)); assert_eq!(bar.close_gap(&prev), dec!(4));
3408 }
3409
3410 #[test]
3411 fn test_close_gap_negative_for_gap_down() {
3412 let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3413 let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97)); assert_eq!(bar.close_gap(&prev), dec!(-4));
3415 }
3416
3417 #[test]
3418 fn test_close_gap_zero_when_no_gap() {
3419 let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3420 let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108));
3421 assert_eq!(bar.close_gap(&prev), dec!(0));
3422 }
3423
3424 #[test]
3427 fn test_close_above_midpoint_true_when_above_mid() {
3428 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
3430 assert!(bar.close_above_midpoint());
3431 }
3432
3433 #[test]
3434 fn test_close_above_midpoint_false_when_at_mid() {
3435 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); assert!(!bar.close_above_midpoint());
3437 }
3438
3439 #[test]
3442 fn test_close_momentum_positive_when_rising() {
3443 let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
3444 let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
3445 assert_eq!(bar.close_momentum(&prev), dec!(10));
3446 }
3447
3448 #[test]
3449 fn test_close_momentum_zero_when_unchanged() {
3450 let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
3451 let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(100));
3452 assert_eq!(bar.close_momentum(&prev), dec!(0));
3453 }
3454
3455 #[test]
3458 fn test_bar_range_correct() {
3459 let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
3460 assert_eq!(bar.bar_range(), dec!(30));
3461 }
3462}