Skip to main content

fin_stream/ohlcv/
mod.rs

1//! Real-time tick-to-OHLCV aggregation at arbitrary timeframes.
2//!
3//! ## Responsibility
4//! Aggregate incoming NormalizedTicks into OHLCV bars at configurable
5//! timeframes. Handles bar completion detection and partial-bar access.
6//!
7//! ## Guarantees
8//! - Non-panicking: all operations return Result or Option
9//! - Thread-safe: OhlcvAggregator is Send + Sync
10
11use crate::error::StreamError;
12use crate::tick::NormalizedTick;
13use rust_decimal::Decimal;
14
15/// Supported bar timeframes.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
17pub enum Timeframe {
18    /// Bar duration measured in seconds.
19    Seconds(u64),
20    /// Bar duration measured in minutes.
21    Minutes(u64),
22    /// Bar duration measured in hours.
23    Hours(u64),
24}
25
26impl Timeframe {
27    /// Duration in milliseconds.
28    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    /// Bar start timestamp for a given ms timestamp.
37    pub fn bar_start_ms(self, ts_ms: u64) -> u64 {
38        let dur = self.duration_ms();
39        (ts_ms / dur) * dur
40    }
41
42    /// Construct a `Timeframe` from a millisecond duration.
43    ///
44    /// Prefers the largest canonical unit that divides evenly:
45    /// hours > minutes > seconds. Returns `None` if `ms` is zero or not a
46    /// whole number of seconds.
47    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 PartialOrd for Timeframe {
65    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
66        Some(self.cmp(other))
67    }
68}
69
70impl Ord for Timeframe {
71    /// Compares timeframes by their duration in milliseconds.
72    ///
73    /// For example: `Seconds(30) < Minutes(1) < Hours(1)`.
74    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
75        self.duration_ms().cmp(&other.duration_ms())
76    }
77}
78
79impl std::fmt::Display for Timeframe {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Timeframe::Seconds(s) => write!(f, "{s}s"),
83            Timeframe::Minutes(m) => write!(f, "{m}m"),
84            Timeframe::Hours(h) => write!(f, "{h}h"),
85        }
86    }
87}
88
89impl std::str::FromStr for Timeframe {
90    type Err = crate::error::StreamError;
91
92    /// Parse a timeframe string such as `"1s"`, `"5m"`, or `"2h"`.
93    ///
94    /// The format is a positive integer followed by a unit suffix:
95    /// - `s` — seconds (e.g. `"30s"`)
96    /// - `m` — minutes (e.g. `"5m"`)
97    /// - `h` — hours   (e.g. `"1h"`)
98    ///
99    /// # Errors
100    ///
101    /// Returns [`StreamError::ConfigError`] if the string is empty, has an
102    /// unknown suffix, or if the numeric part is zero or cannot be parsed.
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        let s = s.trim();
105        if s.is_empty() {
106            return Err(crate::error::StreamError::ConfigError {
107                reason: "timeframe string is empty".into(),
108            });
109        }
110        let (digits, suffix) = s.split_at(s.len() - 1);
111        let n: u64 = digits.parse().map_err(|_| crate::error::StreamError::ConfigError {
112            reason: format!("invalid timeframe numeric part '{digits}' in '{s}'"),
113        })?;
114        if n == 0 {
115            return Err(crate::error::StreamError::ConfigError {
116                reason: format!("timeframe value must be > 0, got '{s}'"),
117            });
118        }
119        match suffix {
120            "s" => Ok(Timeframe::Seconds(n)),
121            "m" => Ok(Timeframe::Minutes(n)),
122            "h" => Ok(Timeframe::Hours(n)),
123            other => Err(crate::error::StreamError::ConfigError {
124                reason: format!(
125                    "unknown timeframe suffix '{other}' in '{s}'; expected s, m, or h"
126                ),
127            }),
128        }
129    }
130}
131
132/// Direction of an OHLCV bar body.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum BarDirection {
135    /// Close is strictly above open.
136    Bullish,
137    /// Close is strictly below open.
138    Bearish,
139    /// Close equals open (flat body).
140    Neutral,
141}
142
143/// A completed or partial OHLCV bar.
144#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
145pub struct OhlcvBar {
146    /// Instrument symbol (e.g. `"BTC-USD"`).
147    pub symbol: String,
148    /// Timeframe of this bar.
149    pub timeframe: Timeframe,
150    /// UTC millisecond timestamp of the bar's open boundary.
151    pub bar_start_ms: u64,
152    /// Opening price (first tick's price in the bar window).
153    pub open: Decimal,
154    /// Highest price seen in the bar window.
155    pub high: Decimal,
156    /// Lowest price seen in the bar window.
157    pub low: Decimal,
158    /// Closing price (most recent tick's price in the bar window).
159    pub close: Decimal,
160    /// Total traded volume in this bar.
161    pub volume: Decimal,
162    /// Number of ticks contributing to this bar.
163    pub trade_count: u64,
164    /// `true` once the bar's time window has been closed by a tick in a later window.
165    pub is_complete: bool,
166    /// `true` if this bar was synthesized to fill a gap — no real ticks were received
167    /// during its window. Gap-fill bars have `trade_count == 0` and all OHLC fields set
168    /// to the last known close price. Callers may use this flag to filter synthetic bars
169    /// out of indicator calculations or storage.
170    pub is_gap_fill: bool,
171    /// Volume-weighted average price for this bar. `None` for gap-fill bars.
172    pub vwap: Option<Decimal>,
173}
174
175impl OhlcvBar {
176    /// Price range of the bar: `high - low`.
177    pub fn range(&self) -> Decimal {
178        self.high - self.low
179    }
180
181    /// Candle body size: `(close - open).abs()`.
182    ///
183    /// Direction-independent; use `close > open` to determine bullish/bearish.
184    pub fn body(&self) -> Decimal {
185        (self.close - self.open).abs()
186    }
187
188    /// Higher of open and close: `max(open, close)`.
189    ///
190    /// The top of the candle body, regardless of direction.
191    pub fn body_high(&self) -> Decimal {
192        self.open.max(self.close)
193    }
194
195    /// Lower of open and close: `min(open, close)`.
196    ///
197    /// The bottom of the candle body, regardless of direction.
198    pub fn body_low(&self) -> Decimal {
199        self.open.min(self.close)
200    }
201
202    /// Returns `true` if this is a bullish bar (`close > open`).
203    pub fn is_bullish(&self) -> bool {
204        self.close > self.open
205    }
206
207    /// Returns `true` if this is a bearish bar (`close < open`).
208    pub fn is_bearish(&self) -> bool {
209        self.close < self.open
210    }
211
212    /// Returns `true` if the bar has a non-zero upper wick (`high > max(open, close)`).
213    pub fn has_upper_wick(&self) -> bool {
214        self.wick_upper() > Decimal::ZERO
215    }
216
217    /// Returns `true` if the bar has a non-zero lower wick (`min(open, close) > low`).
218    pub fn has_lower_wick(&self) -> bool {
219        self.wick_lower() > Decimal::ZERO
220    }
221
222    /// Directional classification of the bar body.
223    ///
224    /// Returns [`BarDirection::Bullish`] when `close > open`, [`BarDirection::Bearish`]
225    /// when `close < open`, and [`BarDirection::Neutral`] when they are equal.
226    pub fn body_direction(&self) -> BarDirection {
227        use std::cmp::Ordering;
228        match self.close.cmp(&self.open) {
229            Ordering::Greater => BarDirection::Bullish,
230            Ordering::Less => BarDirection::Bearish,
231            Ordering::Equal => BarDirection::Neutral,
232        }
233    }
234
235    /// Returns `true` if the bar body is a doji (indecision candle).
236    ///
237    /// A doji has `|close - open| <= epsilon`. Use a small positive `epsilon`
238    /// such as `dec!(0.01)` to account for rounding in price data.
239    pub fn is_doji(&self, epsilon: Decimal) -> bool {
240        self.body() <= epsilon
241    }
242
243    /// Upper wick (shadow) length: `high - max(open, close)`.
244    ///
245    /// The upper wick is the portion of the candle above the body.
246    pub fn wick_upper(&self) -> Decimal {
247        self.high - self.body_high()
248    }
249
250    /// Lower wick (shadow) length: `min(open, close) - low`.
251    ///
252    /// The lower wick is the portion of the candle below the body.
253    pub fn wick_lower(&self) -> Decimal {
254        self.body_low() - self.low
255    }
256
257    /// Signed price change: `close - open`.
258    ///
259    /// Positive for bullish bars, negative for bearish bars, zero for doji.
260    /// Unlike [`body`](Self::body), this preserves direction.
261    pub fn price_change(&self) -> Decimal {
262        self.close - self.open
263    }
264
265    /// Typical price: `(high + low + close) / 3`.
266    ///
267    /// Commonly used as the basis for VWAP and commodity channel index (CCI)
268    /// calculations.
269    pub fn typical_price(&self) -> Decimal {
270        (self.high + self.low + self.close) / Decimal::from(3)
271    }
272
273    /// Close Location Value (CLV): where the close sits within the bar's range.
274    ///
275    /// Formula: `(close - low - (high - close)) / range`.
276    ///
277    /// Returns `None` if the range is zero (e.g. a single-price bar). Values
278    /// are in `[-1.0, 1.0]`: `+1.0` means the close is at the high, `-1.0` at
279    /// the low, and `0.0` means the close is exactly mid-range.
280    pub fn close_location_value(&self) -> Option<f64> {
281        use rust_decimal::prelude::ToPrimitive;
282        let range = self.range();
283        if range.is_zero() {
284            return None;
285        }
286        ((self.close - self.low - (self.high - self.close)) / range).to_f64()
287    }
288
289    /// Median price: `(high + low) / 2`.
290    ///
291    /// The midpoint of the bar's price range, independent of open and close.
292    pub fn median_price(&self) -> Decimal {
293        (self.high + self.low) / Decimal::from(2)
294    }
295
296    /// Weighted close price: `(high + low + close × 2) / 4`.
297    ///
298    /// Gives extra weight to the closing price over the high and low extremes.
299    /// Commonly used as the basis for certain momentum and volatility indicators.
300    pub fn weighted_close(&self) -> Decimal {
301        (self.high + self.low + self.close + self.close) / Decimal::from(4)
302    }
303
304    /// Percentage price change: `(close − open) / open × 100`.
305    ///
306    /// Returns `None` if `open` is zero. Positive values indicate a bullish bar;
307    /// negative values indicate a bearish bar.
308    pub fn price_change_pct(&self) -> Option<f64> {
309        use rust_decimal::prelude::ToPrimitive;
310        if self.open.is_zero() {
311            return None;
312        }
313        let pct = self.price_change() / self.open * Decimal::from(100);
314        pct.to_f64()
315    }
316
317    /// Body ratio: `body / range`.
318    ///
319    /// The fraction of the total price range that is body (rather than wicks).
320    /// Ranges from `0.0` (pure wicks / doji) to `1.0` (no wicks at all).
321    /// Returns `None` if the bar's range is zero (all prices identical).
322    pub fn body_ratio(&self) -> Option<f64> {
323        use rust_decimal::prelude::ToPrimitive;
324        let range = self.range();
325        if range.is_zero() {
326            return None;
327        }
328        (self.body() / range).to_f64()
329    }
330
331    /// True range: `max(high − low, |high − prev_close|, |low − prev_close|)`.
332    ///
333    /// The standard ATR (Average True Range) input. Accounts for overnight gaps by
334    /// including the distance from the previous close to today's high and low.
335    pub fn true_range(&self, prev_close: Decimal) -> Decimal {
336        let hl = self.range();
337        let hpc = (self.high - prev_close).abs();
338        let lpc = (self.low - prev_close).abs();
339        hl.max(hpc).max(lpc)
340    }
341
342    /// Returns `true` if this bar is an inside bar relative to `prev`.
343    ///
344    /// An inside bar has `high < prev.high` and `low > prev.low` — its full
345    /// range is contained within the prior bar's range. Used in price action
346    /// trading as a consolidation signal.
347    #[deprecated(since = "2.2.0", note = "Use `is_inside_bar` instead")]
348    pub fn inside_bar(&self, prev: &OhlcvBar) -> bool {
349        self.is_inside_bar(prev)
350    }
351
352    /// Returns `true` if this bar is an outside bar relative to `prev`.
353    ///
354    /// An outside bar has `high > prev.high` and `low < prev.low` — it fully
355    /// engulfs the prior bar's range. Also called a key reversal day.
356    pub fn outside_bar(&self, prev: &OhlcvBar) -> bool {
357        self.high > prev.high && self.low < prev.low
358    }
359
360    /// Returns the ratio of total wick length to bar range: `(upper_wick + lower_wick) / range`.
361    ///
362    /// A value near 1 indicates a bar that is mostly wicks with little body.
363    /// Returns `None` when the bar has zero range (high == low).
364    pub fn wick_ratio(&self) -> Option<f64> {
365        use rust_decimal::prelude::ToPrimitive;
366        let range = self.range();
367        if range.is_zero() {
368            return None;
369        }
370        ((self.wick_upper() + self.wick_lower()) / range).to_f64()
371    }
372
373    /// Returns `true` if this bar has a classic hammer shape.
374    ///
375    /// A hammer has:
376    /// - A small body (≤ 30% of range)
377    /// - A long lower wick (≥ 60% of range)
378    /// - A tiny upper wick (≤ 10% of range)
379    ///
380    /// Returns `false` if the bar's range is zero.
381    pub fn is_hammer(&self) -> bool {
382        let range = self.range();
383        if range.is_zero() {
384            return false;
385        }
386        let body = self.body();
387        let wick_lo = self.wick_lower();
388        let wick_hi = self.wick_upper();
389        let three = Decimal::from(3);
390        let six = Decimal::from(6);
391        let ten = Decimal::from(10);
392        // body ≤ 30%: body*10 ≤ range*3
393        // lower wick ≥ 60%: wick_lo*10 ≥ range*6
394        // upper wick ≤ 10%: wick_hi*10 ≤ range
395        body * ten <= range * three
396            && wick_lo * ten >= range * six
397            && wick_hi * ten <= range
398    }
399
400    /// Returns `true` if this bar has a classic shooting-star shape.
401    ///
402    /// A shooting star has:
403    /// - A small body (≤ 30% of range)
404    /// - A long upper wick (≥ 60% of range)
405    /// - A tiny lower wick (≤ 10% of range)
406    ///
407    /// This is the inverse of a hammer — it signals a potential reversal at
408    /// the top of an uptrend. Returns `false` if the bar's range is zero.
409    pub fn is_shooting_star(&self) -> bool {
410        let range = self.range();
411        if range.is_zero() {
412            return false;
413        }
414        let body = self.body();
415        let wick_lo = self.wick_lower();
416        let wick_hi = self.wick_upper();
417        let three = Decimal::from(3);
418        let six = Decimal::from(6);
419        let ten = Decimal::from(10);
420        // body ≤ 30%: body*10 ≤ range*3
421        // upper wick ≥ 60%: wick_hi*10 ≥ range*6
422        // lower wick ≤ 10%: wick_lo*10 ≤ range
423        body * ten <= range * three
424            && wick_hi * ten >= range * six
425            && wick_lo * ten <= range
426    }
427
428    /// Gap from the previous bar: `self.open − prev.close`.
429    ///
430    /// Positive values indicate a gap-up; negative values indicate a gap-down.
431    /// Zero means the bar opened exactly at the previous close (no gap).
432    pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
433        self.open - prev.close
434    }
435
436    /// Returns `true` if this bar opened above the previous bar's close.
437    pub fn is_gap_up(&self, prev: &OhlcvBar) -> bool {
438        self.open > prev.close
439    }
440
441    /// Returns `true` if this bar opened below the previous bar's close.
442    pub fn is_gap_down(&self, prev: &OhlcvBar) -> bool {
443        self.open < prev.close
444    }
445
446    /// Body midpoint: `(open + close) / 2`.
447    ///
448    /// The arithmetic center of the candle body, regardless of direction.
449    /// Useful as a proxy for the "fair value" of the period.
450    pub fn bar_midpoint(&self) -> Decimal {
451        (self.open + self.close) / Decimal::from(2)
452    }
453
454    /// Body as a fraction of total range: `body / range`.
455    ///
456    /// Returns `None` when `range` is zero (all OHLC prices identical).
457    pub fn body_to_range_ratio(&self) -> Option<Decimal> {
458        let r = self.range();
459        if r.is_zero() {
460            return None;
461        }
462        Some(self.body() / r)
463    }
464
465    /// Returns `true` if the upper wick is longer than the candle body.
466    ///
467    /// Indicates a bearish rejection at the high (supply above current price).
468    pub fn is_long_upper_wick(&self) -> bool {
469        self.wick_upper() > self.body()
470    }
471
472    /// Returns `true` if the lower wick is longer than the candle body.
473    ///
474    /// Indicates a bullish rejection at the low (demand below current price).
475    pub fn is_long_lower_wick(&self) -> bool {
476        self.wick_lower() > self.body()
477    }
478
479    /// Absolute price change over the bar: `|close − open|`.
480    ///
481    /// Alias for [`body`](Self::body).
482    #[deprecated(since = "2.2.0", note = "Use `body()` instead")]
483    pub fn price_change_abs(&self) -> Decimal {
484        self.body()
485    }
486
487    /// Upper shadow length — alias for [`wick_upper`](Self::wick_upper).
488    ///
489    /// Returns `high − max(open, close)`.
490    pub fn upper_shadow(&self) -> Decimal {
491        self.wick_upper()
492    }
493
494    /// Lower shadow length — alias for [`wick_lower`](Self::wick_lower).
495    ///
496    /// Returns `min(open, close) − low`.
497    pub fn lower_shadow(&self) -> Decimal {
498        self.wick_lower()
499    }
500
501    /// Returns `true` if this bar has a spinning-top pattern.
502    ///
503    /// A spinning top has a small body (≤ `body_pct` of range) with significant
504    /// wicks on both sides (each wick strictly greater than the body). Signals
505    /// market indecision — neither buyers nor sellers controlled the period.
506    ///
507    /// `body_pct` is a fraction in `[0.0, 1.0]`, e.g. `dec!(0.3)` for 30%.
508    /// Returns `false` if the bar's range is zero.
509    pub fn is_spinning_top(&self, body_pct: Decimal) -> bool {
510        let range = self.range();
511        if range.is_zero() {
512            return false;
513        }
514        let body = self.body();
515        let max_body = range * body_pct;
516        body <= max_body && self.wick_upper() > body && self.wick_lower() > body
517    }
518
519    /// HLC3: `(high + low + close) / 3` — alias for [`typical_price`](Self::typical_price).
520    pub fn hlc3(&self) -> Decimal {
521        self.typical_price()
522    }
523
524    /// OHLC4: `(open + high + low + close) / 4`.
525    ///
526    /// Gives equal weight to all four price points. Sometimes used as a smoother
527    /// proxy than typical price because it incorporates the open.
528    pub fn ohlc4(&self) -> Decimal {
529        (self.open + self.high + self.low + self.close) / Decimal::from(4)
530    }
531
532    /// Returns `true` if this bar is a marubozu — no upper or lower wicks.
533    ///
534    /// A marubozu has `open == low` and `close == high` (bullish) or
535    /// `open == high` and `close == low` (bearish). It signals strong
536    /// one-directional momentum with no intrabar rejection.
537    /// A zero-range bar (all prices equal) is considered a marubozu.
538    pub fn is_marubozu(&self) -> bool {
539        self.wick_upper().is_zero() && self.wick_lower().is_zero()
540    }
541
542    /// Returns `true` if this bar's body engulfs `prev`'s body.
543    ///
544    /// Engulfing requires: `self.open < prev.open.min(prev.close)` and
545    /// `self.close > prev.open.max(prev.close)` (or vice versa for bearish).
546    /// Specifically, `self.body_low < prev.body_low` and
547    /// `self.body_high > prev.body_high`.
548    ///
549    /// Does NOT require opposite directions — use in combination with
550    /// [`is_bullish`](Self::is_bullish) / [`is_bearish`](Self::is_bearish) if
551    /// classic engulfing patterns are needed.
552    pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
553        self.body_low() < prev.body_low() && self.body_high() > prev.body_high()
554    }
555
556    /// Returns `true` if this bar is a harami: its body is entirely contained
557    /// within the previous bar's body.
558    ///
559    /// A harami is the opposite of an engulfing pattern. Neither bar needs to
560    /// be bullish or bearish — only the body ranges are compared.
561    pub fn is_harami(&self, prev: &OhlcvBar) -> bool {
562        self.body_low() > prev.body_low() && self.body_high() < prev.body_high()
563    }
564
565    /// The longer of the upper and lower wicks.
566    ///
567    /// Returns the maximum of `wick_upper()` and `wick_lower()`. Useful for
568    /// identifying long-tailed candles regardless of direction.
569    pub fn tail_length(&self) -> Decimal {
570        self.wick_upper().max(self.wick_lower())
571    }
572
573    /// Returns `true` if this bar is an inside bar: both `high` and `low` are
574    /// strictly within the previous bar's range.
575    ///
576    /// Unlike [`is_harami`](Self::is_harami), which compares body ranges,
577    /// this method compares the full high-low range including wicks.
578    pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
579        self.high < prev.high && self.low > prev.low
580    }
581
582    /// Returns `true` if this bar opened above the previous bar's high (gap up).
583    pub fn gap_up(&self, prev: &OhlcvBar) -> bool {
584        self.open > prev.high
585    }
586
587    /// Returns `true` if this bar opened below the previous bar's low (gap down).
588    pub fn gap_down(&self, prev: &OhlcvBar) -> bool {
589        self.open < prev.low
590    }
591
592    /// Absolute size of the candle body: `|close - open|`.
593    ///
594    /// Alias for [`body`](Self::body).
595    #[deprecated(since = "2.2.0", note = "Use `body()` instead")]
596    pub fn body_size(&self) -> Decimal {
597        self.body()
598    }
599
600    /// Volume change vs the previous bar: `self.volume - prev.volume`.
601    pub fn volume_delta(&self, prev: &OhlcvBar) -> Decimal {
602        self.volume - prev.volume
603    }
604
605    /// Returns `true` if this bar's range is less than 50% of the previous bar's range.
606    ///
607    /// Indicates price consolidation / compression.
608    pub fn is_consolidating(&self, prev: &OhlcvBar) -> bool {
609        let prev_range = prev.range();
610        if prev_range.is_zero() {
611            return false;
612        }
613        self.range() < prev_range / Decimal::TWO
614    }
615
616    /// Mean volume across a slice of bars.
617    ///
618    /// Returns `None` if the slice is empty.
619    pub fn mean_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
620        if bars.is_empty() {
621            return None;
622        }
623        Some(Self::sum_volume(bars) / Decimal::from(bars.len() as u64))
624    }
625
626    /// Absolute deviation of close price from VWAP as a fraction of VWAP: `|close - vwap| / vwap`.
627    ///
628    /// Returns `None` if `vwap` is not set or is zero.
629    pub fn vwap_deviation(&self) -> Option<f64> {
630        use rust_decimal::prelude::ToPrimitive;
631        let vwap = self.vwap?;
632        if vwap.is_zero() {
633            return None;
634        }
635        ((self.close - vwap).abs() / vwap).to_f64()
636    }
637
638    /// Volume as a ratio of `avg_volume`.
639    ///
640    /// Returns `None` if `avg_volume` is zero.
641    pub fn relative_volume(&self, avg_volume: Decimal) -> Option<f64> {
642        use rust_decimal::prelude::ToPrimitive;
643        if avg_volume.is_zero() {
644            return None;
645        }
646        (self.volume / avg_volume).to_f64()
647    }
648
649    /// Returns `true` if this bar opens in the direction of the prior bar's move
650    /// but closes against it (an intraday reversal signal).
651    ///
652    /// Specifically: prev was bullish (close > open), this bar opens near/above prev close,
653    /// and closes below prev open — or vice versa for a bearish reversal.
654    pub fn intraday_reversal(&self, prev: &OhlcvBar) -> bool {
655        let prev_bullish = prev.close > prev.open;
656        let this_bearish = self.close < self.open;
657        let prev_bearish = prev.close < prev.open;
658        let this_bullish = self.close > self.open;
659        (prev_bullish && this_bearish && self.open >= prev.close)
660            || (prev_bearish && this_bullish && self.open <= prev.close)
661    }
662
663    /// High-low range as a percentage of the open price: `(high - low) / open * 100`.
664    ///
665    /// Returns `None` if open is zero.
666    pub fn range_pct(&self) -> Option<f64> {
667        use rust_decimal::prelude::ToPrimitive;
668        if self.open.is_zero() {
669            return None;
670        }
671        let range = self.range() / self.open;
672        range.to_f64().map(|v| v * 100.0)
673    }
674
675    /// Returns `true` if this bar is an outside bar (engulfs `prev`'s range).
676    ///
677    /// An outside bar has a higher high AND lower low than the previous bar.
678    /// Alias for [`outside_bar`](Self::outside_bar).
679    #[deprecated(since = "2.2.0", note = "Use `outside_bar()` instead")]
680    pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
681        self.outside_bar(prev)
682    }
683
684    /// Midpoint of the high-low range: `(high + low) / 2`.
685    ///
686    /// Alias for [`median_price`](Self::median_price).
687    pub fn high_low_midpoint(&self) -> Decimal {
688        self.median_price()
689    }
690
691    /// Ratio of close to high: `close / high` as `f64`.
692    ///
693    /// Returns `None` if `high` is zero. A value near 1.0 means the bar closed
694    /// near its high (bullish strength); near 0.0 means it closed far below.
695    pub fn high_close_ratio(&self) -> Option<f64> {
696        use rust_decimal::prelude::ToPrimitive;
697        if self.high.is_zero() {
698            return None;
699        }
700        (self.close / self.high).to_f64()
701    }
702
703    /// Lower shadow as a fraction of the full bar range: `lower_shadow / range`.
704    ///
705    /// Returns `None` if the bar's range is zero.
706    pub fn lower_shadow_pct(&self) -> Option<f64> {
707        use rust_decimal::prelude::ToPrimitive;
708        let range = self.range();
709        if range.is_zero() {
710            return None;
711        }
712        (self.lower_shadow() / range).to_f64()
713    }
714
715    /// Ratio of close to open: `close / open` as `f64`.
716    ///
717    /// Returns `None` if `open` is zero. Values above 1.0 indicate a bullish bar.
718    pub fn open_close_ratio(&self) -> Option<f64> {
719        use rust_decimal::prelude::ToPrimitive;
720        if self.open.is_zero() {
721            return None;
722        }
723        (self.close / self.open).to_f64()
724    }
725
726    /// Returns `true` if this bar's range (`high - low`) exceeds `threshold`.
727    pub fn is_wide_range_bar(&self, threshold: Decimal) -> bool {
728        self.range() > threshold
729    }
730
731    /// Position of close within the bar's high-low range: `(close - low) / (high - low)`.
732    ///
733    /// Returns `None` if the bar's range is zero. Result is in `[0.0, 1.0]`:
734    /// - `0.0` → closed at the low (bearish)
735    /// - `1.0` → closed at the high (bullish)
736    pub fn close_to_low_ratio(&self) -> Option<f64> {
737        use rust_decimal::prelude::ToPrimitive;
738        let range = self.range();
739        if range.is_zero() {
740            return None;
741        }
742        ((self.close - self.low) / range).to_f64()
743    }
744
745    /// Average volume per trade: `volume / trade_count`.
746    ///
747    /// Returns `None` if `trade_count` is zero.
748    pub fn volume_per_trade(&self) -> Option<Decimal> {
749        if self.trade_count == 0 {
750            return None;
751        }
752        Some(self.volume / Decimal::from(self.trade_count as u64))
753    }
754
755    /// Returns `true` if this bar's high-low range overlaps with `other`'s range.
756    ///
757    /// Two ranges overlap when neither is entirely above or below the other.
758    pub fn price_range_overlap(&self, other: &OhlcvBar) -> bool {
759        self.high >= other.low && other.high >= self.low
760    }
761
762    /// Bar height as a fraction of the open price: `(high - low) / open`.
763    ///
764    /// Returns `None` if `open` is zero. Useful for comparing volatility across
765    /// instruments trading at different price levels.
766    pub fn bar_height_pct(&self) -> Option<f64> {
767        use rust_decimal::prelude::ToPrimitive;
768        if self.open.is_zero() {
769            return None;
770        }
771        (self.range() / self.open).to_f64()
772    }
773
774    /// Classifies this bar as `"bullish"`, `"bearish"`, or `"doji"`.
775    ///
776    /// A doji is a bar whose body is zero (open equals close). Otherwise the
777    /// direction is determined by whether close is above or below open.
778    pub fn bar_type(&self) -> &'static str {
779        if self.close == self.open {
780            "doji"
781        } else if self.close > self.open {
782            "bullish"
783        } else {
784            "bearish"
785        }
786    }
787
788    /// Body as a percentage of the total high-low range.
789    ///
790    /// Returns `None` when the range is zero (all four prices equal).
791    /// A 100% body means no wicks (marubozu); near 0% means a doji.
792    pub fn body_pct(&self) -> Option<Decimal> {
793        let range = self.range();
794        if range.is_zero() {
795            return None;
796        }
797        Some(self.body() / range * Decimal::ONE_HUNDRED)
798    }
799
800    /// Returns `true` if this bar is a bullish hammer: a long lower wick,
801    /// small body near the top of the range, and little or no upper wick.
802    ///
803    /// Specifically: the lower wick is at least twice the body, and the upper
804    /// wick is no more than the body.
805    pub fn is_bullish_hammer(&self) -> bool {
806        let body = self.body();
807        if body.is_zero() {
808            return false;
809        }
810        let lower = self.wick_lower();
811        let upper = self.wick_upper();
812        lower >= body * Decimal::TWO && upper <= body
813    }
814
815    /// Upper wick as a percentage of the total range (0–100).
816    ///
817    /// Returns `None` when the range is zero.
818    pub fn upper_wick_pct(&self) -> Option<Decimal> {
819        let range = self.range();
820        if range.is_zero() {
821            return None;
822        }
823        Some(self.wick_upper() / range * Decimal::ONE_HUNDRED)
824    }
825
826    /// Lower wick as a percentage of the total range (0–100).
827    ///
828    /// Returns `None` when the range is zero.
829    pub fn lower_wick_pct(&self) -> Option<Decimal> {
830        let range = self.range();
831        if range.is_zero() {
832            return None;
833        }
834        Some(self.wick_lower() / range * Decimal::ONE_HUNDRED)
835    }
836
837    /// Returns `true` if this bar is a bearish engulfing candle relative to `prev`.
838    ///
839    /// A bearish engulfing has: current bar bearish, body entirely engulfs prev body.
840    pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
841        self.is_bearish() && self.is_engulfing(prev)
842    }
843
844    /// Returns `true` if this bar is a bullish engulfing candle relative to `prev`.
845    ///
846    /// A bullish engulfing has: current bar bullish, body entirely engulfs prev body.
847    pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
848        self.is_bullish() && self.is_engulfing(prev)
849    }
850
851    /// Gap between this bar's open and the previous bar's close: `self.open - prev.close`.
852    ///
853    /// A positive value indicates an upward gap; negative indicates a downward gap.
854    /// Alias for [`gap_from`](Self::gap_from).
855    #[deprecated(since = "2.2.0", note = "Use `gap_from()` instead")]
856    pub fn close_gap(&self, prev: &OhlcvBar) -> Decimal {
857        self.gap_from(prev)
858    }
859
860    /// Returns `true` if the close price is strictly above the bar's midpoint `(high + low) / 2`.
861    pub fn close_above_midpoint(&self) -> bool {
862        self.close > self.high_low_midpoint()
863    }
864
865    /// Price momentum: `self.close - prev.close`.
866    ///
867    /// Positive → price increased; negative → decreased.
868    pub fn close_momentum(&self, prev: &OhlcvBar) -> Decimal {
869        self.close - prev.close
870    }
871
872    /// Full high-low range of the bar: `high - low`.
873    ///
874    /// Alias for [`range`](Self::range).
875    #[deprecated(since = "2.2.0", note = "Use `range()` instead")]
876    pub fn bar_range(&self) -> Decimal {
877        self.range()
878    }
879
880    /// Duration of this bar's timeframe in milliseconds.
881    pub fn bar_duration_ms(&self) -> u64 {
882        self.timeframe.duration_ms()
883    }
884
885    /// Returns `true` if this bar resembles a gravestone doji.
886    ///
887    /// A gravestone doji has open ≈ close ≈ low (body within `epsilon` of
888    /// zero and close within `epsilon` of the low), with a long upper wick.
889    pub fn is_gravestone_doji(&self, epsilon: Decimal) -> bool {
890        self.body() <= epsilon && (self.close - self.low).abs() <= epsilon
891    }
892
893    /// Returns `true` if this bar resembles a dragonfly doji.
894    ///
895    /// A dragonfly doji has open ≈ close ≈ high (body within `epsilon` of
896    /// zero and close within `epsilon` of the high), with a long lower wick.
897    pub fn is_dragonfly_doji(&self, epsilon: Decimal) -> bool {
898        self.body() <= epsilon && (self.high - self.close).abs() <= epsilon
899    }
900
901    /// Returns `true` if this bar is completely flat (open == close == high == low).
902    ///
903    /// For a valid OHLCV bar, `range() == 0` is sufficient: since `low ≤ open, close ≤ high`,
904    /// `high == low` forces all four prices equal.
905    pub fn is_flat(&self) -> bool {
906        self.range().is_zero()
907    }
908
909    /// True range: `max(high - low, |high - prev_close|, |low - prev_close|)`.
910    ///
911    /// Alias for [`true_range`](Self::true_range).
912    #[deprecated(since = "2.2.0", note = "Use `true_range()` instead")]
913    pub fn true_range_with_prev(&self, prev_close: Decimal) -> Decimal {
914        self.true_range(prev_close)
915    }
916
917    /// Returns the ratio of close to high, or `None` if high is zero.
918    ///
919    /// Alias for [`high_close_ratio`](Self::high_close_ratio).
920    #[deprecated(since = "2.2.0", note = "Use `high_close_ratio()` instead")]
921    pub fn close_to_high_ratio(&self) -> Option<f64> {
922        self.high_close_ratio()
923    }
924
925    /// Returns the ratio of close to open, or `None` if open is zero.
926    ///
927    /// Alias for [`open_close_ratio`](Self::open_close_ratio).
928    #[deprecated(since = "2.2.0", note = "Use `open_close_ratio()` instead")]
929    pub fn close_open_ratio(&self) -> Option<f64> {
930        self.open_close_ratio()
931    }
932
933    /// Interpolates a price within the bar's high-low range.
934    ///
935    /// `pct = 0.0` returns `low`; `pct = 1.0` returns `high`.
936    /// Values outside `[0.0, 1.0]` are clamped to that interval.
937    pub fn price_at_pct(&self, pct: f64) -> Decimal {
938        use rust_decimal::prelude::FromPrimitive;
939        let pct_clamped = pct.clamp(0.0, 1.0);
940        let factor = Decimal::from_f64(pct_clamped).unwrap_or(Decimal::ZERO);
941        self.low + self.range() * factor
942    }
943
944    /// Average true range (ATR) across a slice of consecutive bars.
945    ///
946    /// Computes the mean of [`true_range`](Self::true_range) for bars `[1..]`,
947    /// using each bar's predecessor as the previous close. Returns `None` if
948    /// the slice has fewer than 2 bars.
949    pub fn average_true_range(bars: &[OhlcvBar]) -> Option<Decimal> {
950        if bars.len() < 2 {
951            return None;
952        }
953        let sum: Decimal = (1..bars.len())
954            .map(|i| bars[i].true_range(bars[i - 1].close))
955            .sum();
956        Some(sum / Decimal::from((bars.len() - 1) as u64))
957    }
958
959    /// Average body size across a slice of bars: mean of [`body`](Self::body) for each bar.
960    ///
961    /// Returns `None` if the slice is empty.
962    pub fn average_body(bars: &[OhlcvBar]) -> Option<Decimal> {
963        if bars.is_empty() {
964            return None;
965        }
966        let sum: Decimal = bars.iter().map(|b| b.body()).sum();
967        Some(sum / Decimal::from(bars.len() as u64))
968    }
969
970    /// Maximum `high` across a slice of bars.
971    ///
972    /// Returns `None` if the slice is empty. Useful for computing resistance
973    /// levels, swing highs, and ATH/period-high comparisons.
974    pub fn highest_high(bars: &[OhlcvBar]) -> Option<Decimal> {
975        bars.iter().map(|b| b.high).reduce(Decimal::max)
976    }
977
978    /// Minimum `low` across a slice of bars.
979    ///
980    /// Returns `None` if the slice is empty. Useful for computing support
981    /// levels, swing lows, and ATL/period-low comparisons.
982    pub fn lowest_low(bars: &[OhlcvBar]) -> Option<Decimal> {
983        bars.iter().map(|b| b.low).reduce(Decimal::min)
984    }
985
986    /// Maximum `close` across a slice of bars.
987    ///
988    /// Returns `None` if the slice is empty. Useful for identifying the
989    /// highest closing price within a lookback window.
990    pub fn highest_close(bars: &[OhlcvBar]) -> Option<Decimal> {
991        bars.iter().map(|b| b.close).reduce(Decimal::max)
992    }
993
994    /// Minimum `close` across a slice of bars.
995    ///
996    /// Returns `None` if the slice is empty. Useful for identifying the
997    /// lowest closing price within a lookback window.
998    pub fn lowest_close(bars: &[OhlcvBar]) -> Option<Decimal> {
999        bars.iter().map(|b| b.close).reduce(Decimal::min)
1000    }
1001
1002    /// Close price range: `highest_close − lowest_close` across a slice.
1003    ///
1004    /// Returns `None` if the slice is empty.
1005    pub fn close_range(bars: &[OhlcvBar]) -> Option<Decimal> {
1006        let hi = Self::highest_close(bars)?;
1007        let lo = Self::lowest_close(bars)?;
1008        Some(hi - lo)
1009    }
1010
1011    /// N-period price momentum: `(close[last] / close[last - n]) − 1`.
1012    ///
1013    /// Returns `None` if the slice has fewer than `n + 1` bars or if
1014    /// `close[last - n]` is zero.
1015    pub fn momentum(bars: &[OhlcvBar], n: usize) -> Option<f64> {
1016        use rust_decimal::prelude::ToPrimitive;
1017        let len = bars.len();
1018        if len <= n {
1019            return None;
1020        }
1021        let current = bars[len - 1].close;
1022        let prior = bars[len - 1 - n].close;
1023        if prior.is_zero() {
1024            return None;
1025        }
1026        ((current - prior) / prior).to_f64()
1027    }
1028
1029    /// Total traded volume across a slice of bars.
1030    ///
1031    /// Returns `Decimal::ZERO` for an empty slice. Complements
1032    /// [`mean_volume`](Self::mean_volume) when the sum rather than the average
1033    /// is needed.
1034    pub fn sum_volume(bars: &[OhlcvBar]) -> Decimal {
1035        bars.iter().map(|b| b.volume).sum()
1036    }
1037
1038    /// Count of bullish bars (close > open) in a slice.
1039    pub fn bullish_count(bars: &[OhlcvBar]) -> usize {
1040        bars.iter().filter(|b| b.is_bullish()).count()
1041    }
1042
1043    /// Count of bearish bars (close < open) in a slice.
1044    pub fn bearish_count(bars: &[OhlcvBar]) -> usize {
1045        bars.iter().filter(|b| b.is_bearish()).count()
1046    }
1047
1048    /// Length of the current bullish streak at the end of `bars`.
1049    ///
1050    /// Counts consecutive bullish bars (`close > open`) from the tail of the
1051    /// slice. Returns `0` if the last bar is not bullish or the slice is empty.
1052    pub fn bullish_streak(bars: &[OhlcvBar]) -> usize {
1053        bars.iter().rev().take_while(|b| b.is_bullish()).count()
1054    }
1055
1056    /// Length of the current bearish streak at the end of `bars`.
1057    ///
1058    /// Counts consecutive bearish bars (`close < open`) from the tail of the
1059    /// slice. Returns `0` if the last bar is not bearish or the slice is empty.
1060    pub fn bearish_streak(bars: &[OhlcvBar]) -> usize {
1061        bars.iter().rev().take_while(|b| b.is_bearish()).count()
1062    }
1063
1064    /// Fraction of bullish bars in a slice: `bullish_count / total`.
1065    ///
1066    /// Returns `None` if the slice is empty. Result is in `[0.0, 1.0]`.
1067    pub fn win_rate(bars: &[OhlcvBar]) -> Option<f64> {
1068        if bars.is_empty() {
1069            return None;
1070        }
1071        Some(Self::bullish_count(bars) as f64 / bars.len() as f64)
1072    }
1073
1074    /// Maximum drawdown of close prices across a slice of bars.
1075    ///
1076    /// Computed as the largest percentage decline from a running close peak:
1077    /// `max_drawdown = max over i of (peak_close_before_i - close_i) / peak_close_before_i`.
1078    ///
1079    /// Returns `None` if the slice has fewer than 2 bars or if all closes are zero.
1080    /// Result is in `[0.0, ∞)` — a value of `0.05` means a 5% drawdown.
1081    pub fn max_drawdown(bars: &[OhlcvBar]) -> Option<f64> {
1082        use rust_decimal::prelude::ToPrimitive;
1083        if bars.len() < 2 {
1084            return None;
1085        }
1086        let mut peak = bars[0].close;
1087        let mut max_dd = 0.0_f64;
1088        for bar in &bars[1..] {
1089            if bar.close > peak {
1090                peak = bar.close;
1091            } else if !peak.is_zero() {
1092                let dd = ((peak - bar.close) / peak).to_f64().unwrap_or(0.0);
1093                if dd > max_dd {
1094                    max_dd = dd;
1095                }
1096            }
1097        }
1098        Some(max_dd)
1099    }
1100
1101    /// Ordinary least-squares slope of close prices across a slice of bars.
1102    ///
1103    /// Fits the line `close[i] = slope × i + intercept` using simple linear
1104    /// regression, where `i` is the bar index (0-based). A positive slope
1105    /// indicates an upward trend; negative indicates a downtrend.
1106    ///
1107    /// Returns `None` if the slice has fewer than 2 bars or if the closes
1108    /// cannot be converted to `f64`.
1109    pub fn linear_regression_slope(bars: &[OhlcvBar]) -> Option<f64> {
1110        use rust_decimal::prelude::ToPrimitive;
1111        let ys: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
1112        Self::ols_slope_indexed(&ys, bars.len())
1113    }
1114
1115    /// OLS linear regression slope of bar volumes over bar index.
1116    ///
1117    /// Positive means volume is trending up; negative means trending down.
1118    /// Returns `None` for fewer than 2 bars or if volumes can't be converted to `f64`.
1119    pub fn volume_slope(bars: &[OhlcvBar]) -> Option<f64> {
1120        use rust_decimal::prelude::ToPrimitive;
1121        let ys: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
1122        Self::ols_slope_indexed(&ys, bars.len())
1123    }
1124
1125    /// OLS slope of `ys` against integer indices `0..expected_n`.
1126    ///
1127    /// Returns `None` if `ys.len() < expected_n`, `expected_n < 2`, or the x-variance is zero.
1128    fn ols_slope_indexed(ys: &[f64], expected_n: usize) -> Option<f64> {
1129        if ys.len() < expected_n || expected_n < 2 {
1130            return None;
1131        }
1132        let n_f = expected_n as f64;
1133        let x_mean = (n_f - 1.0) / 2.0;
1134        let y_mean = ys.iter().sum::<f64>() / n_f;
1135        let numerator: f64 = ys.iter().enumerate().map(|(i, y)| (i as f64 - x_mean) * (y - y_mean)).sum();
1136        let denominator: f64 = ys.iter().enumerate().map(|(i, _)| (i as f64 - x_mean).powi(2)).sum();
1137        if denominator == 0.0 {
1138            return None;
1139        }
1140        Some(numerator / denominator)
1141    }
1142
1143    /// Arithmetic mean of close prices across a slice of bars.
1144    ///
1145    /// Returns `None` if the slice is empty.
1146    pub fn mean_close(bars: &[OhlcvBar]) -> Option<Decimal> {
1147        if bars.is_empty() {
1148            return None;
1149        }
1150        let sum: Decimal = bars.iter().map(|b| b.close).sum();
1151        Some(sum / Decimal::from(bars.len() as u64))
1152    }
1153
1154    /// Population standard deviation of close prices across a slice of bars.
1155    ///
1156    /// Returns `None` if the slice has fewer than 2 bars or if closes cannot
1157    /// be converted to `f64`.
1158    pub fn close_std_dev(bars: &[OhlcvBar]) -> Option<f64> {
1159        use rust_decimal::prelude::ToPrimitive;
1160        let n = bars.len();
1161        if n < 2 {
1162            return None;
1163        }
1164        let mean = Self::mean_close(bars)?.to_f64()?;
1165        let variance: f64 = bars.iter()
1166            .filter_map(|b| b.close.to_f64())
1167            .map(|c| (c - mean).powi(2))
1168            .sum::<f64>() / n as f64;
1169        Some(variance.sqrt())
1170    }
1171
1172    /// Elder's efficiency ratio: `|close[last] − close[first]| / Σ|range(bar)|`.
1173    ///
1174    /// Measures how directionally efficient price movement is across the slice.
1175    /// A value close to 1 means price moved cleanly; near 0 means choppy.
1176    /// Returns `None` if the slice has fewer than 2 bars or total range is zero.
1177    pub fn price_efficiency_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1178        use rust_decimal::prelude::ToPrimitive;
1179        let n = bars.len();
1180        if n < 2 {
1181            return None;
1182        }
1183        let net_move = (bars[n - 1].close - bars[0].close).abs();
1184        let total_path: Decimal = bars.iter().map(|b| b.range()).sum();
1185        if total_path.is_zero() {
1186            return None;
1187        }
1188        (net_move / total_path).to_f64()
1189    }
1190
1191    /// Mean CLV across a slice of bars; `None` for an empty slice or
1192    /// if any CLV cannot be computed.
1193    pub fn mean_clv(bars: &[OhlcvBar]) -> Option<f64> {
1194        if bars.is_empty() {
1195            return None;
1196        }
1197        let clvs: Vec<f64> = bars.iter().filter_map(|b| b.close_location_value()).collect();
1198        if clvs.is_empty() {
1199            return None;
1200        }
1201        Some(clvs.iter().sum::<f64>() / clvs.len() as f64)
1202    }
1203
1204    /// Mean of the high-low range (H − L) across the slice.
1205    ///
1206    /// Returns `None` if the slice is empty.
1207    pub fn mean_range(bars: &[OhlcvBar]) -> Option<Decimal> {
1208        if bars.is_empty() {
1209            return None;
1210        }
1211        let total: Decimal = bars.iter().map(|b| b.range()).sum();
1212        Some(total / Decimal::from(bars.len() as u64))
1213    }
1214
1215    /// Z-score of `value` relative to the close price series.
1216    ///
1217    /// Returns `None` if the slice has fewer than 2 bars or the standard
1218    /// deviation is zero.
1219    pub fn close_z_score(bars: &[OhlcvBar], value: Decimal) -> Option<f64> {
1220        use rust_decimal::prelude::ToPrimitive;
1221        let mean = Self::mean_close(bars)?;
1222        let std_dev = Self::close_std_dev(bars)?;
1223        if std_dev == 0.0 {
1224            return None;
1225        }
1226        ((value - mean) / Decimal::try_from(std_dev).ok()?).to_f64()
1227    }
1228
1229    /// Normalised Bollinger Band width: `2 × close_std_dev / mean_close`.
1230    ///
1231    /// Returns `None` if `mean_close` is zero or the slice is too small.
1232    pub fn bollinger_band_width(bars: &[OhlcvBar]) -> Option<f64> {
1233        use rust_decimal::prelude::ToPrimitive;
1234        let mean = Self::mean_close(bars)?;
1235        if mean.is_zero() {
1236            return None;
1237        }
1238        let std_dev = Self::close_std_dev(bars)?;
1239        let width = 2.0 * std_dev / mean.to_f64()?;
1240        Some(width)
1241    }
1242
1243    /// Ratio of bullish bars to bearish bars in the slice.
1244    ///
1245    /// Returns `None` if there are no bearish bars.
1246    pub fn up_down_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1247        let down = Self::bearish_count(bars);
1248        if down == 0 {
1249            return None;
1250        }
1251        Some(Self::bullish_count(bars) as f64 / down as f64)
1252    }
1253
1254    /// Volume-weighted average close price across the slice.
1255    ///
1256    /// Returns `None` if the slice is empty or total volume is zero.
1257    pub fn volume_weighted_close(bars: &[OhlcvBar]) -> Option<Decimal> {
1258        let total_volume = Self::sum_volume(bars);
1259        if total_volume.is_zero() {
1260            return None;
1261        }
1262        let weighted_sum: Decimal = bars.iter().map(|b| b.close * b.volume).sum();
1263        Some(weighted_sum / total_volume)
1264    }
1265
1266    /// Percentage change in close price from the first bar to the last.
1267    ///
1268    /// Returns `None` if the slice has fewer than 2 bars or the first
1269    /// close is zero.
1270    pub fn rolling_return(bars: &[OhlcvBar]) -> Option<f64> {
1271        use rust_decimal::prelude::ToPrimitive;
1272        let n = bars.len();
1273        if n < 2 {
1274            return None;
1275        }
1276        let first = bars[0].close;
1277        let last = bars[n - 1].close;
1278        if first.is_zero() {
1279            return None;
1280        }
1281        ((last - first) / first).to_f64()
1282    }
1283
1284    /// Mean of the high prices across the slice.
1285    ///
1286    /// Returns `None` if the slice is empty.
1287    pub fn average_high(bars: &[OhlcvBar]) -> Option<Decimal> {
1288        if bars.is_empty() {
1289            return None;
1290        }
1291        let total: Decimal = bars.iter().map(|b| b.high).sum();
1292        Some(total / Decimal::from(bars.len() as u64))
1293    }
1294
1295    /// Mean of the low prices across the slice.
1296    ///
1297    /// Returns `None` if the slice is empty.
1298    pub fn average_low(bars: &[OhlcvBar]) -> Option<Decimal> {
1299        if bars.is_empty() {
1300            return None;
1301        }
1302        let total: Decimal = bars.iter().map(|b| b.low).sum();
1303        Some(total / Decimal::from(bars.len() as u64))
1304    }
1305
1306    /// Minimum bar body size (|close − open|) across the slice.
1307    ///
1308    /// Returns `None` if the slice is empty.
1309    pub fn min_body(bars: &[OhlcvBar]) -> Option<Decimal> {
1310        bars.iter().map(|b| b.body()).reduce(Decimal::min)
1311    }
1312
1313    /// Maximum bar body size (|close − open|) across the slice.
1314    ///
1315    /// Returns `None` if the slice is empty.
1316    pub fn max_body(bars: &[OhlcvBar]) -> Option<Decimal> {
1317        bars.iter().map(|b| b.body()).reduce(Decimal::max)
1318    }
1319
1320    /// Average True Range expressed as a fraction of the mean close price.
1321    ///
1322    /// Returns `None` if the slice has fewer than 2 bars or mean close is zero.
1323    pub fn atr_pct(bars: &[OhlcvBar]) -> Option<f64> {
1324        use rust_decimal::prelude::ToPrimitive;
1325        let atr = Self::average_true_range(bars)?;
1326        let mean = Self::mean_close(bars)?;
1327        if mean.is_zero() {
1328            return None;
1329        }
1330        (atr / mean).to_f64()
1331    }
1332
1333    /// Count of bars (from index 1 onward) whose close strictly exceeds the
1334    /// previous bar's high — i.e., upside breakout bars.
1335    pub fn breakout_count(bars: &[OhlcvBar]) -> usize {
1336        if bars.len() < 2 {
1337            return 0;
1338        }
1339        bars.windows(2)
1340            .filter(|w| w[1].close > w[0].high)
1341            .count()
1342    }
1343
1344    /// Count of doji bars (|close − open| ≤ epsilon) in the slice.
1345    pub fn doji_count(bars: &[OhlcvBar], epsilon: Decimal) -> usize {
1346        bars.iter().filter(|b| b.is_doji(epsilon)).count()
1347    }
1348
1349    /// Full channel width: `highest_high − lowest_low` across the slice.
1350    ///
1351    /// Returns `None` if the slice is empty.
1352    pub fn channel_width(bars: &[OhlcvBar]) -> Option<Decimal> {
1353        let hi = Self::highest_high(bars)?;
1354        let lo = Self::lowest_low(bars)?;
1355        Some(hi - lo)
1356    }
1357
1358    /// Simple moving average of the last `n` close prices.
1359    ///
1360    /// Returns `None` if `n` is zero or the slice has fewer than `n` bars.
1361    pub fn sma(bars: &[OhlcvBar], n: usize) -> Option<Decimal> {
1362        if n == 0 || bars.len() < n {
1363            return None;
1364        }
1365        let window = &bars[bars.len() - n..];
1366        let sum: Decimal = window.iter().map(|b| b.close).sum();
1367        Some(sum / Decimal::from(n as u64))
1368    }
1369
1370    /// Mean wick ratio (upper_wick + lower_wick) / range across the slice.
1371    ///
1372    /// Bars where range is zero are excluded. Returns `None` if the slice is
1373    /// empty or all bars have zero range.
1374    pub fn mean_wick_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1375        let ratios: Vec<f64> = bars.iter().filter_map(|b| b.wick_ratio()).collect();
1376        if ratios.is_empty() {
1377            return None;
1378        }
1379        Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
1380    }
1381
1382    /// Total volume of all bullish (close ≥ open) bars in the slice.
1383    pub fn bullish_volume(bars: &[OhlcvBar]) -> Decimal {
1384        bars.iter().filter(|b| b.is_bullish()).map(|b| b.volume).sum()
1385    }
1386
1387    /// Total volume of all bearish (close < open) bars in the slice.
1388    pub fn bearish_volume(bars: &[OhlcvBar]) -> Decimal {
1389        bars.iter().filter(|b| b.is_bearish()).map(|b| b.volume).sum()
1390    }
1391
1392    /// Count of bars where close is strictly above the bar midpoint ((high + low) / 2).
1393    pub fn close_above_mid_count(bars: &[OhlcvBar]) -> usize {
1394        bars.iter().filter(|b| b.close > b.high_low_midpoint()).count()
1395    }
1396
1397    /// Exponential moving average of close prices over the slice.
1398    ///
1399    /// `alpha` is the smoothing factor in (0.0, 1.0]; higher values weight
1400    /// recent bars more. Processes bars in order (oldest to newest).
1401    /// Returns `None` if the slice is empty.
1402    pub fn ema(bars: &[OhlcvBar], alpha: f64) -> Option<f64> {
1403        use rust_decimal::prelude::ToPrimitive;
1404        let alpha = alpha.clamp(1e-9, 1.0);
1405        let mut iter = bars.iter();
1406        let first = iter.next()?.close.to_f64()?;
1407        let result = iter.fold(first, |acc, b| {
1408            let c = b.close.to_f64().unwrap_or(acc);
1409            alpha * c + (1.0 - alpha) * acc
1410        });
1411        Some(result)
1412    }
1413
1414    /// Maximum open price across the slice.
1415    ///
1416    /// Returns `None` if the slice is empty.
1417    pub fn highest_open(bars: &[OhlcvBar]) -> Option<Decimal> {
1418        bars.iter().map(|b| b.open).reduce(Decimal::max)
1419    }
1420
1421    /// Minimum open price across the slice.
1422    ///
1423    /// Returns `None` if the slice is empty.
1424    pub fn lowest_open(bars: &[OhlcvBar]) -> Option<Decimal> {
1425        bars.iter().map(|b| b.open).reduce(Decimal::min)
1426    }
1427
1428    /// Count of bars (from index 1 onward) where close is strictly greater
1429    /// than the previous bar's close.
1430    pub fn rising_close_count(bars: &[OhlcvBar]) -> usize {
1431        if bars.len() < 2 {
1432            return 0;
1433        }
1434        bars.windows(2).filter(|w| w[1].close > w[0].close).count()
1435    }
1436
1437    /// Mean body-to-range ratio across the slice.
1438    ///
1439    /// Bars with zero range are excluded.
1440    /// Returns `None` if the slice is empty or all bars have zero range.
1441    pub fn mean_body_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1442        let ratios: Vec<f64> = bars.iter().filter_map(|b| b.body_ratio()).collect();
1443        if ratios.is_empty() {
1444            return None;
1445        }
1446        Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
1447    }
1448
1449    /// Sample standard deviation of bar volumes across the slice.
1450    ///
1451    /// Returns `None` if the slice has fewer than 2 bars.
1452    pub fn volume_std_dev(bars: &[OhlcvBar]) -> Option<f64> {
1453        use rust_decimal::prelude::ToPrimitive;
1454        let n = bars.len();
1455        if n < 2 {
1456            return None;
1457        }
1458        let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
1459        if vols.len() < 2 {
1460            return None;
1461        }
1462        let mean = vols.iter().sum::<f64>() / vols.len() as f64;
1463        let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (vols.len() - 1) as f64;
1464        Some(variance.sqrt())
1465    }
1466
1467    /// Bar with the highest volume in the slice.
1468    ///
1469    /// Returns `None` if the slice is empty.
1470    pub fn max_volume_bar(bars: &[OhlcvBar]) -> Option<&OhlcvBar> {
1471        bars.iter().max_by(|a, b| a.volume.cmp(&b.volume))
1472    }
1473
1474    /// Bar with the lowest volume in the slice.
1475    ///
1476    /// Returns `None` if the slice is empty.
1477    pub fn min_volume_bar(bars: &[OhlcvBar]) -> Option<&OhlcvBar> {
1478        bars.iter().min_by(|a, b| a.volume.cmp(&b.volume))
1479    }
1480
1481    /// Sum of gap-open amounts: Σ (open[n] − close[n−1]) for n ≥ 1.
1482    ///
1483    /// A positive value indicates upward gaps dominate; negative means downward.
1484    pub fn gap_sum(bars: &[OhlcvBar]) -> Decimal {
1485        if bars.len() < 2 {
1486            return Decimal::ZERO;
1487        }
1488        bars.windows(2).map(|w| w[1].open - w[0].close).sum()
1489    }
1490
1491    /// Returns `true` if the last three bars form a "three white soldiers" pattern:
1492    /// three consecutive bullish (close > open) bars, each closing above the prior bar's close.
1493    pub fn three_white_soldiers(bars: &[OhlcvBar]) -> bool {
1494        if bars.len() < 3 {
1495            return false;
1496        }
1497        let last3 = &bars[bars.len() - 3..];
1498        last3[0].close > last3[0].open
1499            && last3[1].close > last3[1].open
1500            && last3[2].close > last3[2].open
1501            && last3[1].close > last3[0].close
1502            && last3[2].close > last3[1].close
1503    }
1504
1505    /// Returns `true` if the last three bars form a "three black crows" pattern:
1506    /// three consecutive bearish (close < open) bars, each closing below the prior bar's close.
1507    pub fn three_black_crows(bars: &[OhlcvBar]) -> bool {
1508        if bars.len() < 3 {
1509            return false;
1510        }
1511        let last3 = &bars[bars.len() - 3..];
1512        last3[0].close < last3[0].open
1513            && last3[1].close < last3[1].open
1514            && last3[2].close < last3[2].open
1515            && last3[1].close < last3[0].close
1516            && last3[2].close < last3[1].close
1517    }
1518
1519    /// Returns `true` if the bar opened with a gap relative to `prev_close`
1520    /// (i.e. open != prev_close).
1521    pub fn is_gap_bar(bar: &OhlcvBar, prev_close: Decimal) -> bool {
1522        bar.open != prev_close
1523    }
1524
1525    /// Counts consecutive-pair windows where a gap exists (open != prior close).
1526    pub fn gap_bars_count(bars: &[OhlcvBar]) -> usize {
1527        if bars.len() < 2 {
1528            return 0;
1529        }
1530        bars.windows(2).filter(|w| w[1].open != w[0].close).count()
1531    }
1532
1533    /// Bar efficiency: ratio of net price move to total range.
1534    ///
1535    /// `(close - open).abs() / (high - low)`.  Returns `None` for a zero-range bar.
1536    pub fn bar_efficiency(bar: &OhlcvBar) -> Option<f64> {
1537        use rust_decimal::prelude::ToPrimitive;
1538        let range = bar.range();
1539        if range.is_zero() {
1540            return None;
1541        }
1542        (bar.body() / range).to_f64()
1543    }
1544
1545    /// Sum of upper and lower wick lengths for each bar.
1546    ///
1547    /// Upper wick = `high - close.max(open)`, lower wick = `close.min(open) - low`.
1548    pub fn wicks_sum(bars: &[OhlcvBar]) -> Decimal {
1549        bars.iter().map(|b| b.wick_upper() + b.wick_lower()).sum()
1550    }
1551
1552    /// Mean of `(high - close)` across all bars — average distance from close to high.
1553    ///
1554    /// Returns `None` for an empty slice.
1555    pub fn avg_close_to_high(bars: &[OhlcvBar]) -> Option<f64> {
1556        use rust_decimal::prelude::ToPrimitive;
1557        if bars.is_empty() {
1558            return None;
1559        }
1560        let sum: Decimal = bars.iter().map(|b| b.high - b.close).sum();
1561        (sum / Decimal::from(bars.len() as u32)).to_f64()
1562    }
1563
1564    /// Mean of `(high - low)` across all bars.
1565    ///
1566    /// Returns `None` for an empty slice.
1567    pub fn avg_range(bars: &[OhlcvBar]) -> Option<f64> {
1568        use rust_decimal::prelude::ToPrimitive;
1569        if bars.is_empty() {
1570            return None;
1571        }
1572        let sum: Decimal = bars.iter().map(|b| b.range()).sum();
1573        (sum / Decimal::from(bars.len() as u32)).to_f64()
1574    }
1575
1576    /// Maximum close price in the slice.
1577    ///
1578    /// Returns `None` for an empty slice.
1579    pub fn max_close(bars: &[OhlcvBar]) -> Option<Decimal> {
1580        bars.iter().map(|b| b.close).reduce(Decimal::max)
1581    }
1582
1583    /// Minimum close price in the slice.
1584    ///
1585    /// Returns `None` for an empty slice.
1586    pub fn min_close(bars: &[OhlcvBar]) -> Option<Decimal> {
1587        bars.iter().map(|b| b.close).reduce(Decimal::min)
1588    }
1589
1590    /// Trend strength: fraction of consecutive close-to-close moves that are upward.
1591    ///
1592    /// Returns `None` if fewer than 2 bars.
1593    pub fn trend_strength(bars: &[OhlcvBar]) -> Option<f64> {
1594        if bars.len() < 2 {
1595            return None;
1596        }
1597        let moves = bars.len() - 1;
1598        let up = bars.windows(2).filter(|w| w[1].close > w[0].close).count();
1599        Some(up as f64 / moves as f64)
1600    }
1601
1602    /// Net price change: `close - open` for the last bar.
1603    ///
1604    /// Returns `None` for an empty slice.
1605    pub fn net_change(bars: &[OhlcvBar]) -> Option<Decimal> {
1606        bars.last().map(|b| b.price_change())
1607    }
1608
1609    /// Percentage change from open to close for the last bar: `(close - open) / open * 100`.
1610    ///
1611    /// Returns `None` for an empty slice or zero open.
1612    pub fn open_to_close_pct(bars: &[OhlcvBar]) -> Option<f64> {
1613        use rust_decimal::prelude::ToPrimitive;
1614        let bar = bars.last()?;
1615        if bar.open.is_zero() {
1616            return None;
1617        }
1618        (bar.price_change() / bar.open * Decimal::ONE_HUNDRED).to_f64()
1619    }
1620
1621    /// Percentage of high-to-low range relative to high: `(high - low) / high * 100`.
1622    ///
1623    /// Returns `None` for an empty slice or zero high.
1624    pub fn high_to_low_pct(bars: &[OhlcvBar]) -> Option<f64> {
1625        use rust_decimal::prelude::ToPrimitive;
1626        let bar = bars.last()?;
1627        if bar.high.is_zero() {
1628            return None;
1629        }
1630        (bar.range() / bar.high * Decimal::ONE_HUNDRED).to_f64()
1631    }
1632
1633    /// Count of consecutive bars (from the end) where `high` is strictly higher than the prior `high`.
1634    pub fn consecutive_highs(bars: &[OhlcvBar]) -> usize {
1635        if bars.len() < 2 {
1636            return 0;
1637        }
1638        let mut count = 0;
1639        for w in bars.windows(2).rev() {
1640            if w[1].high > w[0].high {
1641                count += 1;
1642            } else {
1643                break;
1644            }
1645        }
1646        count
1647    }
1648
1649    /// Count of consecutive bars (from the end) where `low` is strictly lower than the prior `low`.
1650    pub fn consecutive_lows(bars: &[OhlcvBar]) -> usize {
1651        if bars.len() < 2 {
1652            return 0;
1653        }
1654        let mut count = 0;
1655        for w in bars.windows(2).rev() {
1656            if w[1].low < w[0].low {
1657                count += 1;
1658            } else {
1659                break;
1660            }
1661        }
1662        count
1663    }
1664
1665    /// Percentage change in volume from one bar to the next (last bar vs prior bar).
1666    ///
1667    /// Returns `None` if fewer than 2 bars or prior volume is zero.
1668    pub fn volume_change_pct(bars: &[OhlcvBar]) -> Option<f64> {
1669        use rust_decimal::prelude::ToPrimitive;
1670        if bars.len() < 2 {
1671            return None;
1672        }
1673        let prior = bars[bars.len() - 2].volume;
1674        if prior.is_zero() {
1675            return None;
1676        }
1677        let current = bars[bars.len() - 1].volume;
1678        ((current - prior) / prior * Decimal::ONE_HUNDRED).to_f64()
1679    }
1680
1681    /// Gap percentage between consecutive bars: `(bar.open - prev.close) / prev.close * 100`.
1682    ///
1683    /// Returns `None` if fewer than 2 bars or previous close is zero.
1684    pub fn open_gap_pct(bars: &[OhlcvBar]) -> Option<f64> {
1685        use rust_decimal::prelude::ToPrimitive;
1686        if bars.len() < 2 {
1687            return None;
1688        }
1689        let prev_close = bars[bars.len() - 2].close;
1690        if prev_close.is_zero() {
1691            return None;
1692        }
1693        let current_open = bars[bars.len() - 1].open;
1694        ((current_open - prev_close) / prev_close * Decimal::ONE_HUNDRED).to_f64()
1695    }
1696
1697    /// Cumulative volume across all bars.
1698    pub fn volume_cumulative(bars: &[OhlcvBar]) -> Decimal {
1699        bars.iter().map(|b| b.volume).sum()
1700    }
1701
1702    /// Position of the last bar's close within the overall high-low range of all bars.
1703    ///
1704    /// Returns `(close - lowest_low) / (highest_high - lowest_low)`.
1705    /// Returns `None` if the slice is empty or range is zero.
1706    pub fn price_position(bars: &[OhlcvBar]) -> Option<f64> {
1707        use rust_decimal::prelude::ToPrimitive;
1708        let hi = Self::highest_high(bars)?;
1709        let lo = Self::lowest_low(bars)?;
1710        let range = hi - lo;
1711        if range.is_zero() {
1712            return None;
1713        }
1714        let last_close = bars.last()?.close;
1715        ((last_close - lo) / range).to_f64()
1716    }
1717
1718    /// Returns `true` if the last `n` closes form a strict uptrend.
1719    ///
1720    /// Each close must be strictly greater than the previous. Returns `false`
1721    /// if `n < 2` or the slice has fewer than `n` bars.
1722    pub fn is_trending_up(bars: &[OhlcvBar], n: usize) -> bool {
1723        if n < 2 || bars.len() < n {
1724            return false;
1725        }
1726        bars[bars.len() - n..].windows(2).all(|w| w[1].close > w[0].close)
1727    }
1728
1729    /// Returns `true` if the last `n` closes form a strict downtrend.
1730    ///
1731    /// Each close must be strictly less than the previous. Returns `false`
1732    /// if `n < 2` or the slice has fewer than `n` bars.
1733    pub fn is_trending_down(bars: &[OhlcvBar], n: usize) -> bool {
1734        if n < 2 || bars.len() < n {
1735            return false;
1736        }
1737        bars[bars.len() - n..].windows(2).all(|w| w[1].close < w[0].close)
1738    }
1739
1740    /// Percentage change in volume between the last two bars.
1741    ///
1742    /// Returns `None` if fewer than 2 bars or if the previous bar's volume is
1743    /// zero.
1744    pub fn volume_acceleration(bars: &[OhlcvBar]) -> Option<f64> {
1745        use rust_decimal::prelude::ToPrimitive;
1746        if bars.len() < 2 {
1747            return None;
1748        }
1749        let prev = bars[bars.len() - 2].volume;
1750        if prev.is_zero() {
1751            return None;
1752        }
1753        let curr = bars[bars.len() - 1].volume;
1754        ((curr - prev) / prev * Decimal::ONE_HUNDRED).to_f64()
1755    }
1756
1757    /// Mean ratio of total wick length to body size across all bars.
1758    ///
1759    /// For each bar: `(upper_wick + lower_wick) / body`. Bars with a zero body
1760    /// are skipped. Returns `None` if no valid bars exist.
1761    pub fn wick_body_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1762        use rust_decimal::prelude::ToPrimitive;
1763        let valid: Vec<f64> = bars.iter().filter_map(|b| {
1764            let body = b.body();
1765            if body.is_zero() {
1766                return None;
1767            }
1768            let wicks = b.wick_upper() + b.wick_lower();
1769            (wicks / body).to_f64()
1770        }).collect();
1771        if valid.is_empty() {
1772            return None;
1773        }
1774        Some(valid.iter().sum::<f64>() / valid.len() as f64)
1775    }
1776
1777    /// Count of bars where `close > open` (bullish bars).
1778    pub fn close_above_open_count(bars: &[OhlcvBar]) -> usize {
1779        bars.iter().filter(|b| b.close > b.open).count()
1780    }
1781
1782    /// Pearson correlation between per-bar volume and close price.
1783    ///
1784    /// Returns `None` if fewer than 2 bars or if either series has zero
1785    /// variance.
1786    pub fn volume_price_correlation(bars: &[OhlcvBar]) -> Option<f64> {
1787        use rust_decimal::prelude::ToPrimitive;
1788        let n = bars.len();
1789        if n < 2 {
1790            return None;
1791        }
1792        let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
1793        let closes: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
1794        if vols.len() != n || closes.len() != n {
1795            return None;
1796        }
1797        let nf = n as f64;
1798        let mean_v = vols.iter().sum::<f64>() / nf;
1799        let mean_c = closes.iter().sum::<f64>() / nf;
1800        let cov: f64 = vols.iter().zip(closes.iter()).map(|(v, c)| (v - mean_v) * (c - mean_c)).sum::<f64>() / nf;
1801        let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / nf).sqrt();
1802        let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
1803        if std_v == 0.0 || std_c == 0.0 {
1804            return None;
1805        }
1806        Some(cov / (std_v * std_c))
1807    }
1808
1809    /// Fraction of bars where body size exceeds 50% of the bar's total range.
1810    ///
1811    /// Returns `None` if the slice is empty or all bars have zero range.
1812    pub fn body_consistency(bars: &[OhlcvBar]) -> Option<f64> {
1813        if bars.is_empty() {
1814            return None;
1815        }
1816        let valid: Vec<_> = bars.iter().filter(|b| !b.range().is_zero()).collect();
1817        if valid.is_empty() {
1818            return None;
1819        }
1820        let consistent = valid.iter().filter(|b| {
1821            b.body() * Decimal::TWO > b.range()
1822        }).count();
1823        Some(consistent as f64 / valid.len() as f64)
1824    }
1825
1826    /// Coefficient of variation of close prices: `std_dev(close) / mean(close)`.
1827    ///
1828    /// A dimensionless measure of close price dispersion. Returns `None` if
1829    /// fewer than 2 bars or if mean close is zero.
1830    pub fn close_volatility_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1831        use rust_decimal::prelude::ToPrimitive;
1832        let mean = Self::mean_close(bars)?;
1833        if mean.is_zero() {
1834            return None;
1835        }
1836        let std = Self::close_std_dev(bars)?;
1837        let mean_f = mean.to_f64()?;
1838        Some(std / mean_f.abs())
1839    }
1840
1841    /// Rolling close momentum score: fraction of bars where close is above the
1842    /// simple average close of the window.
1843    ///
1844    /// Returns `None` if the slice is empty or the mean cannot be computed.
1845    pub fn close_momentum_score(bars: &[OhlcvBar]) -> Option<f64> {
1846        let mean = Self::mean_close(bars)?;
1847        let above = bars.iter().filter(|b| b.close > mean).count();
1848        Some(above as f64 / bars.len() as f64)
1849    }
1850
1851    /// Count of bars where the range (high - low) exceeds the preceding bar's
1852    /// range (i.e., the bar "expands" relative to the prior bar).
1853    ///
1854    /// Returns 0 if fewer than 2 bars.
1855    pub fn range_expansion_count(bars: &[OhlcvBar]) -> usize {
1856        if bars.len() < 2 {
1857            return 0;
1858        }
1859        bars.windows(2).filter(|w| w[1].range() > w[0].range()).count()
1860    }
1861
1862    /// Count of bars where the open gaps away from the previous bar's close
1863    /// (absolute gap > zero, i.e., `open != prev_close`).
1864    pub fn gap_count(bars: &[OhlcvBar]) -> usize {
1865        if bars.len() < 2 {
1866            return 0;
1867        }
1868        bars.windows(2).filter(|w| w[1].open != w[0].close).count()
1869    }
1870
1871    /// Mean total wick size (upper + lower wick) across all bars.
1872    ///
1873    /// Returns `None` if the slice is empty.
1874    pub fn avg_wick_size(bars: &[OhlcvBar]) -> Option<f64> {
1875        use rust_decimal::prelude::ToPrimitive;
1876        if bars.is_empty() {
1877            return None;
1878        }
1879        let total: f64 = bars.iter()
1880            .filter_map(|b| (b.wick_upper() + b.wick_lower()).to_f64())
1881            .sum();
1882        Some(total / bars.len() as f64)
1883    }
1884
1885    /// Ratio of each bar's volume to the mean volume of the window.
1886    ///
1887    /// Returns a `Vec` of `Option<f64>` — `None` entries indicate bars where
1888    /// the mean cannot be computed (e.g., empty slice) or the conversion
1889    /// failed. Returns an empty `Vec` for an empty slice.
1890    pub fn mean_volume_ratio(bars: &[OhlcvBar]) -> Vec<Option<f64>> {
1891        use rust_decimal::prelude::ToPrimitive;
1892        if bars.is_empty() {
1893            return vec![];
1894        }
1895        let mean = match Self::mean_volume(bars) {
1896            Some(m) if !m.is_zero() => m,
1897            _ => return bars.iter().map(|_| None).collect(),
1898        };
1899        bars.iter().map(|b| (b.volume / mean).to_f64()).collect()
1900    }
1901
1902    /// Count of bars where `close > n`-bar simple moving average of highs.
1903    ///
1904    /// Returns 0 if `n < 1` or the slice has fewer than `n` bars.
1905    pub fn close_above_high_ma(bars: &[OhlcvBar], n: usize) -> usize {
1906        if n < 1 || bars.len() < n {
1907            return 0;
1908        }
1909        let high_ma: Decimal = bars.iter().take(n).map(|b| b.high).sum::<Decimal>()
1910            / Decimal::from(n as u32);
1911        bars[n - 1..].iter().filter(|b| b.close > high_ma).count()
1912    }
1913
1914    /// Price compression ratio: `mean_body / mean_range`.
1915    ///
1916    /// A value near 1 indicates full-body candles (strong directional moves);
1917    /// near 0 indicates indecision or doji-heavy periods. Returns `None` if
1918    /// the slice is empty or mean range is zero.
1919    pub fn price_compression_ratio(bars: &[OhlcvBar]) -> Option<f64> {
1920        use rust_decimal::prelude::ToPrimitive;
1921        let mean_body = Self::average_body(bars)?;
1922        let mean_range = Self::mean_range(bars)?;
1923        if mean_range.is_zero() {
1924            return None;
1925        }
1926        (mean_body / mean_range).to_f64()
1927    }
1928
1929    /// Mean absolute difference between open and close across all bars.
1930    ///
1931    /// Returns `None` if the slice is empty.
1932    pub fn open_close_spread(bars: &[OhlcvBar]) -> Option<f64> {
1933        use rust_decimal::prelude::ToPrimitive;
1934        if bars.is_empty() {
1935            return None;
1936        }
1937        let total: f64 = bars.iter()
1938            .filter_map(|b| (b.close - b.open).abs().to_f64())
1939            .sum();
1940        Some(total / bars.len() as f64)
1941    }
1942
1943    /// Longest run of consecutive bars with strictly rising closes.
1944    pub fn max_consecutive_gains(bars: &[OhlcvBar]) -> usize {
1945        let mut max_run = 0usize;
1946        let mut current = 0usize;
1947        for w in bars.windows(2) {
1948            if w[1].close > w[0].close {
1949                current += 1;
1950                if current > max_run {
1951                    max_run = current;
1952                }
1953            } else {
1954                current = 0;
1955            }
1956        }
1957        max_run
1958    }
1959
1960    /// Longest run of consecutive bars with strictly falling closes.
1961    pub fn max_consecutive_losses(bars: &[OhlcvBar]) -> usize {
1962        let mut max_run = 0usize;
1963        let mut current = 0usize;
1964        for w in bars.windows(2) {
1965            if w[1].close < w[0].close {
1966                current += 1;
1967                if current > max_run {
1968                    max_run = current;
1969                }
1970            } else {
1971                current = 0;
1972            }
1973        }
1974        max_run
1975    }
1976
1977    /// Total path length of close prices: sum of absolute consecutive changes.
1978    ///
1979    /// Measures how much the close price "travels" over the window. A high
1980    /// value relative to the net change indicates choppy price action.
1981    /// Returns `None` if fewer than 2 bars.
1982    pub fn price_path_length(bars: &[OhlcvBar]) -> Option<f64> {
1983        use rust_decimal::prelude::ToPrimitive;
1984        if bars.len() < 2 {
1985            return None;
1986        }
1987        let total: f64 = bars.windows(2)
1988            .filter_map(|w| (w[1].close - w[0].close).abs().to_f64())
1989            .sum();
1990        Some(total)
1991    }
1992
1993    /// Count of bars where the close reverts toward the window mean (i.e., the
1994    /// bar's close is between the previous close and the window mean close).
1995    ///
1996    /// Returns 0 if fewer than 2 bars or no mean can be computed.
1997    pub fn close_reversion_count(bars: &[OhlcvBar]) -> usize {
1998        let mean = match Self::mean_close(bars) {
1999            Some(m) => m,
2000            None => return 0,
2001        };
2002        if bars.len() < 2 {
2003            return 0;
2004        }
2005        bars.windows(2).filter(|w| {
2006            let prev = w[0].close;
2007            let curr = w[1].close;
2008            // Reverts if the current close is between prev and mean
2009            if prev < mean {
2010                curr > prev && curr <= mean
2011            } else {
2012                curr < prev && curr >= mean
2013            }
2014        }).count()
2015    }
2016
2017    /// ATR as a fraction of the mean close price.
2018    ///
2019    /// `ATR / mean_close * 100`. A dimensionless volatility measure. Returns
2020    /// `None` if fewer than 2 bars or if mean close is zero.
2021    pub fn atr_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2022        use rust_decimal::prelude::ToPrimitive;
2023        let atr = Self::average_true_range(bars)?;
2024        let mean = Self::mean_close(bars)?;
2025        if mean.is_zero() {
2026            return None;
2027        }
2028        (atr / mean * Decimal::ONE_HUNDRED).to_f64()
2029    }
2030
2031    /// Pearson correlation between bar index and volume — a measure of whether
2032    /// volume is trending up or down over the window.
2033    ///
2034    /// Returns `None` if fewer than 2 bars or if either series has zero variance.
2035    pub fn volume_trend_strength(bars: &[OhlcvBar]) -> Option<f64> {
2036        use rust_decimal::prelude::ToPrimitive;
2037        let n = bars.len();
2038        if n < 2 {
2039            return None;
2040        }
2041        let nf = n as f64;
2042        let indices: Vec<f64> = (0..n).map(|i| i as f64).collect();
2043        let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
2044        if vols.len() != n {
2045            return None;
2046        }
2047        let mean_i = indices.iter().sum::<f64>() / nf;
2048        let mean_v = vols.iter().sum::<f64>() / nf;
2049        let cov: f64 = indices.iter().zip(vols.iter()).map(|(i, v)| (i - mean_i) * (v - mean_v)).sum::<f64>() / nf;
2050        let std_i = (indices.iter().map(|i| (i - mean_i).powi(2)).sum::<f64>() / nf).sqrt();
2051        let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / nf).sqrt();
2052        if std_i == 0.0 || std_v == 0.0 {
2053            return None;
2054        }
2055        Some(cov / (std_i * std_v))
2056    }
2057
2058    /// Mean spread between high and close across all bars.
2059    ///
2060    /// `mean(high - close)`. Always ≥ 0 since high ≥ close by definition.
2061    /// Returns `None` if the slice is empty.
2062    pub fn high_close_spread(bars: &[OhlcvBar]) -> Option<f64> {
2063        use rust_decimal::prelude::ToPrimitive;
2064        if bars.is_empty() {
2065            return None;
2066        }
2067        let total: f64 = bars.iter().filter_map(|b| (b.high - b.close).to_f64()).sum();
2068        Some(total / bars.len() as f64)
2069    }
2070
2071    /// Mean open-to-first-close range for the session opening bar.
2072    ///
2073    /// Defined as the absolute distance `|close - open|` averaged over all
2074    /// bars. Equivalent to [`open_close_spread`] but named for discoverability.
2075    /// Returns `None` if the slice is empty.
2076    pub fn open_range(bars: &[OhlcvBar]) -> Option<f64> {
2077        use rust_decimal::prelude::ToPrimitive;
2078        if bars.is_empty() {
2079            return None;
2080        }
2081        let total: f64 = bars.iter().filter_map(|b| (b.close - b.open).abs().to_f64()).sum();
2082        Some(total / bars.len() as f64)
2083    }
2084
2085    /// Normalised close: last close as a fraction of the window's close range.
2086    ///
2087    /// `(last_close - min_close) / (max_close - min_close)`. Returns `None`
2088    /// if fewer than 2 bars or the close range is zero.
2089    pub fn normalized_close(bars: &[OhlcvBar]) -> Option<f64> {
2090        use rust_decimal::prelude::ToPrimitive;
2091        let min = Self::min_close(bars)?;
2092        let max = Self::max_close(bars)?;
2093        let range = max - min;
2094        if range.is_zero() {
2095            return None;
2096        }
2097        let last = bars.last()?.close;
2098        ((last - min) / range).to_f64()
2099    }
2100
2101    /// Price channel position: where the last close falls in the
2102    /// `[lowest_low, highest_high]` range.
2103    ///
2104    /// `(last_close - lowest_low) / (highest_high - lowest_low)`. Returns
2105    /// `None` if the slice is empty or the range is zero.
2106    pub fn price_channel_position(bars: &[OhlcvBar]) -> Option<f64> {
2107        Self::price_position(bars)
2108    }
2109
2110    /// Composite candle score: fraction of bars that are bullish, have a body
2111    /// above 50% of range, and close above the midpoint of the bar.
2112    ///
2113    /// Returns `None` if the slice is empty.
2114    pub fn candle_score(bars: &[OhlcvBar]) -> Option<f64> {
2115        if bars.is_empty() {
2116            return None;
2117        }
2118        let strong = bars.iter().filter(|b| {
2119            b.is_bullish()
2120                && !b.range().is_zero()
2121                && b.body() * Decimal::TWO > b.range()
2122                && b.close_above_midpoint()
2123        }).count();
2124        Some(strong as f64 / bars.len() as f64)
2125    }
2126
2127    /// Mean number of ticks per millisecond of bar duration.
2128    ///
2129    /// `tick_count / bar_duration_ms`. Returns `None` if the slice is empty
2130    /// or the total duration across bars is zero.
2131    pub fn bar_speed(bars: &[OhlcvBar]) -> Option<f64> {
2132        if bars.is_empty() {
2133            return None;
2134        }
2135        let total_ticks: u64 = bars.iter().map(|b| b.trade_count).sum();
2136        let total_ms: u64 = bars.iter().map(|b| b.bar_duration_ms()).sum();
2137        if total_ms == 0 {
2138            return None;
2139        }
2140        Some(total_ticks as f64 / total_ms as f64)
2141    }
2142
2143    /// Count of bars where the high is strictly greater than the previous bar's high.
2144    ///
2145    /// Returns 0 if fewer than 2 bars.
2146    pub fn higher_highs_count(bars: &[OhlcvBar]) -> usize {
2147        if bars.len() < 2 {
2148            return 0;
2149        }
2150        bars.windows(2).filter(|w| w[1].high > w[0].high).count()
2151    }
2152
2153    /// Count of bars where the low is strictly less than the previous bar's low.
2154    ///
2155    /// Returns 0 if fewer than 2 bars.
2156    pub fn lower_lows_count(bars: &[OhlcvBar]) -> usize {
2157        if bars.len() < 2 {
2158            return 0;
2159        }
2160        bars.windows(2).filter(|w| w[1].low < w[0].low).count()
2161    }
2162
2163    /// Mean `(close - open) / open * 100` across all bars.
2164    ///
2165    /// Bars with zero open are skipped. Returns `None` if no valid bars.
2166    pub fn close_minus_open_pct(bars: &[OhlcvBar]) -> Option<f64> {
2167        use rust_decimal::prelude::ToPrimitive;
2168        let values: Vec<f64> = bars.iter().filter_map(|b| {
2169            if b.open.is_zero() { return None; }
2170            ((b.close - b.open) / b.open * Decimal::ONE_HUNDRED).to_f64()
2171        }).collect();
2172        if values.is_empty() {
2173            return None;
2174        }
2175        Some(values.iter().sum::<f64>() / values.len() as f64)
2176    }
2177
2178    /// Mean volume per unit of bar range.
2179    ///
2180    /// `volume / (high - low)`. Bars with zero range are skipped. Returns
2181    /// `None` if the slice is empty or all bars have zero range.
2182    pub fn volume_per_range(bars: &[OhlcvBar]) -> Option<f64> {
2183        use rust_decimal::prelude::ToPrimitive;
2184        let values: Vec<f64> = bars.iter().filter_map(|b| {
2185            let r = b.range();
2186            if r.is_zero() { return None; }
2187            (b.volume / r).to_f64()
2188        }).collect();
2189        if values.is_empty() {
2190            return None;
2191        }
2192        Some(values.iter().sum::<f64>() / values.len() as f64)
2193    }
2194
2195    /// Average body size (|close − open|) as a fraction of total bar range.
2196    ///
2197    /// Returns `None` if all bars have zero range or the slice is empty.
2198    pub fn body_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2199        use rust_decimal::prelude::ToPrimitive;
2200        let fracs: Vec<f64> = bars.iter().filter_map(|b| {
2201            let range = b.range();
2202            if range.is_zero() { return None; }
2203            let body = (b.close - b.open).abs();
2204            (body / range).to_f64()
2205        }).collect();
2206        if fracs.is_empty() {
2207            return None;
2208        }
2209        Some(fracs.iter().sum::<f64>() / fracs.len() as f64)
2210    }
2211
2212    /// Fraction of bars that are bullish (close strictly greater than open).
2213    ///
2214    /// Returns `None` if the slice is empty.
2215    pub fn bullish_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2216        if bars.is_empty() {
2217            return None;
2218        }
2219        let bullish = bars.iter().filter(|b| b.close > b.open).count();
2220        Some(bullish as f64 / bars.len() as f64)
2221    }
2222
2223    /// Maximum (highest) close price across all bars.
2224    ///
2225    /// Returns `None` if the slice is empty.
2226    pub fn peak_close(bars: &[OhlcvBar]) -> Option<Decimal> {
2227        bars.iter().map(|b| b.close).reduce(Decimal::max)
2228    }
2229
2230    /// Minimum (lowest) close price across all bars.
2231    ///
2232    /// Returns `None` if the slice is empty.
2233    pub fn trough_close(bars: &[OhlcvBar]) -> Option<Decimal> {
2234        bars.iter().map(|b| b.close).reduce(Decimal::min)
2235    }
2236
2237    /// Fraction of total volume that occurred on up-bars (close > open).
2238    ///
2239    /// Returns `None` if total volume is zero or the slice is empty.
2240    pub fn up_volume_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2241        use rust_decimal::prelude::ToPrimitive;
2242        if bars.is_empty() {
2243            return None;
2244        }
2245        let total: Decimal = bars.iter().map(|b| b.volume).sum();
2246        if total.is_zero() {
2247            return None;
2248        }
2249        let up_vol: Decimal = bars.iter()
2250            .filter(|b| b.close > b.open)
2251            .map(|b| b.volume)
2252            .sum();
2253        (up_vol / total).to_f64()
2254    }
2255
2256    /// Average upper wick as a fraction of total bar range.
2257    ///
2258    /// Upper wick = high − max(open, close). Returns `None` if all bars have
2259    /// zero range or the slice is empty.
2260    pub fn tail_upper_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2261        use rust_decimal::prelude::ToPrimitive;
2262        let fracs: Vec<f64> = bars.iter().filter_map(|b| {
2263            let range = b.range();
2264            if range.is_zero() { return None; }
2265            let body_top = b.open.max(b.close);
2266            let upper_wick = b.high - body_top;
2267            (upper_wick / range).to_f64()
2268        }).collect();
2269        if fracs.is_empty() {
2270            return None;
2271        }
2272        Some(fracs.iter().sum::<f64>() / fracs.len() as f64)
2273    }
2274
2275    /// Average lower wick as a fraction of total bar range.
2276    ///
2277    /// Lower wick = min(open, close) − low. Returns `None` if all bars have
2278    /// zero range or the slice is empty.
2279    pub fn tail_lower_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2280        use rust_decimal::prelude::ToPrimitive;
2281        let fracs: Vec<f64> = bars.iter().filter_map(|b| {
2282            let range = b.range();
2283            if range.is_zero() { return None; }
2284            let body_bot = b.open.min(b.close);
2285            let lower_wick = body_bot - b.low;
2286            (lower_wick / range).to_f64()
2287        }).collect();
2288        if fracs.is_empty() {
2289            return None;
2290        }
2291        Some(fracs.iter().sum::<f64>() / fracs.len() as f64)
2292    }
2293
2294    /// Standard deviation of bar ranges (high − low).
2295    ///
2296    /// Measures consistency of bar volatility. Returns `None` if fewer than 2
2297    /// bars are provided.
2298    pub fn range_std_dev(bars: &[OhlcvBar]) -> Option<f64> {
2299        use rust_decimal::prelude::ToPrimitive;
2300        if bars.len() < 2 {
2301            return None;
2302        }
2303        let vals: Vec<f64> = bars.iter().filter_map(|b| b.range().to_f64()).collect();
2304        if vals.len() < 2 {
2305            return None;
2306        }
2307        let n = vals.len() as f64;
2308        let mean = vals.iter().sum::<f64>() / n;
2309        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2310        Some(variance.sqrt())
2311    }
2312
2313    // ── round-79 ─────────────────────────────────────────────────────────────
2314
2315    /// Mean of `(close − low) / range` across bars — where the close lands
2316    /// inside each bar's high-low range.
2317    ///
2318    /// Returns `None` if the slice is empty or every bar has zero range.
2319    /// A value near 1.0 means closes are consistently near the high (bullish);
2320    /// near 0.0 means closes hug the low (bearish).
2321    pub fn close_to_range_position(bars: &[OhlcvBar]) -> Option<f64> {
2322        use rust_decimal::prelude::ToPrimitive;
2323        let vals: Vec<f64> = bars
2324            .iter()
2325            .filter_map(|b| {
2326                let r = b.range();
2327                if r.is_zero() {
2328                    return None;
2329                }
2330                ((b.close - b.low) / r).to_f64()
2331            })
2332            .collect();
2333        if vals.is_empty() {
2334            return None;
2335        }
2336        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2337    }
2338
2339    /// Volume oscillator: `(short_avg_vol − long_avg_vol) / long_avg_vol`.
2340    ///
2341    /// `short_n` bars are used for the short average and `long_n` for the long.
2342    /// Returns `None` if `long_n > bars.len()`, `short_n == 0`, `long_n == 0`,
2343    /// `short_n >= long_n`, or the long average is zero.
2344    ///
2345    /// A positive result means recent volume is above the longer-term average
2346    /// (expanding volume); negative means volume is contracting.
2347    pub fn volume_oscillator(bars: &[OhlcvBar], short_n: usize, long_n: usize) -> Option<f64> {
2348        use rust_decimal::prelude::ToPrimitive;
2349        if short_n == 0 || long_n == 0 || short_n >= long_n || bars.len() < long_n {
2350            return None;
2351        }
2352        let recent = &bars[bars.len() - short_n..];
2353        let long_slice = &bars[bars.len() - long_n..];
2354        let short_avg: f64 =
2355            recent.iter().filter_map(|b| b.volume.to_f64()).sum::<f64>() / short_n as f64;
2356        let long_sum: Vec<f64> = long_slice.iter().filter_map(|b| b.volume.to_f64()).collect();
2357        if long_sum.is_empty() {
2358            return None;
2359        }
2360        let long_avg = long_sum.iter().sum::<f64>() / long_sum.len() as f64;
2361        if long_avg == 0.0 {
2362            return None;
2363        }
2364        Some((short_avg - long_avg) / long_avg)
2365    }
2366
2367    /// Count of consecutive direction changes: how many times the bar
2368    /// sentiment (bullish / bearish) flips from one bar to the next.
2369    ///
2370    /// Doji bars (open == close) count as a continuation of the previous
2371    /// direction. Returns 0 for slices shorter than 2.
2372    pub fn direction_reversal_count(bars: &[OhlcvBar]) -> usize {
2373        if bars.len() < 2 {
2374            return 0;
2375        }
2376        let mut count = 0usize;
2377        let mut prev_bullish: Option<bool> = None;
2378        for b in bars {
2379            let bullish = b.close > b.open;
2380            if let Some(pb) = prev_bullish {
2381                if bullish != pb {
2382                    count += 1;
2383                }
2384            }
2385            prev_bullish = Some(bullish);
2386        }
2387        count
2388    }
2389
2390    /// Fraction of bars where the upper wick is strictly longer than the
2391    /// lower wick.
2392    ///
2393    /// Returns `None` for an empty slice.
2394    pub fn upper_wick_dominance_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2395        if bars.is_empty() {
2396            return None;
2397        }
2398        let count = bars.iter().filter(|b| b.wick_upper() > b.wick_lower()).count();
2399        Some(count as f64 / bars.len() as f64)
2400    }
2401
2402    /// Mean of `(high − open) / range` across bars — average fraction of the
2403    /// bar the price moved up from the open.
2404    ///
2405    /// Returns `None` if the slice is empty or every bar has zero range.
2406    pub fn avg_open_to_high_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2407        use rust_decimal::prelude::ToPrimitive;
2408        let vals: Vec<f64> = bars
2409            .iter()
2410            .filter_map(|b| {
2411                let r = b.range();
2412                if r.is_zero() {
2413                    return None;
2414                }
2415                ((b.high - b.open) / r).to_f64()
2416            })
2417            .collect();
2418        if vals.is_empty() {
2419            return None;
2420        }
2421        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2422    }
2423
2424    /// Volume-weighted average of bar range: `sum(range × volume) / sum(volume)`.
2425    ///
2426    /// Returns `None` when the slice is empty or total volume is zero.
2427    pub fn volume_weighted_range(bars: &[OhlcvBar]) -> Option<f64> {
2428        use rust_decimal::prelude::ToPrimitive;
2429        if bars.is_empty() {
2430            return None;
2431        }
2432        let mut numerator = 0f64;
2433        let mut denom = 0f64;
2434        for b in bars {
2435            let r = b.range().to_f64()?;
2436            let v = b.volume.to_f64()?;
2437            numerator += r * v;
2438            denom += v;
2439        }
2440        if denom == 0.0 {
2441            return None;
2442        }
2443        Some(numerator / denom)
2444    }
2445
2446    /// Bar strength index: mean close-location value (CLV) across the slice.
2447    ///
2448    /// CLV for each bar is `(close − low − (high − close)) / range`, which
2449    /// is `+1` when close == high and `−1` when close == low. Bars with zero
2450    /// range are excluded.
2451    ///
2452    /// Returns `None` when no bars have non-zero range.
2453    pub fn bar_strength_index(bars: &[OhlcvBar]) -> Option<f64> {
2454        let vals: Vec<f64> =
2455            bars.iter().filter_map(|b| b.close_location_value()).collect();
2456        if vals.is_empty() {
2457            return None;
2458        }
2459        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2460    }
2461
2462    /// Ratio of total wick length to total body size across the slice.
2463    ///
2464    /// Computes `sum(upper_wick + lower_wick) / sum(body)` where body is
2465    /// `|close − open|`. Returns `None` when total body is zero (all doji
2466    /// or empty slice).
2467    pub fn shadow_to_body_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2468        use rust_decimal::prelude::ToPrimitive;
2469        if bars.is_empty() {
2470            return None;
2471        }
2472        let total_wick: Decimal = bars.iter().map(|b| b.wick_upper() + b.wick_lower()).sum();
2473        let total_body: Decimal = bars.iter().map(|b| b.body()).sum();
2474        if total_body.is_zero() {
2475            return None;
2476        }
2477        (total_wick / total_body).to_f64()
2478    }
2479
2480    /// Percentage change from the first bar's close to the last bar's close.
2481    ///
2482    /// Computed as `(last.close − first.close) / first.close × 100`.
2483    /// Returns `None` when the slice is empty or the first close is zero.
2484    pub fn first_last_close_pct(bars: &[OhlcvBar]) -> Option<f64> {
2485        use rust_decimal::prelude::ToPrimitive;
2486        let first = bars.first()?;
2487        let last = bars.last()?;
2488        if first.close.is_zero() {
2489            return None;
2490        }
2491        ((last.close - first.close) / first.close * Decimal::ONE_HUNDRED).to_f64()
2492    }
2493
2494    /// Standard deviation of per-bar `(close − open) / open` returns.
2495    ///
2496    /// Measures intrabar volatility consistency. Returns `None` when fewer
2497    /// than 2 bars are provided or every open is zero.
2498    pub fn open_to_close_volatility(bars: &[OhlcvBar]) -> Option<f64> {
2499        use rust_decimal::prelude::ToPrimitive;
2500        if bars.len() < 2 {
2501            return None;
2502        }
2503        let returns: Vec<f64> = bars
2504            .iter()
2505            .filter_map(|b| {
2506                if b.open.is_zero() {
2507                    return None;
2508                }
2509                ((b.close - b.open) / b.open).to_f64()
2510            })
2511            .collect();
2512        if returns.len() < 2 {
2513            return None;
2514        }
2515        let n = returns.len() as f64;
2516        let mean = returns.iter().sum::<f64>() / n;
2517        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
2518        Some(variance.sqrt())
2519    }
2520
2521    // ── round-80 ─────────────────────────────────────────────────────────────
2522
2523    /// Mean of `(close − low) / range` across bars.
2524    ///
2525    /// Indicates where each close lands within its bar's high-low range.
2526    /// Near 1.0 means closes consistently hug the high (bullish);
2527    /// near 0.0 means closes hug the low (bearish). Bars with zero range
2528    /// are excluded. Returns `None` if no bars have non-zero range.
2529    pub fn close_recovery_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2530        use rust_decimal::prelude::ToPrimitive;
2531        let vals: Vec<f64> = bars
2532            .iter()
2533            .filter_map(|b| {
2534                let r = b.range();
2535                if r.is_zero() {
2536                    return None;
2537                }
2538                ((b.close - b.low) / r).to_f64()
2539            })
2540            .collect();
2541        if vals.is_empty() {
2542            return None;
2543        }
2544        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2545    }
2546
2547    /// Median bar range `(high − low)` across the slice.
2548    ///
2549    /// Robust to outlier bars with unusually wide or narrow ranges.
2550    /// Returns `None` for an empty slice.
2551    pub fn median_range(bars: &[OhlcvBar]) -> Option<Decimal> {
2552        if bars.is_empty() {
2553            return None;
2554        }
2555        let mut ranges: Vec<Decimal> = bars.iter().map(|b| b.range()).collect();
2556        ranges.sort();
2557        let n = ranges.len();
2558        if n % 2 == 1 {
2559            Some(ranges[n / 2])
2560        } else {
2561            Some((ranges[n / 2 - 1] + ranges[n / 2]) / Decimal::from(2u64))
2562        }
2563    }
2564
2565    /// Mean typical price `(high + low + close) / 3` across the slice.
2566    ///
2567    /// The typical price is a common single-value summary of a bar used
2568    /// in pivot-point and money-flow calculations. Returns `None` if empty.
2569    pub fn mean_typical_price(bars: &[OhlcvBar]) -> Option<Decimal> {
2570        if bars.is_empty() {
2571            return None;
2572        }
2573        let sum: Decimal = bars.iter().map(|b| b.typical_price()).sum();
2574        Some(sum / Decimal::from(bars.len() as u64))
2575    }
2576
2577    /// Ratio of bullish-bar volume to the sum of bullish and bearish volumes.
2578    ///
2579    /// A value near 1.0 means almost all volume is in up-bars; near 0.0
2580    /// means down-bars dominate. Returns `None` when both are zero (e.g.,
2581    /// all flat/neutral bars).
2582    pub fn directional_volume_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2583        use rust_decimal::prelude::ToPrimitive;
2584        let bull = Self::bullish_volume(bars);
2585        let bear = Self::bearish_volume(bars);
2586        let total = bull + bear;
2587        if total.is_zero() {
2588            return None;
2589        }
2590        (bull / total).to_f64()
2591    }
2592
2593    /// Fraction of bars (from the second onward) that are inside bars.
2594    ///
2595    /// An inside bar has a high ≤ previous high and low ≥ previous low.
2596    /// Returns `None` if the slice has fewer than 2 bars.
2597    pub fn inside_bar_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2598        if bars.len() < 2 {
2599            return None;
2600        }
2601        let inside = bars.windows(2).filter(|w| w[1].is_inside_bar(&w[0])).count();
2602        Some(inside as f64 / (bars.len() - 1) as f64)
2603    }
2604
2605    /// Net body momentum: sum of signed body sizes across all bars.
2606    ///
2607    /// Bullish bars contribute `+(close − open)`; bearish bars contribute
2608    /// `−(open − close)`; flat bars contribute `0`. A positive total
2609    /// indicates the bars collectively closed higher than they opened.
2610    pub fn body_momentum(bars: &[OhlcvBar]) -> Decimal {
2611        bars.iter()
2612            .map(|b| b.close - b.open)
2613            .sum()
2614    }
2615
2616    /// Mean `trade_count` (ticks per bar) across the slice.
2617    ///
2618    /// Returns `None` if the slice is empty.
2619    pub fn avg_trade_count(bars: &[OhlcvBar]) -> Option<f64> {
2620        if bars.is_empty() {
2621            return None;
2622        }
2623        let total: u64 = bars.iter().map(|b| b.trade_count).sum();
2624        Some(total as f64 / bars.len() as f64)
2625    }
2626
2627    /// Maximum `trade_count` seen across the slice.
2628    ///
2629    /// Returns `None` if the slice is empty.
2630    pub fn max_trade_count(bars: &[OhlcvBar]) -> Option<u64> {
2631        bars.iter().map(|b| b.trade_count).max()
2632    }
2633
2634}
2635
2636impl std::fmt::Display for OhlcvBar {
2637    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2638        write!(
2639            f,
2640            "{} {} [{}/{}/{}/{}  v={}]",
2641            self.symbol, self.timeframe, self.open, self.high, self.low, self.close, self.volume
2642        )
2643    }
2644}
2645
2646/// Aggregates ticks into OHLCV bars.
2647pub struct OhlcvAggregator {
2648    symbol: String,
2649    timeframe: Timeframe,
2650    current_bar: Option<OhlcvBar>,
2651    /// The most recently completed bar emitted by `feed` or `flush`.
2652    last_bar: Option<OhlcvBar>,
2653    /// When true, `feed` returns synthetic zero-volume bars for any bar windows
2654    /// that were skipped between the previous tick and the current one.
2655    /// The synthetic bars use the last known close price for all OHLC fields.
2656    emit_empty_bars: bool,
2657    /// Total number of completed bars emitted by this aggregator.
2658    bars_emitted: u64,
2659    /// Running sum of `price × quantity` for VWAP computation in the current bar.
2660    price_volume_sum: Decimal,
2661    /// Cumulative volume across all completed bars (does not include the current partial bar).
2662    total_volume: Decimal,
2663    /// Maximum single-bar volume seen across all completed bars.
2664    peak_volume: Option<Decimal>,
2665    /// Minimum single-bar volume seen across all completed bars.
2666    min_volume: Option<Decimal>,
2667}
2668
2669impl OhlcvAggregator {
2670    /// Create a new aggregator for `symbol` at `timeframe`.
2671    ///
2672    /// Returns an error if `timeframe.duration_ms()` is zero, which would make
2673    /// bar boundary alignment undefined.
2674    pub fn new(symbol: impl Into<String>, timeframe: Timeframe) -> Result<Self, StreamError> {
2675        let tf_dur = timeframe.duration_ms();
2676        if tf_dur == 0 {
2677            return Err(StreamError::ConfigError {
2678                reason: "OhlcvAggregator timeframe duration must be > 0".into(),
2679            });
2680        }
2681        Ok(Self {
2682            symbol: symbol.into(),
2683            timeframe,
2684            current_bar: None,
2685            last_bar: None,
2686            emit_empty_bars: false,
2687            bars_emitted: 0,
2688            price_volume_sum: Decimal::ZERO,
2689            total_volume: Decimal::ZERO,
2690            peak_volume: None,
2691            min_volume: None,
2692        })
2693    }
2694
2695    /// Enable emission of synthetic zero-volume bars for skipped bar windows.
2696    pub fn with_emit_empty_bars(mut self, enabled: bool) -> Self {
2697        self.emit_empty_bars = enabled;
2698        self
2699    }
2700
2701    /// Feed a tick. Returns completed bars (including any empty gap bars when
2702    /// `emit_empty_bars` is true). At most one real completed bar plus zero or
2703    /// more empty bars can be returned per call.
2704    ///
2705    /// Bar boundaries are aligned using the exchange-side timestamp
2706    /// (`exchange_ts_ms`) when available, falling back to the local system
2707    /// clock (`received_at_ms`). Using the exchange timestamp avoids
2708    /// misalignment caused by variable network latency.
2709    #[must_use = "completed bars are returned; ignoring them loses bar data"]
2710    #[inline]
2711    pub fn feed(&mut self, tick: &NormalizedTick) -> Result<Vec<OhlcvBar>, StreamError> {
2712        if tick.symbol != self.symbol {
2713            return Err(StreamError::AggregationError {
2714                reason: format!(
2715                    "tick symbol '{}' does not match aggregator '{}'",
2716                    tick.symbol, self.symbol
2717                ),
2718            });
2719        }
2720
2721        // Prefer the authoritative exchange timestamp; fall back to local clock.
2722        let tick_ts = tick.exchange_ts_ms.unwrap_or(tick.received_at_ms);
2723        let bar_start = self.timeframe.bar_start_ms(tick_ts);
2724        let mut emitted: Vec<OhlcvBar> = Vec::new();
2725
2726        // Check whether the incoming tick belongs to a new bar window.
2727        let bar_window_changed = self
2728            .current_bar
2729            .as_ref()
2730            .map_or(false, |b| b.bar_start_ms != bar_start);
2731
2732        if bar_window_changed {
2733            // Take ownership — avoids cloning the current bar.
2734            let mut completed = self.current_bar.take().unwrap_or_else(|| unreachable!());
2735            completed.is_complete = true;
2736            let prev_close = completed.close;
2737            let prev_start = completed.bar_start_ms;
2738            emitted.push(completed);
2739
2740            // Optionally fill any empty bar windows between prev_start and bar_start.
2741            if self.emit_empty_bars {
2742                let dur = self.timeframe.duration_ms();
2743                let mut gap_start = prev_start + dur;
2744                while gap_start < bar_start {
2745                    emitted.push(OhlcvBar {
2746                        symbol: self.symbol.clone(),
2747                        timeframe: self.timeframe,
2748                        bar_start_ms: gap_start,
2749                        open: prev_close,
2750                        high: prev_close,
2751                        low: prev_close,
2752                        close: prev_close,
2753                        volume: Decimal::ZERO,
2754                        trade_count: 0,
2755                        is_complete: true,
2756                        is_gap_fill: true,
2757                        vwap: None,
2758                    });
2759                    gap_start += dur;
2760                }
2761            }
2762        }
2763
2764        // Update price_volume_sum before the match to avoid borrow conflicts.
2765        let tick_value = tick.value();
2766        if self.current_bar.is_some() {
2767            self.price_volume_sum += tick_value;
2768        } else {
2769            self.price_volume_sum = tick_value;
2770        }
2771
2772        match &mut self.current_bar {
2773            Some(bar) => {
2774                if tick.price > bar.high {
2775                    bar.high = tick.price;
2776                }
2777                if tick.price < bar.low {
2778                    bar.low = tick.price;
2779                }
2780                bar.close = tick.price;
2781                bar.volume += tick.quantity;
2782                bar.trade_count += 1;
2783                bar.vwap = if bar.volume.is_zero() {
2784                    None
2785                } else {
2786                    Some(self.price_volume_sum / bar.volume)
2787                };
2788            }
2789            None => {
2790                self.current_bar = Some(OhlcvBar {
2791                    symbol: self.symbol.clone(),
2792                    timeframe: self.timeframe,
2793                    bar_start_ms: bar_start,
2794                    open: tick.price,
2795                    high: tick.price,
2796                    low: tick.price,
2797                    close: tick.price,
2798                    volume: tick.quantity,
2799                    trade_count: 1,
2800                    is_complete: false,
2801                    is_gap_fill: false,
2802                    vwap: Some(tick.price), // single-tick VWAP = price
2803                });
2804            }
2805        }
2806        self.bars_emitted += emitted.len() as u64;
2807        for b in &emitted {
2808            self.total_volume += b.volume;
2809            self.peak_volume = Some(match self.peak_volume {
2810                Some(prev) => prev.max(b.volume),
2811                None => b.volume,
2812            });
2813            self.min_volume = Some(match self.min_volume {
2814                Some(prev) => prev.min(b.volume),
2815                None => b.volume,
2816            });
2817        }
2818        if let Some(b) = emitted.last() {
2819            self.last_bar = Some(b.clone());
2820        }
2821        Ok(emitted)
2822    }
2823
2824    /// Current partial bar (if any).
2825    pub fn current_bar(&self) -> Option<&OhlcvBar> {
2826        self.current_bar.as_ref()
2827    }
2828
2829    /// Flush the current partial bar as complete.
2830    #[must_use = "the flushed bar is returned; ignoring it loses the partial bar"]
2831    pub fn flush(&mut self) -> Option<OhlcvBar> {
2832        let mut bar = self.current_bar.take()?;
2833        bar.is_complete = true;
2834        self.bars_emitted += 1;
2835        self.total_volume += bar.volume;
2836        self.peak_volume = Some(match self.peak_volume {
2837            Some(prev) => prev.max(bar.volume),
2838            None => bar.volume,
2839        });
2840        self.min_volume = Some(match self.min_volume {
2841            Some(prev) => prev.min(bar.volume),
2842            None => bar.volume,
2843        });
2844        self.last_bar = Some(bar.clone());
2845        Some(bar)
2846    }
2847
2848    /// The most recently completed bar emitted by [`feed`](Self::feed) or
2849    /// [`flush`](Self::flush). Returns `None` if no bar has been completed yet.
2850    ///
2851    /// Unlike [`current_bar`](Self::current_bar), this bar is always complete.
2852    pub fn last_bar(&self) -> Option<&OhlcvBar> {
2853        self.last_bar.as_ref()
2854    }
2855
2856    /// Total number of completed bars emitted by this aggregator (via `feed` or `flush`).
2857    pub fn bar_count(&self) -> u64 {
2858        self.bars_emitted
2859    }
2860
2861    /// Discard the in-progress bar and reset the bar counter to zero.
2862    ///
2863    /// Useful for backtesting rewind or when restarting aggregation from a
2864    /// new anchor point. Does not affect the aggregator's symbol or timeframe.
2865    pub fn reset(&mut self) {
2866        self.current_bar = None;
2867        self.last_bar = None;
2868        self.bars_emitted = 0;
2869        self.price_volume_sum = Decimal::ZERO;
2870        self.total_volume = Decimal::ZERO;
2871        self.peak_volume = None;
2872        self.min_volume = None;
2873    }
2874
2875    /// Cumulative traded volume across all completed bars emitted by this aggregator.
2876    ///
2877    /// Does not include the current partial bar's volume. Reset to zero by
2878    /// [`reset`](Self::reset).
2879    pub fn total_volume(&self) -> Decimal {
2880        self.total_volume
2881    }
2882
2883    /// Maximum single-bar volume seen across all completed bars.
2884    ///
2885    /// Returns `None` if no bars have been completed yet. Reset to `None` by
2886    /// [`reset`](Self::reset).
2887    pub fn peak_volume(&self) -> Option<Decimal> {
2888        self.peak_volume
2889    }
2890
2891    /// Minimum single-bar volume seen across all completed bars.
2892    ///
2893    /// Returns `None` if no bars have been completed yet. Reset to `None` by
2894    /// [`reset`](Self::reset).
2895    pub fn min_volume(&self) -> Option<Decimal> {
2896        self.min_volume
2897    }
2898
2899    /// Volume range across completed bars: `(min_volume, peak_volume)`.
2900    ///
2901    /// Returns `None` if no bars have been completed yet. Useful for
2902    /// normalizing volume signals to the observed range.
2903    pub fn volume_range(&self) -> Option<(Decimal, Decimal)> {
2904        Some((self.min_volume?, self.peak_volume?))
2905    }
2906
2907    /// Average volume per completed bar: `total_volume / bars_emitted`.
2908    ///
2909    /// Returns `None` if no bars have been completed yet (avoids division by zero).
2910    pub fn average_volume(&self) -> Option<Decimal> {
2911        if self.bars_emitted == 0 {
2912            return None;
2913        }
2914        Some(self.total_volume / Decimal::from(self.bars_emitted))
2915    }
2916
2917    /// The symbol this aggregator tracks.
2918    pub fn symbol(&self) -> &str {
2919        &self.symbol
2920    }
2921
2922    /// The timeframe used for bar alignment.
2923    pub fn timeframe(&self) -> Timeframe {
2924        self.timeframe
2925    }
2926
2927    /// Fraction of the current bar's time window that has elapsed, in `[0.0, 1.0]`.
2928    ///
2929    /// Returns `None` if no bar is in progress (no ticks seen since last
2930    /// flush/reset). `now_ms` should be ≥ the current bar's `bar_start_ms`;
2931    /// values before the start clamp to `0.0`.
2932    pub fn window_progress(&self, now_ms: u64) -> Option<f64> {
2933        let bar = self.current_bar.as_ref()?;
2934        let elapsed = now_ms.saturating_sub(bar.bar_start_ms);
2935        let duration = self.timeframe.duration_ms();
2936        let progress = elapsed as f64 / duration as f64;
2937        Some(progress.clamp(0.0, 1.0))
2938    }
2939
2940    /// Returns `true` if a bar is currently in progress (at least one tick has
2941    /// been fed since the last flush or reset).
2942    pub fn is_active(&self) -> bool {
2943        self.current_bar.is_some()
2944    }
2945
2946    /// Volume-weighted average price of the current in-progress bar.
2947    ///
2948    /// Returns `None` if no bar is currently being built or the bar has zero
2949    /// volume (should not happen with real ticks).
2950    pub fn vwap_current(&self) -> Option<Decimal> {
2951        let bar = self.current_bar.as_ref()?;
2952        if bar.volume.is_zero() {
2953            return None;
2954        }
2955        Some(self.price_volume_sum / bar.volume)
2956    }
2957}
2958
2959#[cfg(test)]
2960#[allow(deprecated)]
2961mod tests {
2962    use super::*;
2963    use crate::tick::{Exchange, NormalizedTick, TradeSide};
2964    use rust_decimal_macros::dec;
2965
2966    fn make_tick(symbol: &str, price: Decimal, qty: Decimal, ts_ms: u64) -> NormalizedTick {
2967        NormalizedTick {
2968            exchange: Exchange::Binance,
2969            symbol: symbol.to_string(),
2970            price,
2971            quantity: qty,
2972            side: Some(TradeSide::Buy),
2973            trade_id: None,
2974            exchange_ts_ms: None,
2975            received_at_ms: ts_ms,
2976        }
2977    }
2978
2979    fn make_tick_with_exchange_ts(
2980        symbol: &str,
2981        price: Decimal,
2982        qty: Decimal,
2983        exchange_ts_ms: u64,
2984        received_at_ms: u64,
2985    ) -> NormalizedTick {
2986        NormalizedTick {
2987            exchange: Exchange::Binance,
2988            symbol: symbol.to_string(),
2989            price,
2990            quantity: qty,
2991            side: Some(TradeSide::Buy),
2992            trade_id: None,
2993            exchange_ts_ms: Some(exchange_ts_ms),
2994            received_at_ms,
2995        }
2996    }
2997
2998    fn agg(symbol: &str, tf: Timeframe) -> OhlcvAggregator {
2999        OhlcvAggregator::new(symbol, tf).unwrap()
3000    }
3001
3002    #[test]
3003    fn test_timeframe_seconds_duration_ms() {
3004        assert_eq!(Timeframe::Seconds(30).duration_ms(), 30_000);
3005    }
3006
3007    #[test]
3008    fn test_timeframe_minutes_duration_ms() {
3009        assert_eq!(Timeframe::Minutes(5).duration_ms(), 300_000);
3010    }
3011
3012    #[test]
3013    fn test_timeframe_hours_duration_ms() {
3014        assert_eq!(Timeframe::Hours(1).duration_ms(), 3_600_000);
3015    }
3016
3017    #[test]
3018    fn test_timeframe_bar_start_ms_aligns() {
3019        let tf = Timeframe::Minutes(1);
3020        let ts = 61_500; // 1min 1.5sec
3021        assert_eq!(tf.bar_start_ms(ts), 60_000);
3022    }
3023
3024    #[test]
3025    fn test_timeframe_display() {
3026        assert_eq!(Timeframe::Seconds(30).to_string(), "30s");
3027        assert_eq!(Timeframe::Minutes(5).to_string(), "5m");
3028        assert_eq!(Timeframe::Hours(4).to_string(), "4h");
3029    }
3030
3031    #[test]
3032    fn test_timeframe_ord_seconds_lt_minutes() {
3033        assert!(Timeframe::Seconds(30) < Timeframe::Minutes(1));
3034    }
3035
3036    #[test]
3037    fn test_timeframe_ord_minutes_lt_hours() {
3038        assert!(Timeframe::Minutes(59) < Timeframe::Hours(1));
3039    }
3040
3041    #[test]
3042    fn test_timeframe_ord_same_duration_equal() {
3043        assert_eq!(Timeframe::Seconds(60), Timeframe::Seconds(60));
3044        assert_eq!(
3045            Timeframe::Seconds(3600).cmp(&Timeframe::Hours(1)),
3046            std::cmp::Ordering::Equal
3047        );
3048    }
3049
3050    #[test]
3051    fn test_timeframe_ord_sort() {
3052        let mut tfs = vec![
3053            Timeframe::Hours(1),
3054            Timeframe::Seconds(30),
3055            Timeframe::Minutes(5),
3056        ];
3057        tfs.sort();
3058        assert_eq!(tfs[0], Timeframe::Seconds(30));
3059        assert_eq!(tfs[1], Timeframe::Minutes(5));
3060        assert_eq!(tfs[2], Timeframe::Hours(1));
3061    }
3062
3063    #[test]
3064    fn test_ohlcv_aggregator_first_tick_sets_ohlcv() {
3065        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3066        let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 60_000);
3067        let result = agg.feed(&tick).unwrap();
3068        assert!(result.is_empty()); // no completed bar yet
3069        let bar = agg.current_bar().unwrap();
3070        assert_eq!(bar.open, dec!(50000));
3071        assert_eq!(bar.high, dec!(50000));
3072        assert_eq!(bar.low, dec!(50000));
3073        assert_eq!(bar.close, dec!(50000));
3074        assert_eq!(bar.volume, dec!(1));
3075        assert_eq!(bar.trade_count, 1);
3076    }
3077
3078    #[test]
3079    fn test_ohlcv_aggregator_high_low_tracking() {
3080        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3081        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3082            .unwrap();
3083        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
3084            .unwrap();
3085        agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
3086            .unwrap();
3087        let bar = agg.current_bar().unwrap();
3088        assert_eq!(bar.high, dec!(51000));
3089        assert_eq!(bar.low, dec!(49500));
3090        assert_eq!(bar.close, dec!(49500));
3091        assert_eq!(bar.trade_count, 3);
3092    }
3093
3094    #[test]
3095    fn test_ohlcv_aggregator_bar_completes_on_new_window() {
3096        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3097        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3098            .unwrap();
3099        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2), 60_500))
3100            .unwrap();
3101        // Tick in next minute window closes previous bar
3102        let mut bars = agg
3103            .feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
3104            .unwrap();
3105        assert_eq!(bars.len(), 1);
3106        let bar = bars.remove(0);
3107        assert!(bar.is_complete);
3108        assert_eq!(bar.open, dec!(50000));
3109        assert_eq!(bar.close, dec!(50100));
3110        assert_eq!(bar.volume, dec!(3));
3111        assert_eq!(bar.bar_start_ms, 60_000);
3112    }
3113
3114    #[test]
3115    fn test_ohlcv_aggregator_new_bar_started_after_completion() {
3116        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3117        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3118            .unwrap();
3119        agg.feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
3120            .unwrap();
3121        let bar = agg.current_bar().unwrap();
3122        assert_eq!(bar.open, dec!(50200));
3123        assert_eq!(bar.bar_start_ms, 120_000);
3124    }
3125
3126    #[test]
3127    fn test_ohlcv_aggregator_flush_marks_complete() {
3128        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3129        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3130            .unwrap();
3131        let flushed = agg.flush().unwrap();
3132        assert!(flushed.is_complete);
3133        assert!(agg.current_bar().is_none());
3134    }
3135
3136    #[test]
3137    fn test_ohlcv_aggregator_flush_empty_returns_none() {
3138        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3139        assert!(agg.flush().is_none());
3140    }
3141
3142    #[test]
3143    fn test_ohlcv_aggregator_wrong_symbol_returns_error() {
3144        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3145        let tick = make_tick("ETH-USD", dec!(3000), dec!(1), 60_000);
3146        let result = agg.feed(&tick);
3147        assert!(matches!(result, Err(StreamError::AggregationError { .. })));
3148    }
3149
3150    #[test]
3151    fn test_ohlcv_aggregator_volume_accumulates() {
3152        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3153        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1.5), 60_000))
3154            .unwrap();
3155        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2.5), 60_100))
3156            .unwrap();
3157        let bar = agg.current_bar().unwrap();
3158        assert_eq!(bar.volume, dec!(4));
3159    }
3160
3161    #[test]
3162    fn test_ohlcv_bar_symbol_and_timeframe() {
3163        let mut agg = agg("BTC-USD", Timeframe::Minutes(5));
3164        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 300_000))
3165            .unwrap();
3166        let bar = agg.current_bar().unwrap();
3167        assert_eq!(bar.symbol, "BTC-USD");
3168        assert_eq!(bar.timeframe, Timeframe::Minutes(5));
3169    }
3170
3171    #[test]
3172    fn test_ohlcv_aggregator_symbol_accessor() {
3173        let agg = agg("ETH-USD", Timeframe::Hours(1));
3174        assert_eq!(agg.symbol(), "ETH-USD");
3175        assert_eq!(agg.timeframe(), Timeframe::Hours(1));
3176    }
3177
3178    #[test]
3179    fn test_bar_aligned_by_exchange_ts_not_received_ts() {
3180        // exchange_ts_ms puts tick in minute 1 (60_000..120_000)
3181        // received_at_ms puts tick in minute 2 (120_000..180_000) due to latency
3182        // Bar should use the exchange timestamp.
3183        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3184        let tick = make_tick_with_exchange_ts("BTC-USD", dec!(50000), dec!(1), 60_500, 120_100);
3185        agg.feed(&tick).unwrap();
3186        let bar = agg.current_bar().unwrap();
3187        assert_eq!(bar.bar_start_ms, 60_000, "bar should use exchange_ts_ms");
3188    }
3189
3190    #[test]
3191    fn test_bar_falls_back_to_received_ts_when_no_exchange_ts() {
3192        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3193        let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 75_000);
3194        agg.feed(&tick).unwrap();
3195        let bar = agg.current_bar().unwrap();
3196        assert_eq!(bar.bar_start_ms, 60_000);
3197    }
3198
3199    // --- emit_empty_bars tests ---
3200
3201    #[test]
3202    fn test_emit_empty_bars_no_gap_no_empties() {
3203        // Consecutive bars — no gap — should not produce empty bars.
3204        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
3205            .unwrap()
3206            .with_emit_empty_bars(true);
3207        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3208            .unwrap();
3209        let bars = agg
3210            .feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
3211            .unwrap();
3212        // Only the completed bar for the first minute; no empties.
3213        assert_eq!(bars.len(), 1);
3214        assert_eq!(bars[0].bar_start_ms, 60_000);
3215        assert_eq!(bars[0].volume, dec!(1));
3216    }
3217
3218    #[test]
3219    fn test_emit_empty_bars_two_skipped_windows() {
3220        // Gap of 3 minutes: complete bar at 60s, then two empty bars at 120s and 180s,
3221        // then the 240s tick starts a new bar.
3222        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
3223            .unwrap()
3224            .with_emit_empty_bars(true);
3225        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3226            .unwrap();
3227        let bars = agg
3228            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
3229            .unwrap();
3230        // 1 real completed bar + 2 empty gap bars (120_000, 180_000)
3231        assert_eq!(bars.len(), 3);
3232        assert_eq!(bars[0].bar_start_ms, 60_000);
3233        assert!(!bars[0].volume.is_zero()); // real bar
3234        assert_eq!(bars[1].bar_start_ms, 120_000);
3235        assert!(bars[1].volume.is_zero()); // empty
3236        assert_eq!(bars[1].trade_count, 0);
3237        assert_eq!(bars[1].open, dec!(50000)); // last close carried forward
3238        assert_eq!(bars[2].bar_start_ms, 180_000);
3239        assert!(bars[2].volume.is_zero()); // empty
3240    }
3241
3242    #[test]
3243    fn test_emit_empty_bars_disabled_no_empties_on_gap() {
3244        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
3245            .unwrap()
3246            .with_emit_empty_bars(false);
3247        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3248            .unwrap();
3249        let bars = agg
3250            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
3251            .unwrap();
3252        assert_eq!(bars.len(), 1); // only real completed bar, no empties
3253    }
3254
3255    #[test]
3256    fn test_emit_empty_bars_is_complete_true() {
3257        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
3258            .unwrap()
3259            .with_emit_empty_bars(true);
3260        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3261            .unwrap();
3262        let bars = agg
3263            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
3264            .unwrap();
3265        for bar in &bars {
3266            assert!(bar.is_complete, "all emitted bars must be marked complete");
3267        }
3268    }
3269
3270    #[test]
3271    fn test_ohlcv_bar_display() {
3272        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3273        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3274            .unwrap();
3275        let bar = agg.current_bar().unwrap();
3276        let s = bar.to_string();
3277        assert!(s.contains("BTC-USD"));
3278        assert!(s.contains("1m"));
3279        assert!(s.contains("50000"));
3280    }
3281
3282    #[test]
3283    fn test_bar_count_increments_on_feed() {
3284        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3285        assert_eq!(agg.bar_count(), 0);
3286        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3287            .unwrap();
3288        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
3289            .unwrap();
3290        assert_eq!(agg.bar_count(), 1);
3291    }
3292
3293    #[test]
3294    fn test_bar_count_increments_on_flush() {
3295        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3296        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3297            .unwrap();
3298        agg.flush().unwrap();
3299        assert_eq!(agg.bar_count(), 1);
3300    }
3301
3302    #[test]
3303    fn test_ohlcv_bar_range() {
3304        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3305        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3306            .unwrap();
3307        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
3308            .unwrap();
3309        agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
3310            .unwrap();
3311        let bar = agg.current_bar().unwrap();
3312        assert_eq!(bar.range(), dec!(1500)); // 51000 - 49500
3313    }
3314
3315    #[test]
3316    fn test_ohlcv_bar_body_bullish() {
3317        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3318        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3319            .unwrap();
3320        agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_100))
3321            .unwrap();
3322        let bar = agg.current_bar().unwrap();
3323        // open=50000, close=50500 → body = 500
3324        assert_eq!(bar.body(), dec!(500));
3325    }
3326
3327    #[test]
3328    fn test_ohlcv_bar_body_bearish() {
3329        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3330        agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_000))
3331            .unwrap();
3332        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
3333            .unwrap();
3334        let bar = agg.current_bar().unwrap();
3335        // open=50500, close=50000 → body = 500 (abs)
3336        assert_eq!(bar.body(), dec!(500));
3337    }
3338
3339    #[test]
3340    fn test_aggregator_reset_clears_bar_and_count() {
3341        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3342        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3343            .unwrap();
3344        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
3345            .unwrap();
3346        assert_eq!(agg.bar_count(), 1);
3347        assert!(agg.current_bar().is_some());
3348        agg.reset();
3349        assert_eq!(agg.bar_count(), 0);
3350        assert!(agg.current_bar().is_none());
3351    }
3352
3353    #[test]
3354    fn test_ohlcv_bar_is_bullish_when_close_gt_open() {
3355        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3356        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3357            .unwrap();
3358        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
3359            .unwrap();
3360        let bar = agg.current_bar().unwrap();
3361        assert!(bar.is_bullish());
3362        assert!(!bar.is_bearish());
3363    }
3364
3365    #[test]
3366    fn test_ohlcv_bar_is_bearish_when_close_lt_open() {
3367        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3368        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_000))
3369            .unwrap();
3370        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
3371            .unwrap();
3372        let bar = agg.current_bar().unwrap();
3373        assert!(bar.is_bearish());
3374        assert!(!bar.is_bullish());
3375    }
3376
3377    #[test]
3378    fn test_ohlcv_bar_neither_bullish_nor_bearish_on_equal_open_close() {
3379        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3380        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3381            .unwrap();
3382        // Single tick: open == close
3383        let bar = agg.current_bar().unwrap();
3384        assert!(!bar.is_bullish());
3385        assert!(!bar.is_bearish());
3386    }
3387
3388    #[test]
3389    fn test_ohlcv_bar_vwap_single_tick_equals_price() {
3390        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3391        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(2), 60_000))
3392            .unwrap();
3393        let bar = agg.current_bar().unwrap();
3394        assert_eq!(bar.vwap, Some(dec!(50000)));
3395    }
3396
3397    #[test]
3398    fn test_ohlcv_bar_vwap_two_equal_price_ticks() {
3399        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3400        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3401            .unwrap();
3402        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(3), 60_100))
3403            .unwrap();
3404        let bar = agg.current_bar().unwrap();
3405        // vwap = (50000*1 + 50000*3) / (1+3) = 50000
3406        assert_eq!(bar.vwap, Some(dec!(50000)));
3407    }
3408
3409    #[test]
3410    fn test_ohlcv_bar_vwap_two_different_price_ticks() {
3411        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3412        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3413            .unwrap();
3414        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
3415            .unwrap();
3416        let bar = agg.current_bar().unwrap();
3417        // vwap = (50000*1 + 51000*1) / (1+1) = 50500
3418        assert_eq!(bar.vwap, Some(dec!(50500)));
3419    }
3420
3421    #[test]
3422    fn test_ohlcv_bar_vwap_gap_fill_is_none() {
3423        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
3424            .unwrap()
3425            .with_emit_empty_bars(true);
3426        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3427            .unwrap();
3428        let bars = agg
3429            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
3430            .unwrap();
3431        // bars[0] = real, bars[1] and bars[2] = gap-fills
3432        assert!(bars[0].vwap.is_some());
3433        assert!(bars[1].vwap.is_none());
3434        assert!(bars[2].vwap.is_none());
3435    }
3436
3437    #[test]
3438    fn test_aggregator_reset_allows_fresh_start() {
3439        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3440        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
3441            .unwrap();
3442        agg.reset();
3443        agg.feed(&make_tick("BTC-USD", dec!(99999), dec!(2), 60_000))
3444            .unwrap();
3445        let bar = agg.current_bar().unwrap();
3446        assert_eq!(bar.open, dec!(99999));
3447    }
3448
3449    // ── Timeframe::from_duration_ms ───────────────────────────────────────────
3450
3451    #[test]
3452    fn test_from_duration_ms_hours() {
3453        assert_eq!(Timeframe::from_duration_ms(3_600_000), Some(Timeframe::Hours(1)));
3454        assert_eq!(Timeframe::from_duration_ms(7_200_000), Some(Timeframe::Hours(2)));
3455    }
3456
3457    #[test]
3458    fn test_from_duration_ms_minutes() {
3459        assert_eq!(Timeframe::from_duration_ms(300_000), Some(Timeframe::Minutes(5)));
3460        assert_eq!(Timeframe::from_duration_ms(60_000), Some(Timeframe::Minutes(1)));
3461    }
3462
3463    #[test]
3464    fn test_from_duration_ms_seconds() {
3465        assert_eq!(Timeframe::from_duration_ms(15_000), Some(Timeframe::Seconds(15)));
3466        assert_eq!(Timeframe::from_duration_ms(1_000), Some(Timeframe::Seconds(1)));
3467    }
3468
3469    #[test]
3470    fn test_from_duration_ms_zero_returns_none() {
3471        assert_eq!(Timeframe::from_duration_ms(0), None);
3472    }
3473
3474    #[test]
3475    fn test_from_duration_ms_non_whole_second_returns_none() {
3476        assert_eq!(Timeframe::from_duration_ms(1_500), None);
3477    }
3478
3479    #[test]
3480    fn test_from_duration_ms_roundtrip() {
3481        for tf in [Timeframe::Seconds(30), Timeframe::Minutes(5), Timeframe::Hours(4)] {
3482            assert_eq!(Timeframe::from_duration_ms(tf.duration_ms()), Some(tf));
3483        }
3484    }
3485
3486    // ── OhlcvBar::is_doji / wick_upper / wick_lower ──────────────────────────
3487
3488    #[test]
3489    fn test_is_doji_exact_zero_body() {
3490        let bar = OhlcvBar {
3491            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
3492            bar_start_ms: 0, open: dec!(100), high: dec!(105),
3493            low: dec!(95), close: dec!(100),
3494            volume: dec!(1), trade_count: 1, is_complete: true,
3495            is_gap_fill: false, vwap: None,
3496        };
3497        assert!(bar.is_doji(Decimal::ZERO));
3498    }
3499
3500    #[test]
3501    fn test_is_doji_small_epsilon() {
3502        let bar = OhlcvBar {
3503            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
3504            bar_start_ms: 0, open: dec!(100), high: dec!(105),
3505            low: dec!(95), close: dec!(100.005),
3506            volume: dec!(1), trade_count: 1, is_complete: true,
3507            is_gap_fill: false, vwap: None,
3508        };
3509        assert!(bar.is_doji(dec!(0.01)));
3510        assert!(!bar.is_doji(Decimal::ZERO));
3511    }
3512
3513    #[test]
3514    fn test_wick_upper_bullish() {
3515        // open=100, close=104, high=107 → upper wick = 107 - 104 = 3
3516        let bar = OhlcvBar {
3517            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
3518            bar_start_ms: 0, open: dec!(100), high: dec!(107),
3519            low: dec!(98), close: dec!(104),
3520            volume: dec!(1), trade_count: 1, is_complete: true,
3521            is_gap_fill: false, vwap: None,
3522        };
3523        assert_eq!(bar.wick_upper(), dec!(3));
3524    }
3525
3526    #[test]
3527    fn test_wick_lower_bearish() {
3528        // open=104, close=100, low=97 → lower wick = 100 - 97 = 3
3529        let bar = OhlcvBar {
3530            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
3531            bar_start_ms: 0, open: dec!(104), high: dec!(107),
3532            low: dec!(97), close: dec!(100),
3533            volume: dec!(1), trade_count: 1, is_complete: true,
3534            is_gap_fill: false, vwap: None,
3535        };
3536        assert_eq!(bar.wick_lower(), dec!(3));
3537    }
3538
3539    // ── OhlcvAggregator::window_progress ─────────────────────────────────────
3540
3541    #[test]
3542    fn test_window_progress_none_when_no_bar() {
3543        let agg = agg("BTC-USD", Timeframe::Minutes(1));
3544        assert!(agg.window_progress(60_000).is_none());
3545    }
3546
3547    #[test]
3548    fn test_window_progress_at_start_is_zero() {
3549        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3550        // Tick at bar start.
3551        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
3552        assert_eq!(agg.window_progress(60_000), Some(0.0));
3553    }
3554
3555    #[test]
3556    fn test_window_progress_midpoint() {
3557        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3558        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
3559        // 30 s into a 60 s bar → 0.5
3560        let progress = agg.window_progress(90_000).unwrap();
3561        assert!((progress - 0.5).abs() < 1e-9);
3562    }
3563
3564    #[test]
3565    fn test_window_progress_clamps_at_one() {
3566        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3567        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
3568        // 90 s past the bar start (longer than the bar) → clamped to 1.0
3569        assert_eq!(agg.window_progress(150_000), Some(1.0));
3570    }
3571
3572    // ── OhlcvBar::price_change ────────────────────────────────────────────────
3573
3574    #[test]
3575    fn test_price_change_bullish_is_positive() {
3576        let bar = make_bar(dec!(100), dec!(110), dec!(98), dec!(105));
3577        assert_eq!(bar.price_change(), dec!(5));
3578    }
3579
3580    #[test]
3581    fn test_price_change_bearish_is_negative() {
3582        let bar = make_bar(dec!(105), dec!(110), dec!(98), dec!(100));
3583        assert_eq!(bar.price_change(), dec!(-5));
3584    }
3585
3586    #[test]
3587    fn test_price_change_doji_is_zero() {
3588        let bar = make_bar(dec!(100), dec!(102), dec!(98), dec!(100));
3589        assert_eq!(bar.price_change(), dec!(0));
3590    }
3591
3592    // ── OhlcvAggregator::total_volume ─────────────────────────────────────────
3593
3594    #[test]
3595    fn test_total_volume_zero_before_completion() {
3596        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3597        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
3598        // Bar not yet complete; total_volume should be zero
3599        assert_eq!(agg.total_volume(), dec!(0));
3600    }
3601
3602    #[test]
3603    fn test_total_volume_accumulates_across_bars() {
3604        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3605        // Bar 1: volume = 2
3606        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
3607        // Trigger completion of bar 1
3608        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
3609        // Bar 1 completed with volume 2. Bar 2 in progress with volume 3 (not counted).
3610        assert_eq!(agg.total_volume(), dec!(2));
3611        // Trigger completion of bar 2
3612        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
3613        assert_eq!(agg.total_volume(), dec!(5)); // 2 + 3
3614    }
3615
3616    #[test]
3617    fn test_total_volume_reset_clears() {
3618        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3619        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
3620        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
3621        agg.reset();
3622        assert_eq!(agg.total_volume(), dec!(0));
3623    }
3624
3625    // ── OhlcvBar::typical_price / median_price ────────────────────────────────
3626
3627    fn make_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
3628        OhlcvBar {
3629            symbol: "X".into(),
3630            timeframe: Timeframe::Minutes(1),
3631            bar_start_ms: 0,
3632            open,
3633            high,
3634            low,
3635            close,
3636            volume: dec!(1),
3637            trade_count: 1,
3638            is_complete: true,
3639            is_gap_fill: false,
3640            vwap: None,
3641        }
3642    }
3643
3644    #[test]
3645    fn test_typical_price() {
3646        // high=12, low=8, close=10 → (12+8+10)/3 = 10
3647        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
3648        assert_eq!(bar.typical_price(), dec!(10));
3649    }
3650
3651    #[test]
3652    fn test_median_price() {
3653        // high=12, low=8 → (12+8)/2 = 10
3654        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
3655        assert_eq!(bar.median_price(), dec!(10));
3656    }
3657
3658    #[test]
3659    fn test_typical_price_differs_from_median() {
3660        // high=10, low=6, close=10 → typical=(10+6+10)/3 = 26/3, median=(10+6)/2 = 8
3661        let bar = make_bar(dec!(8), dec!(10), dec!(6), dec!(10));
3662        assert_eq!(bar.median_price(), dec!(8));
3663        assert!(bar.typical_price() > bar.median_price());
3664    }
3665
3666    #[test]
3667    fn test_close_location_value_at_high() {
3668        // close == high → CLV = (high - low - 0) / range = 1.0
3669        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3670        let clv = bar.close_location_value().unwrap();
3671        assert!((clv - 1.0).abs() < 1e-9, "expected 1.0 got {clv}");
3672    }
3673
3674    #[test]
3675    fn test_close_location_value_at_low() {
3676        // close == low → CLV = (low - low - (high - low)) / range = -range/range = -1.0
3677        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(90));
3678        let clv = bar.close_location_value().unwrap();
3679        assert!((clv + 1.0).abs() < 1e-9, "expected -1.0 got {clv}");
3680    }
3681
3682    #[test]
3683    fn test_close_location_value_midpoint_is_zero() {
3684        // close == (high + low) / 2 → CLV = 0.0
3685        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3686        let clv = bar.close_location_value().unwrap();
3687        assert!(clv.abs() < 1e-9, "expected 0.0 got {clv}");
3688    }
3689
3690    #[test]
3691    fn test_close_location_value_zero_range_returns_none() {
3692        let bar = make_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3693        assert!(bar.close_location_value().is_none());
3694    }
3695
3696    #[test]
3697    fn test_body_direction_bullish() {
3698        let bar = make_bar(dec!(90), dec!(110), dec!(85), dec!(105));
3699        assert_eq!(bar.body_direction(), BarDirection::Bullish);
3700    }
3701
3702    #[test]
3703    fn test_body_direction_bearish() {
3704        let bar = make_bar(dec!(105), dec!(110), dec!(85), dec!(90));
3705        assert_eq!(bar.body_direction(), BarDirection::Bearish);
3706    }
3707
3708    #[test]
3709    fn test_body_direction_neutral() {
3710        let bar = make_bar(dec!(100), dec!(110), dec!(85), dec!(100));
3711        assert_eq!(bar.body_direction(), BarDirection::Neutral);
3712    }
3713
3714    // ── OhlcvAggregator::last_bar ─────────────────────────────────────────────
3715
3716    #[test]
3717    fn test_last_bar_none_before_completion() {
3718        let agg = agg("BTC-USD", Timeframe::Minutes(1));
3719        assert!(agg.last_bar().is_none());
3720    }
3721
3722    #[test]
3723    fn test_last_bar_set_after_bar_completion() {
3724        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3725        // First bar in window [60000, 120000)
3726        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
3727        // Second tick in next window completes the first bar
3728        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
3729        let last = agg.last_bar().unwrap();
3730        assert!(last.is_complete);
3731        assert_eq!(last.close, dec!(100));
3732    }
3733
3734    #[test]
3735    fn test_last_bar_set_after_flush() {
3736        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3737        agg.feed(&make_tick("BTC-USD", dec!(50), dec!(1), 60_000)).unwrap();
3738        let flushed = agg.flush().unwrap();
3739        assert_eq!(agg.last_bar().unwrap().close, flushed.close);
3740    }
3741
3742    #[test]
3743    fn test_last_bar_cleared_on_reset() {
3744        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3745        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
3746        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
3747        assert!(agg.last_bar().is_some());
3748        agg.reset();
3749        assert!(agg.last_bar().is_none());
3750    }
3751
3752    // ── OhlcvBar::weighted_close / price_change_pct / wick_ratio ─────────────
3753
3754    #[test]
3755    fn test_weighted_close_basic() {
3756        // (high + low + close*2) / 4 = (12 + 8 + 10*2) / 4 = 40/4 = 10
3757        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
3758        assert_eq!(bar.weighted_close(), dec!(10));
3759    }
3760
3761    #[test]
3762    fn test_weighted_close_weights_close_more_than_typical() {
3763        // high=100, low=0, close=80 → typical=(100+0+80)/3≈60, weighted=(100+0+80+80)/4=65
3764        let bar = make_bar(dec!(50), dec!(100), dec!(0), dec!(80));
3765        assert_eq!(bar.weighted_close(), dec!(65));
3766    }
3767
3768    #[test]
3769    fn test_price_change_pct_bullish() {
3770        // open=100, close=110 → +10%
3771        let bar = make_bar(dec!(100), dec!(115), dec!(95), dec!(110));
3772        let pct = bar.price_change_pct().unwrap();
3773        assert!((pct - 10.0).abs() < 1e-9, "expected 10.0 got {pct}");
3774    }
3775
3776    #[test]
3777    fn test_price_change_pct_bearish() {
3778        // open=200, close=180 → -10%
3779        let bar = make_bar(dec!(200), dec!(210), dec!(175), dec!(180));
3780        let pct = bar.price_change_pct().unwrap();
3781        assert!((pct - (-10.0)).abs() < 1e-9, "expected -10.0 got {pct}");
3782    }
3783
3784    #[test]
3785    fn test_price_change_pct_zero_open_returns_none() {
3786        let bar = make_bar(dec!(0), dec!(5), dec!(0), dec!(3));
3787        assert!(bar.price_change_pct().is_none());
3788    }
3789
3790    #[test]
3791    fn test_wick_ratio_all_wicks() {
3792        // open=close=5, high=10, low=0 → body=0, wicks=5+5=10, range=10 → ratio=1.0
3793        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
3794        let r = bar.wick_ratio().unwrap();
3795        assert!((r - 1.0).abs() < 1e-9, "expected 1.0 got {r}");
3796    }
3797
3798    #[test]
3799    fn test_wick_ratio_no_wicks() {
3800        // open=low=0, close=high=10 → body=10, wicks=0, range=10 → ratio=0.0
3801        let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
3802        let r = bar.wick_ratio().unwrap();
3803        assert!((r - 0.0).abs() < 1e-9, "expected 0.0 got {r}");
3804    }
3805
3806    #[test]
3807    fn test_wick_ratio_zero_range_returns_none() {
3808        // all prices identical → range=0
3809        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
3810        assert!(bar.wick_ratio().is_none());
3811    }
3812
3813    // ── OhlcvBar::body_ratio ──────────────────────────────────────────────────
3814
3815    #[test]
3816    fn test_body_ratio_no_wicks_is_one() {
3817        // open=low=0, close=high=10 → body=10, range=10 → ratio=1.0
3818        let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
3819        let r = bar.body_ratio().unwrap();
3820        assert!((r - 1.0).abs() < 1e-9);
3821    }
3822
3823    #[test]
3824    fn test_body_ratio_all_wicks_is_zero() {
3825        // doji: open=close=5, high=10, low=0 → body=0, range=10 → ratio=0.0
3826        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
3827        let r = bar.body_ratio().unwrap();
3828        assert!((r - 0.0).abs() < 1e-9);
3829    }
3830
3831    #[test]
3832    fn test_body_ratio_zero_range_returns_none() {
3833        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
3834        assert!(bar.body_ratio().is_none());
3835    }
3836
3837    #[test]
3838    fn test_body_ratio_plus_wick_ratio_equals_one() {
3839        // body + wicks = range → ratios sum to 1
3840        let bar = make_bar(dec!(4), dec!(10), dec!(0), dec!(8));
3841        let body = bar.body_ratio().unwrap();
3842        let wick = bar.wick_ratio().unwrap();
3843        assert!((body + wick - 1.0).abs() < 1e-9);
3844    }
3845
3846    // ── OhlcvAggregator::average_volume ──────────────────────────────────────
3847
3848    #[test]
3849    fn test_average_volume_none_before_bars() {
3850        let agg = agg("BTC-USD", Timeframe::Minutes(1));
3851        assert!(agg.average_volume().is_none());
3852    }
3853
3854    #[test]
3855    fn test_average_volume_one_bar() {
3856        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3857        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
3858        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
3859        // bar 1 complete with volume 4; bar 2 in progress, not counted
3860        assert_eq!(agg.average_volume(), Some(dec!(4)));
3861    }
3862
3863    #[test]
3864    fn test_average_volume_two_bars() {
3865        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3866        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
3867        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(6), 120_000)).unwrap();
3868        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
3869        // bar 1 vol=4, bar 2 vol=6 → avg=5
3870        assert_eq!(agg.average_volume(), Some(dec!(5)));
3871    }
3872
3873    // ── OhlcvBar::true_range / inside_bar / outside_bar ──────────────────────
3874
3875    #[test]
3876    fn test_true_range_no_gap() {
3877        // high=12, low=8, prev_close=10 → HL=4, H-prev=2, L-prev=2 → TR=4
3878        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
3879        assert_eq!(bar.true_range(dec!(10)), dec!(4));
3880    }
3881
3882    #[test]
3883    fn test_true_range_gap_up() {
3884        // high=15, low=12, prev_close=10 → HL=3, H-prev=5, L-prev=2 → TR=5
3885        let bar = make_bar(dec!(12), dec!(15), dec!(12), dec!(13));
3886        assert_eq!(bar.true_range(dec!(10)), dec!(5));
3887    }
3888
3889    #[test]
3890    fn test_true_range_gap_down() {
3891        // high=8, low=5, prev_close=12 → HL=3, H-prev=4, L-prev=7 → TR=7
3892        let bar = make_bar(dec!(7), dec!(8), dec!(5), dec!(6));
3893        assert_eq!(bar.true_range(dec!(12)), dec!(7));
3894    }
3895
3896    #[test]
3897    fn test_inside_bar_true_when_contained() {
3898        let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
3899        let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
3900        assert!(curr.is_inside_bar(&prev));
3901    }
3902
3903    #[test]
3904    fn test_inside_bar_false_when_not_contained() {
3905        let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
3906        let curr = make_bar(dec!(10), dec!(16), dec!(6), dec!(11));
3907        assert!(!curr.is_inside_bar(&prev));
3908    }
3909
3910    #[test]
3911    fn test_outside_bar_true_when_engulfing() {
3912        let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
3913        let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
3914        assert!(curr.outside_bar(&prev));
3915    }
3916
3917    #[test]
3918    fn test_outside_bar_false_when_not_engulfing() {
3919        let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
3920        let curr = make_bar(dec!(10), dec!(11), dec!(9), dec!(10));
3921        assert!(!curr.outside_bar(&prev));
3922    }
3923
3924    // ── OhlcvBar::is_hammer ───────────────────────────────────────────────────
3925
3926    #[test]
3927    fn test_is_hammer_classic() {
3928        // open=9, high=10, low=0, close=9 → body=0, wick_lo=9, wick_hi=1, range=10
3929        // body=0 ≤ 30%, wick_lo=9 ≥ 60%, wick_hi=1 ≤ 10% → hammer
3930        let bar = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
3931        assert!(bar.is_hammer());
3932    }
3933
3934    #[test]
3935    fn test_is_hammer_false_large_upper_wick() {
3936        // open=5, high=10, low=0, close=5 → body=0, wick_hi=5 (50%) → not hammer
3937        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
3938        assert!(!bar.is_hammer());
3939    }
3940
3941    #[test]
3942    fn test_is_hammer_false_zero_range() {
3943        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
3944        assert!(!bar.is_hammer());
3945    }
3946
3947    // ── OhlcvAggregator::peak_volume ─────────────────────────────────────────
3948
3949    #[test]
3950    fn test_peak_volume_none_before_completion() {
3951        let agg = agg("BTC-USD", Timeframe::Minutes(1));
3952        assert!(agg.peak_volume().is_none());
3953    }
3954
3955    #[test]
3956    fn test_peak_volume_tracks_maximum() {
3957        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3958        // Bar 1: vol=3
3959        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
3960        // Trigger bar 1 completion; bar 2 vol=10 in progress
3961        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
3962        assert_eq!(agg.peak_volume(), Some(dec!(3)));
3963        // Trigger bar 2 completion
3964        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
3965        assert_eq!(agg.peak_volume(), Some(dec!(10)));
3966    }
3967
3968    #[test]
3969    fn test_peak_volume_reset_clears() {
3970        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3971        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
3972        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
3973        agg.reset();
3974        assert!(agg.peak_volume().is_none());
3975    }
3976
3977    #[test]
3978    fn test_peak_volume_via_flush() {
3979        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
3980        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(7), 60_000)).unwrap();
3981        agg.flush();
3982        assert_eq!(agg.peak_volume(), Some(dec!(7)));
3983    }
3984
3985    // ── OhlcvBar::is_shooting_star ────────────────────────────────────────────
3986
3987    #[test]
3988    fn test_is_shooting_star_classic() {
3989        // open=1, high=10, low=0, close=1 → body=0, wick_hi=9, wick_lo=1, range=10
3990        // body≤30%, wick_hi=9≥60%, wick_lo=1≤10% → shooting star
3991        let bar = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
3992        assert!(bar.is_shooting_star());
3993    }
3994
3995    #[test]
3996    fn test_is_shooting_star_false_large_lower_wick() {
3997        // open=5, high=10, low=0, close=5 → lower wick = 5 (50%) → not shooting star
3998        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
3999        assert!(!bar.is_shooting_star());
4000    }
4001
4002    #[test]
4003    fn test_is_shooting_star_false_zero_range() {
4004        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
4005        assert!(!bar.is_shooting_star());
4006    }
4007
4008    #[test]
4009    fn test_hammer_and_shooting_star_are_mutually_exclusive_for_typical_bars() {
4010        // Classic hammer: long lower wick
4011        let hammer = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
4012        // Classic shooting star: long upper wick
4013        let star = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
4014        assert!(hammer.is_hammer() && !hammer.is_shooting_star());
4015        assert!(star.is_shooting_star() && !star.is_hammer());
4016    }
4017
4018    // ── OhlcvAggregator::min_volume ───────────────────────────────────────────
4019
4020    #[test]
4021    fn test_min_volume_none_before_completion() {
4022        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4023        assert!(agg.min_volume().is_none());
4024    }
4025
4026    #[test]
4027    fn test_min_volume_tracks_minimum() {
4028        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4029        // Bar 1: vol=10
4030        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(10), 60_000)).unwrap();
4031        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
4032        assert_eq!(agg.min_volume(), Some(dec!(10)));
4033        // Bar 2: vol=1 — should update minimum
4034        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
4035        assert_eq!(agg.min_volume(), Some(dec!(1)));
4036    }
4037
4038    #[test]
4039    fn test_min_volume_reset_clears() {
4040        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4041        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
4042        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
4043        agg.reset();
4044        assert!(agg.min_volume().is_none());
4045    }
4046
4047    // ── OhlcvBar::is_gap_up / is_gap_down ────────────────────────────────────
4048
4049    #[test]
4050    fn test_is_gap_up_true() {
4051        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
4052        let curr = make_bar(dec!(9), dec!(12), dec!(8), dec!(11)); // open=9 > prev.close=8
4053        assert!(curr.is_gap_up(&prev));
4054    }
4055
4056    #[test]
4057    fn test_is_gap_up_false_when_equal() {
4058        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
4059        let curr = make_bar(dec!(8), dec!(12), dec!(7), dec!(11)); // open=8 == prev.close=8
4060        assert!(!curr.is_gap_up(&prev));
4061    }
4062
4063    #[test]
4064    fn test_is_gap_down_true() {
4065        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
4066        let curr = make_bar(dec!(7), dec!(8), dec!(6), dec!(7)); // open=7 < prev.close=8
4067        assert!(curr.is_gap_down(&prev));
4068    }
4069
4070    #[test]
4071    fn test_is_gap_down_false_when_equal() {
4072        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
4073        let curr = make_bar(dec!(8), dec!(9), dec!(7), dec!(8)); // open=8 == prev.close=8
4074        assert!(!curr.is_gap_down(&prev));
4075    }
4076
4077    // ── OhlcvAggregator::volume_range ─────────────────────────────────────────
4078
4079    #[test]
4080    fn test_volume_range_none_before_completion() {
4081        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4082        assert!(agg.volume_range().is_none());
4083    }
4084
4085    #[test]
4086    fn test_volume_range_after_two_bars() {
4087        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4088        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
4089        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
4090        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
4091        // bar1=3, bar2=10 → min=3, peak=10
4092        assert_eq!(agg.volume_range(), Some((dec!(3), dec!(10))));
4093    }
4094
4095    // ── OhlcvBar::body_to_range_ratio ─────────────────────────────────────────
4096
4097    fn make_ohlcv_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
4098        OhlcvBar {
4099            symbol: "X".into(),
4100            timeframe: Timeframe::Minutes(1),
4101            open,
4102            high,
4103            low,
4104            close,
4105            volume: dec!(1),
4106            bar_start_ms: 0,
4107            trade_count: 1,
4108            is_complete: false,
4109            is_gap_fill: false,
4110            vwap: None,
4111        }
4112    }
4113
4114    #[test]
4115    fn test_body_to_range_ratio_bullish_full_body() {
4116        // open=100, close=110, high=110, low=100 → body=10, range=10 → ratio=1.0
4117        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
4118        assert_eq!(bar.body_to_range_ratio(), Some(dec!(1)));
4119    }
4120
4121    #[test]
4122    fn test_body_to_range_ratio_doji_like() {
4123        // open=close → body=0, range>0 → ratio=0
4124        let bar = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
4125        assert_eq!(bar.body_to_range_ratio(), Some(dec!(0)));
4126    }
4127
4128    #[test]
4129    fn test_body_to_range_ratio_none_when_range_zero() {
4130        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
4131        assert!(bar.body_to_range_ratio().is_none());
4132    }
4133
4134    // ── OhlcvAggregator::is_active ────────────────────────────────────────────
4135
4136    #[test]
4137    fn test_is_active_false_before_any_ticks() {
4138        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4139        assert!(!agg.is_active());
4140    }
4141
4142    #[test]
4143    fn test_is_active_true_after_first_tick() {
4144        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4145        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
4146        assert!(agg.is_active());
4147    }
4148
4149    #[test]
4150    fn test_is_active_false_after_flush() {
4151        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4152        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
4153        agg.flush();
4154        assert!(!agg.is_active());
4155    }
4156
4157    // ── OhlcvBar::is_long_upper_wick ──────────────────────────────────────────
4158
4159    #[test]
4160    fn test_is_long_upper_wick_true_when_upper_wick_dominates() {
4161        // open=100, close=101, high=110, low=100 → body=1, upper_wick=9
4162        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(101));
4163        assert!(bar.is_long_upper_wick());
4164    }
4165
4166    #[test]
4167    fn test_is_long_upper_wick_false_for_full_body() {
4168        // open=100, close=110, high=110, low=100 → body=10, upper_wick=0
4169        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
4170        assert!(!bar.is_long_upper_wick());
4171    }
4172
4173    #[test]
4174    fn test_is_long_upper_wick_false_when_equal() {
4175        // open=100, close=105, high=110, low=100 → body=5, upper_wick=5
4176        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(105));
4177        assert!(!bar.is_long_upper_wick());
4178    }
4179
4180    // ── OhlcvBar::price_change_abs ────────────────────────────────────────────
4181
4182    #[test]
4183    fn test_price_change_abs_bullish_bar() {
4184        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
4185        assert_eq!(bar.price_change_abs(), dec!(8));
4186    }
4187
4188    #[test]
4189    fn test_price_change_abs_bearish_bar() {
4190        let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(102));
4191        assert_eq!(bar.price_change_abs(), dec!(8));
4192    }
4193
4194    #[test]
4195    fn test_price_change_abs_doji_zero() {
4196        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
4197        assert_eq!(bar.price_change_abs(), dec!(0));
4198    }
4199
4200    // ── OhlcvAggregator::vwap_current ────────────────────────────────────────
4201
4202    #[test]
4203    fn test_vwap_current_none_before_any_ticks() {
4204        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4205        assert!(agg.vwap_current().is_none());
4206    }
4207
4208    #[test]
4209    fn test_vwap_current_equals_price_for_single_tick() {
4210        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4211        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(5), 1_000)).unwrap();
4212        // vwap = price*qty / qty = 200
4213        assert_eq!(agg.vwap_current(), Some(dec!(200)));
4214    }
4215
4216    #[test]
4217    fn test_vwap_current_weighted_average() {
4218        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4219        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
4220        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(3), 2_000)).unwrap();
4221        // vwap = (100*1 + 200*3) / (1+3) = 700/4 = 175
4222        assert_eq!(agg.vwap_current(), Some(dec!(175)));
4223    }
4224
4225    // --- upper_shadow / lower_shadow / is_spinning_top / hlc3 ---
4226
4227    fn bar(o: i64, h: i64, l: i64, c: i64) -> OhlcvBar {
4228        OhlcvBar {
4229            symbol: "X".into(),
4230            timeframe: Timeframe::Minutes(1),
4231            open: Decimal::from(o),
4232            high: Decimal::from(h),
4233            low: Decimal::from(l),
4234            close: Decimal::from(c),
4235            volume: Decimal::ZERO,
4236            bar_start_ms: 0,
4237            trade_count: 0,
4238            is_complete: false,
4239            is_gap_fill: false,
4240            vwap: None,
4241        }
4242    }
4243
4244    #[test]
4245    fn test_upper_shadow_equals_wick_upper() {
4246        let b = bar(100, 120, 90, 110);
4247        assert_eq!(b.upper_shadow(), b.wick_upper());
4248        assert_eq!(b.upper_shadow(), Decimal::from(10)); // 120 - max(100,110)
4249    }
4250
4251    #[test]
4252    fn test_lower_shadow_equals_wick_lower() {
4253        let b = bar(100, 120, 90, 110);
4254        assert_eq!(b.lower_shadow(), b.wick_lower());
4255        assert_eq!(b.lower_shadow(), Decimal::from(10)); // min(100,110) - 90
4256    }
4257
4258    #[test]
4259    fn test_is_spinning_top_true_when_small_body_large_wicks() {
4260        // body = |110-100| = 10, range = 130-80 = 50
4261        // body_pct = 0.3 → max_body = 15; body(10) <= 15
4262        // wick_upper = 130 - 110 = 20 > 10 ✓
4263        // wick_lower = 100 - 80 = 20 > 10 ✓
4264        let b = bar(100, 130, 80, 110);
4265        assert!(b.is_spinning_top(dec!(0.3)));
4266    }
4267
4268    #[test]
4269    fn test_is_spinning_top_false_when_body_too_large() {
4270        // body = 40, range = 50; body_pct=0.3 → max_body=15; 40 > 15
4271        let b = bar(80, 130, 80, 120);
4272        assert!(!b.is_spinning_top(dec!(0.3)));
4273    }
4274
4275    #[test]
4276    fn test_is_spinning_top_false_when_zero_range() {
4277        let b = bar(100, 100, 100, 100);
4278        assert!(!b.is_spinning_top(dec!(0.3)));
4279    }
4280
4281    #[test]
4282    fn test_hlc3_equals_typical_price() {
4283        let b = bar(100, 120, 80, 110);
4284        assert_eq!(b.hlc3(), b.typical_price());
4285        // (120 + 80 + 110) / 3 = 310/3
4286        assert_eq!(b.hlc3(), (Decimal::from(120) + Decimal::from(80) + Decimal::from(110)) / Decimal::from(3));
4287    }
4288
4289    // ── OhlcvBar::is_bearish ──────────────────────────────────────────────────
4290
4291    #[test]
4292    fn test_is_bearish_true_when_close_below_open() {
4293        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
4294        assert!(bar.is_bearish());
4295    }
4296
4297    #[test]
4298    fn test_is_bearish_false_when_close_above_open() {
4299        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
4300        assert!(!bar.is_bearish());
4301    }
4302
4303    #[test]
4304    fn test_is_bearish_false_when_doji() {
4305        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
4306        assert!(!bar.is_bearish());
4307    }
4308
4309    // ── OhlcvBar::wick_ratio ──────────────────────────────────────────────────
4310
4311    #[test]
4312    fn test_wick_ratio_zero_for_full_body_no_wicks() {
4313        // open=100, close=110, high=110, low=100 → no wicks → ratio=0
4314        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
4315        let ratio = bar.wick_ratio().unwrap();
4316        assert!(ratio.abs() < 1e-10);
4317    }
4318
4319    #[test]
4320    fn test_wick_ratio_one_for_pure_wick_doji() {
4321        // open=close=105, high=110, low=100 → body=0, upper=5, lower=5, range=10 → ratio=1
4322        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
4323        let ratio = bar.wick_ratio().unwrap();
4324        assert!((ratio - 1.0).abs() < 1e-10);
4325    }
4326
4327    #[test]
4328    fn test_wick_ratio_none_for_zero_range_bar() {
4329        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
4330        assert!(bar.wick_ratio().is_none());
4331    }
4332
4333    // ── OhlcvBar::is_bullish ──────────────────────────────────────────────────
4334
4335    #[test]
4336    fn test_is_bullish_true_when_close_above_open() {
4337        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
4338        assert!(bar.is_bullish());
4339    }
4340
4341    #[test]
4342    fn test_is_bullish_false_when_close_below_open() {
4343        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
4344        assert!(!bar.is_bullish());
4345    }
4346
4347    #[test]
4348    fn test_is_bullish_false_when_doji() {
4349        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
4350        assert!(!bar.is_bullish());
4351    }
4352
4353    // ── OhlcvBar::bar_duration_ms ─────────────────────────────────────────────
4354
4355    #[test]
4356    fn test_bar_duration_ms_one_minute() {
4357        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
4358        assert_eq!(bar.bar_duration_ms(), 60_000);
4359    }
4360
4361    #[test]
4362    fn test_bar_duration_ms_consistent_with_timeframe() {
4363        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
4364        bar.timeframe = Timeframe::Hours(1);
4365        assert_eq!(bar.bar_duration_ms(), 3_600_000);
4366    }
4367
4368    #[test]
4369    fn test_bar_duration_ms_seconds_timeframe() {
4370        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
4371        bar.timeframe = Timeframe::Seconds(30);
4372        assert_eq!(bar.bar_duration_ms(), 30_000);
4373    }
4374
4375    // --- ohlc4 / is_marubozu / is_engulfing ---
4376
4377    #[test]
4378    fn test_ohlc4_equals_average_of_all_four_prices() {
4379        let b = bar(100, 120, 80, 110);
4380        // (100 + 120 + 80 + 110) / 4 = 410 / 4 = 102.5
4381        let expected = (Decimal::from(100) + Decimal::from(120) + Decimal::from(80) + Decimal::from(110))
4382            / Decimal::from(4);
4383        assert_eq!(b.ohlc4(), expected);
4384    }
4385
4386    #[test]
4387    fn test_is_marubozu_true_when_no_wicks() {
4388        // Bullish marubozu: open=low=100, close=high=110
4389        let b = bar(100, 110, 100, 110);
4390        assert!(b.is_marubozu());
4391    }
4392
4393    #[test]
4394    fn test_is_marubozu_false_when_has_upper_wick() {
4395        let b = bar(100, 115, 100, 110);
4396        assert!(!b.is_marubozu());
4397    }
4398
4399    #[test]
4400    fn test_is_marubozu_false_when_has_lower_wick() {
4401        let b = bar(100, 110, 95, 110);
4402        assert!(!b.is_marubozu());
4403    }
4404
4405    // --- is_harami / tail_length ---
4406
4407    #[test]
4408    fn test_is_harami_true_when_body_inside_prev_body() {
4409        let prev = bar(98, 115, 90, 108); // prev body: 98-108
4410        let curr = bar(100, 110, 95, 105); // curr body: 100-105 — inside 98-108
4411        assert!(curr.is_harami(&prev));
4412    }
4413
4414    #[test]
4415    fn test_is_harami_false_when_body_engulfs_prev() {
4416        let prev = bar(100, 110, 95, 105); // prev body: 100-105
4417        let curr = bar(98, 115, 90, 108);  // curr body: 98-108 — engulfs prev
4418        assert!(!curr.is_harami(&prev));
4419    }
4420
4421    #[test]
4422    fn test_is_harami_false_when_bodies_equal() {
4423        let prev = bar(100, 110, 90, 105);
4424        let curr = bar(100, 110, 90, 105); // equal bodies
4425        assert!(!curr.is_harami(&prev));
4426    }
4427
4428    #[test]
4429    fn test_tail_length_upper_wick_longer() {
4430        // open=100, high=120, low=95, close=105 → upper_wick=15, lower_wick=5
4431        let b = bar(100, 120, 95, 105);
4432        assert_eq!(b.tail_length(), Decimal::from(15));
4433    }
4434
4435    #[test]
4436    fn test_tail_length_lower_wick_longer() {
4437        // open=105, high=110, low=80, close=100 → upper_wick=5, lower_wick=20
4438        let b = bar(105, 110, 80, 100);
4439        assert_eq!(b.tail_length(), Decimal::from(20));
4440    }
4441
4442    #[test]
4443    fn test_tail_length_zero_for_marubozu() {
4444        // open=low=100, close=high=110 → both wicks zero
4445        let b = bar(100, 110, 100, 110);
4446        assert!(b.tail_length().is_zero());
4447    }
4448
4449    // --- is_inside_bar / bar_type ---
4450
4451    #[test]
4452    fn test_is_inside_bar_true_when_range_within_prev() {
4453        let prev = bar(90, 120, 80, 110); // prev range: 80-120
4454        let curr = bar(95, 115, 85, 100); // curr range: 85-115 — inside 80-120
4455        assert!(curr.is_inside_bar(&prev));
4456    }
4457
4458    #[test]
4459    fn test_is_inside_bar_false_when_high_exceeds_prev_high() {
4460        let prev = bar(90, 110, 80, 100); // prev high = 110
4461        let curr = bar(95, 112, 85, 100); // curr high = 112 > 110
4462        assert!(!curr.is_inside_bar(&prev));
4463    }
4464
4465    #[test]
4466    fn test_is_inside_bar_false_when_equal_range() {
4467        let prev = bar(90, 110, 80, 100);
4468        let curr = bar(90, 110, 80, 100); // same high/low — not strictly inside
4469        assert!(!curr.is_inside_bar(&prev));
4470    }
4471
4472    #[test]
4473    fn test_bar_type_bullish() {
4474        let b = bar(100, 110, 90, 105); // close > open
4475        assert_eq!(b.bar_type(), "bullish");
4476    }
4477
4478    #[test]
4479    fn test_bar_type_bearish() {
4480        let b = bar(105, 110, 90, 100); // close < open
4481        assert_eq!(b.bar_type(), "bearish");
4482    }
4483
4484    #[test]
4485    fn test_bar_type_doji() {
4486        let b = bar(100, 110, 90, 100); // close == open
4487        assert_eq!(b.bar_type(), "doji");
4488    }
4489
4490    // --- body_pct / is_bullish_hammer ---
4491
4492    #[test]
4493    fn test_body_pct_none_for_zero_range() {
4494        let b = bar(100, 100, 100, 100);
4495        assert!(b.body_pct().is_none());
4496    }
4497
4498    #[test]
4499    fn test_body_pct_100_for_marubozu() {
4500        // open=low=100, close=high=110 → body=10, range=10, pct=100
4501        let b = bar(100, 110, 100, 110);
4502        assert_eq!(b.body_pct().unwrap(), Decimal::ONE_HUNDRED);
4503    }
4504
4505    #[test]
4506    fn test_body_pct_50_for_half_body() {
4507        // open=100, close=105, high=110, low=100 → body=5, range=10, pct=50
4508        let b = bar(100, 110, 100, 105);
4509        assert_eq!(b.body_pct().unwrap(), Decimal::from(50));
4510    }
4511
4512    #[test]
4513    fn test_is_bullish_hammer_true_for_classic_hammer() {
4514        // long lower wick, small body near top, tiny upper wick
4515        // open=108, high=110, low=100, close=109 → body=1, lower=8, upper=1
4516        let b = bar(108, 110, 100, 109);
4517        assert!(b.is_bullish_hammer());
4518    }
4519
4520    #[test]
4521    fn test_is_bullish_hammer_false_when_lower_wick_not_long_enough() {
4522        // open=100, high=110, low=98, close=108 → body=8, lower=2 < 2*8=16
4523        let b = bar(100, 110, 98, 108);
4524        assert!(!b.is_bullish_hammer());
4525    }
4526
4527    #[test]
4528    fn test_is_bullish_hammer_false_for_doji() {
4529        let b = bar(100, 110, 90, 100); // open == close, body = 0
4530        assert!(!b.is_bullish_hammer());
4531    }
4532
4533    // --- OhlcvBar::is_marubozu ---
4534    #[test]
4535    fn test_is_marubozu_true_when_full_body() {
4536        // open=100, high=100, low=100, close=110 → body=10, range=10 → 100%
4537        let b = bar(100, 110, 100, 110);
4538        assert!(b.is_marubozu());
4539    }
4540
4541    #[test]
4542    fn test_is_marubozu_false_when_large_wicks() {
4543        // open=100, high=120, low=80, close=110 → body=10, range=40 → 25%
4544        let b = bar(100, 120, 80, 110);
4545        assert!(!b.is_marubozu());
4546    }
4547
4548    #[test]
4549    fn test_is_marubozu_true_for_zero_range_flat_bar() {
4550        // flat bar has no wicks → qualifies as marubozu under "no wicks" definition
4551        let b = bar(100, 100, 100, 100);
4552        assert!(b.is_marubozu());
4553    }
4554
4555    // --- OhlcvBar::upper_wick_pct ---
4556    #[test]
4557    fn test_upper_wick_pct_zero_when_no_upper_wick() {
4558        // close is the high
4559        let b = bar(100, 110, 90, 110);
4560        let pct = b.upper_wick_pct().unwrap();
4561        assert!(pct.is_zero(), "expected 0, got {pct}");
4562    }
4563
4564    #[test]
4565    fn test_upper_wick_pct_50_when_half_range() {
4566        // open=100, high=120, low=100, close=110 → upper_wick=10, range=20 → 50%
4567        let b = bar(100, 120, 100, 110);
4568        let pct = b.upper_wick_pct().unwrap();
4569        assert_eq!(pct, dec!(50));
4570    }
4571
4572    #[test]
4573    fn test_upper_wick_pct_none_for_zero_range() {
4574        let b = bar(100, 100, 100, 100);
4575        assert!(b.upper_wick_pct().is_none());
4576    }
4577
4578    // --- OhlcvBar::lower_wick_pct ---
4579    #[test]
4580    fn test_lower_wick_pct_zero_when_no_lower_wick() {
4581        // open is the low
4582        let b = bar(100, 110, 100, 105);
4583        let pct = b.lower_wick_pct().unwrap();
4584        assert!(pct.is_zero(), "expected 0, got {pct}");
4585    }
4586
4587    #[test]
4588    fn test_lower_wick_pct_50_when_half_range() {
4589        // open=110, high=120, low=100, close=115 → lower_wick=10, range=20 → 50%
4590        let b = bar(110, 120, 100, 115);
4591        let pct = b.lower_wick_pct().unwrap();
4592        assert_eq!(pct, dec!(50));
4593    }
4594
4595    #[test]
4596    fn test_lower_wick_pct_none_for_zero_range() {
4597        let b = bar(100, 100, 100, 100);
4598        assert!(b.lower_wick_pct().is_none());
4599    }
4600
4601    // --- OhlcvBar::is_bearish_engulfing ---
4602    #[test]
4603    fn test_is_bearish_engulfing_true_for_bearish_engulf() {
4604        let prev = bar(100, 115, 95, 110); // bullish, body 100-110
4605        let curr = bar(112, 115, 88, 90);  // bearish, body 112-90, engulfs 100-110
4606        assert!(curr.is_bearish_engulfing(&prev));
4607    }
4608
4609    #[test]
4610    fn test_is_bearish_engulfing_false_for_bullish_engulf() {
4611        let prev = bar(110, 115, 95, 100); // bearish, body 110-100
4612        let curr = bar(98, 120, 95, 115);  // bullish, body 98-115 engulfs but not bearish
4613        assert!(!curr.is_bearish_engulfing(&prev));
4614    }
4615
4616    #[test]
4617    fn test_is_engulfing_true_when_body_contains_prev_body() {
4618        let prev = bar(100, 110, 95, 105); // prev body: 100-105
4619        let curr = bar(98, 115, 95, 108);  // curr body: 98-108 engulfs 100-105
4620        assert!(curr.is_engulfing(&prev));
4621    }
4622
4623    #[test]
4624    fn test_is_engulfing_false_when_only_partial_overlap() {
4625        let prev = bar(100, 115, 90, 112); // prev body: 100-112
4626        let curr = bar(101, 115, 90, 113); // curr body: 101-113 — lo=101 > 100, not engulfing
4627        assert!(!curr.is_engulfing(&prev));
4628    }
4629
4630    #[test]
4631    fn test_is_engulfing_false_for_equal_bodies() {
4632        let prev = bar(100, 110, 90, 108);
4633        let curr = bar(100, 110, 90, 108); // exactly equal
4634        assert!(!curr.is_engulfing(&prev));
4635    }
4636
4637    // ── OhlcvBar::has_upper_wick / has_lower_wick ─────────────────────────────
4638
4639    #[test]
4640    fn test_has_upper_wick_true_when_high_above_max_oc() {
4641        // open=100, close=110, high=115 → upper wick = 5
4642        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(100), dec!(110));
4643        assert!(bar.has_upper_wick());
4644    }
4645
4646    #[test]
4647    fn test_has_upper_wick_false_for_full_body() {
4648        // open=100, close=110, high=110 → no upper wick
4649        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
4650        assert!(!bar.has_upper_wick());
4651    }
4652
4653    #[test]
4654    fn test_has_lower_wick_true_when_low_below_min_oc() {
4655        // open=105, close=110, low=100 → lower wick = 5
4656        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(110));
4657        assert!(bar.has_lower_wick());
4658    }
4659
4660    #[test]
4661    fn test_has_lower_wick_false_for_full_body() {
4662        // open=100, close=110, low=100 → no lower wick
4663        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
4664        assert!(!bar.has_lower_wick());
4665    }
4666
4667    // ── OhlcvBar::is_gravestone_doji ──────────────────────────────────────────
4668
4669    #[test]
4670    fn test_is_gravestone_doji_true() {
4671        // open=close=low=100, high=110 → body=0, close≈low → gravestone
4672        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(100));
4673        assert!(bar.is_gravestone_doji(dec!(0)));
4674    }
4675
4676    #[test]
4677    fn test_is_gravestone_doji_false_when_close_above_low() {
4678        // open=100, close=105, low=99, high=110 → body=5 → not a doji
4679        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
4680        assert!(!bar.is_gravestone_doji(dec!(1)));
4681    }
4682
4683    // ── OhlcvBar::is_dragonfly_doji ───────────────────────────────────────────
4684
4685    #[test]
4686    fn test_is_dragonfly_doji_true() {
4687        // open=close=high=110, low=100 → body=0, close≈high → dragonfly
4688        let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(110));
4689        assert!(bar.is_dragonfly_doji(dec!(0)));
4690    }
4691
4692    #[test]
4693    fn test_is_dragonfly_doji_false_when_close_below_high() {
4694        // close=105, high=110 → close not near high
4695        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
4696        assert!(!bar.is_dragonfly_doji(dec!(1)));
4697    }
4698
4699    // ── OhlcvBar::is_flat / close_to_high_ratio / close_open_ratio ──────────
4700
4701    #[test]
4702    fn test_is_flat_true() {
4703        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
4704        assert!(bar.is_flat());
4705    }
4706
4707    #[test]
4708    fn test_is_flat_false_when_range_exists() {
4709        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4710        assert!(!bar.is_flat());
4711    }
4712
4713    #[test]
4714    fn test_close_to_high_ratio_normal() {
4715        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
4716        // close=110, high=110 → ratio=1.0
4717        let r = bar.close_to_high_ratio().unwrap();
4718        assert!((r - 1.0).abs() < 1e-9);
4719    }
4720
4721    #[test]
4722    fn test_close_to_high_ratio_none_when_high_zero() {
4723        let bar = make_ohlcv_bar(dec!(0), dec!(0), dec!(0), dec!(0));
4724        assert!(bar.close_to_high_ratio().is_none());
4725    }
4726
4727    #[test]
4728    fn test_close_open_ratio_normal() {
4729        // close=110, open=100 → ratio=1.1
4730        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
4731        let r = bar.close_open_ratio().unwrap();
4732        assert!((r - 1.1).abs() < 1e-9);
4733    }
4734
4735    #[test]
4736    fn test_close_open_ratio_none_when_open_zero() {
4737        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
4738        assert!(bar.close_open_ratio().is_none());
4739    }
4740
4741    // ── OhlcvBar::true_range_with_prev ────────────────────────────────────────
4742
4743    #[test]
4744    fn test_true_range_simple_hl_dominates() {
4745        // high=110, low=90, prev_close=100 → hl=20, hc=10, lc=10 → TR=20
4746        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4747        assert_eq!(bar.true_range_with_prev(dec!(100)), dec!(20));
4748    }
4749
4750    #[test]
4751    fn test_true_range_gap_up_dominates() {
4752        // prev_close=80, high=100, low=90 → hl=10, hc=20, lc=10 → TR=20
4753        let bar = make_ohlcv_bar(dec!(91), dec!(100), dec!(90), dec!(95));
4754        assert_eq!(bar.true_range_with_prev(dec!(80)), dec!(20));
4755    }
4756
4757    #[test]
4758    fn test_true_range_gap_down_dominates() {
4759        // prev_close=120, high=100, low=95 → hl=5, hc=20, lc=25 → TR=25
4760        let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97));
4761        assert_eq!(bar.true_range_with_prev(dec!(120)), dec!(25));
4762    }
4763
4764    // ── OhlcvBar::is_outside_bar / high_low_midpoint ─────────────────────────
4765
4766    #[test]
4767    fn test_is_outside_bar_true() {
4768        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
4769        let bar  = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
4770        assert!(bar.is_outside_bar(&prev));
4771    }
4772
4773    #[test]
4774    fn test_is_outside_bar_false_when_inside() {
4775        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4776        let bar  = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
4777        assert!(!bar.is_outside_bar(&prev));
4778    }
4779
4780    #[test]
4781    fn test_high_low_midpoint_correct() {
4782        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4783        // (110 + 90) / 2 = 100
4784        assert_eq!(bar.high_low_midpoint(), dec!(100));
4785    }
4786
4787    #[test]
4788    fn test_high_low_midpoint_uneven() {
4789        let bar = make_ohlcv_bar(dec!(100), dec!(111), dec!(90), dec!(100));
4790        // (111 + 90) / 2 = 100.5
4791        assert_eq!(bar.high_low_midpoint(), dec!(100.5));
4792    }
4793
4794    // ── OhlcvBar::gap_up / gap_down ──────────────────────────────────────────
4795
4796    #[test]
4797    fn test_gap_up_true() {
4798        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
4799        let bar  = make_ohlcv_bar(dec!(102), dec!(110), dec!(101), dec!(108));
4800        assert!(bar.gap_up(&prev));
4801    }
4802
4803    #[test]
4804    fn test_gap_up_false_when_no_gap() {
4805        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
4806        let bar  = make_ohlcv_bar(dec!(99), dec!(105), dec!(98), dec!(104));
4807        assert!(!bar.gap_up(&prev));
4808    }
4809
4810    #[test]
4811    fn test_gap_down_true() {
4812        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
4813        let bar  = make_ohlcv_bar(dec!(88), dec!(89), dec!(85), dec!(86));
4814        assert!(bar.gap_down(&prev));
4815    }
4816
4817    #[test]
4818    fn test_gap_down_false_when_no_gap() {
4819        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
4820        let bar  = make_ohlcv_bar(dec!(91), dec!(95), dec!(89), dec!(93));
4821        assert!(!bar.gap_down(&prev));
4822    }
4823
4824    // ── OhlcvBar::range_pct ──────────────────────────────────────────────────
4825
4826    #[test]
4827    fn test_range_pct_correct() {
4828        // open=100, high=110, low=90 → range=20, 20/100 * 100 = 20%
4829        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4830        let pct = bar.range_pct().unwrap();
4831        assert!((pct - 20.0).abs() < 1e-9);
4832    }
4833
4834    #[test]
4835    fn test_range_pct_none_when_open_zero() {
4836        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
4837        assert!(bar.range_pct().is_none());
4838    }
4839
4840    #[test]
4841    fn test_range_pct_zero_for_flat_bar() {
4842        // high == low → range = 0
4843        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
4844        let pct = bar.range_pct().unwrap();
4845        assert_eq!(pct, 0.0);
4846    }
4847
4848    // ── OhlcvBar::body_size ──────────────────────────────────────────────────
4849
4850    #[test]
4851    fn test_body_size_bullish_bar() {
4852        // open=100, close=110 → body = 10
4853        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
4854        assert_eq!(bar.body_size(), dec!(10));
4855    }
4856
4857    #[test]
4858    fn test_body_size_bearish_bar() {
4859        // open=110, close=100 → body = 10
4860        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
4861        assert_eq!(bar.body_size(), dec!(10));
4862    }
4863
4864    #[test]
4865    fn test_body_size_doji() {
4866        // open == close → body = 0
4867        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
4868        assert_eq!(bar.body_size(), dec!(0));
4869    }
4870
4871    // ── OhlcvBar::volume_delta / is_consolidating ────────────────────────────
4872
4873    #[test]
4874    fn test_volume_delta_positive_when_increasing() {
4875        let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
4876        prev.volume = dec!(1000);
4877        let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
4878        bar.volume = dec!(1500);
4879        assert_eq!(bar.volume_delta(&prev), dec!(500));
4880    }
4881
4882    #[test]
4883    fn test_volume_delta_negative_when_decreasing() {
4884        let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
4885        prev.volume = dec!(1500);
4886        let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
4887        bar.volume = dec!(1000);
4888        assert_eq!(bar.volume_delta(&prev), dec!(-500));
4889    }
4890
4891    #[test]
4892    fn test_is_consolidating_true_when_small_range() {
4893        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
4894        let bar  = make_ohlcv_bar(dec!(102), dec!(106), dec!(100), dec!(104)); // range=6 < 10
4895        assert!(bar.is_consolidating(&prev));
4896    }
4897
4898    #[test]
4899    fn test_is_consolidating_false_when_large_range() {
4900        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
4901        let bar  = make_ohlcv_bar(dec!(102), dec!(115), dec!(95), dec!(110)); // range=20, not < 10
4902        assert!(!bar.is_consolidating(&prev));
4903    }
4904
4905    // ── OhlcvBar::relative_volume / intraday_reversal ─────────────────────────
4906
4907    #[test]
4908    fn test_relative_volume_correct() {
4909        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
4910        // bar.volume = dec!(1) (default), avg = 2 → ratio = 0.5
4911        let rv = bar.relative_volume(dec!(2)).unwrap();
4912        assert!((rv - 0.5).abs() < 1e-9);
4913    }
4914
4915    #[test]
4916    fn test_relative_volume_none_when_avg_zero() {
4917        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
4918        assert!(bar.relative_volume(dec!(0)).is_none());
4919    }
4920
4921    #[test]
4922    fn test_intraday_reversal_true_for_bullish_then_bearish() {
4923        // prev: open=100, close=105 (bullish)
4924        let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
4925        // this: opens at 105 (≥ prev close), closes below prev open (100) → reversal
4926        let bar = make_ohlcv_bar(dec!(105), dec!(107), dec!(97), dec!(98));
4927        assert!(bar.intraday_reversal(&prev));
4928    }
4929
4930    #[test]
4931    fn test_intraday_reversal_false_for_continuation() {
4932        // prev: open=100, close=105 (bullish), this also bullish at lower open
4933        let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
4934        let bar = make_ohlcv_bar(dec!(104), dec!(115), dec!(103), dec!(113));
4935        assert!(!bar.intraday_reversal(&prev));
4936    }
4937
4938    // ── OhlcvBar::price_at_pct ───────────────────────────────────────────────
4939
4940    #[test]
4941    fn test_price_at_pct_zero_returns_low() {
4942        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4943        assert_eq!(bar.price_at_pct(0.0), dec!(90));
4944    }
4945
4946    #[test]
4947    fn test_price_at_pct_one_returns_high() {
4948        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4949        assert_eq!(bar.price_at_pct(1.0), dec!(110));
4950    }
4951
4952    #[test]
4953    fn test_price_at_pct_half_returns_midpoint() {
4954        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4955        // low=90, range=20, 0.5*20=10 → 90+10=100
4956        assert_eq!(bar.price_at_pct(0.5), dec!(100));
4957    }
4958
4959    #[test]
4960    fn test_price_at_pct_clamped_above_one() {
4961        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4962        assert_eq!(bar.price_at_pct(2.0), dec!(110));
4963    }
4964
4965    // ── average_true_range ────────────────────────────────────────────────────
4966
4967    #[test]
4968    fn test_average_true_range_none_when_fewer_than_two_bars() {
4969        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
4970        assert!(OhlcvBar::average_true_range(&[bar]).is_none());
4971        assert!(OhlcvBar::average_true_range(&[]).is_none());
4972    }
4973
4974    #[test]
4975    fn test_average_true_range_two_bars_no_gap() {
4976        // bar1: high=110 low=90 close=100
4977        // bar2: high=115 low=95 close=110  tr = max(115-95, |115-100|, |95-100|) = max(20,15,5) = 20
4978        let bar1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4979        let bar2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
4980        let atr = OhlcvBar::average_true_range(&[bar1, bar2]).unwrap();
4981        assert_eq!(atr, dec!(20)); // only one TR value: bar2 vs bar1.close=100
4982    }
4983
4984    #[test]
4985    fn test_average_true_range_three_bars_mean() {
4986        // bar1: close=100
4987        // bar2: h=110 l=90 c=105; tr = max(20, |110-100|, |90-100|) = max(20,10,10) = 20
4988        // bar3: h=120 l=100 c=115; tr = max(20, |120-105|, |100-105|) = max(20,15,5) = 20
4989        let bar1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4990        let bar2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
4991        let bar3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(115));
4992        let atr = OhlcvBar::average_true_range(&[bar1, bar2, bar3]).unwrap();
4993        assert_eq!(atr, dec!(20));
4994    }
4995
4996    // ── average_body ──────────────────────────────────────────────────────────
4997
4998    #[test]
4999    fn test_average_body_none_when_empty() {
5000        assert!(OhlcvBar::average_body(&[]).is_none());
5001    }
5002
5003    #[test]
5004    fn test_average_body_single_bar() {
5005        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5006        // body = |108 - 100| = 8
5007        assert_eq!(OhlcvBar::average_body(&[bar]), Some(dec!(8)));
5008    }
5009
5010    #[test]
5011    fn test_average_body_multiple_bars() {
5012        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)); // body=10
5013        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(100)); // body=10
5014        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120)); // body=20
5015        let avg = OhlcvBar::average_body(&[b1, b2, b3]).unwrap();
5016        // (10 + 10 + 20) / 3 = 40/3
5017        assert_eq!(avg, dec!(40) / dec!(3));
5018    }
5019
5020    // ── bullish_count / bearish_count / win_rate ──────────────────────────────
5021
5022    #[test]
5023    fn test_bullish_count_zero_for_empty_slice() {
5024        assert_eq!(OhlcvBar::bullish_count(&[]), 0);
5025    }
5026
5027    #[test]
5028    fn test_bullish_count_all_bullish() {
5029        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); // bullish
5030        let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(105), dec!(115)); // bullish
5031        assert_eq!(OhlcvBar::bullish_count(&[b1, b2]), 2);
5032    }
5033
5034    #[test]
5035    fn test_bearish_count_correct() {
5036        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5037        let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
5038        let doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5039        assert_eq!(OhlcvBar::bearish_count(&[bull, bear, doji]), 1);
5040    }
5041
5042    #[test]
5043    fn test_win_rate_none_when_empty() {
5044        assert!(OhlcvBar::win_rate(&[]).is_none());
5045    }
5046
5047    #[test]
5048    fn test_win_rate_all_bullish() {
5049        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5050        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(112));
5051        let wr = OhlcvBar::win_rate(&[b1, b2]).unwrap();
5052        assert!((wr - 1.0).abs() < 1e-9);
5053    }
5054
5055    #[test]
5056    fn test_win_rate_half_and_half() {
5057        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5058        let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
5059        let wr = OhlcvBar::win_rate(&[bull, bear]).unwrap();
5060        assert!((wr - 0.5).abs() < 1e-9);
5061    }
5062
5063    // ── bullish_streak / bearish_streak ──────────────────────────────────────
5064
5065    #[test]
5066    fn test_bullish_streak_zero_for_empty_slice() {
5067        assert_eq!(OhlcvBar::bullish_streak(&[]), 0);
5068    }
5069
5070    #[test]
5071    fn test_bullish_streak_zero_when_last_bar_bearish() {
5072        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5073        let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
5074        assert_eq!(OhlcvBar::bullish_streak(&[bull, bear]), 0);
5075    }
5076
5077    #[test]
5078    fn test_bullish_streak_counts_consecutive_tail() {
5079        let bear = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)); // bearish
5080        let bull1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(102)); // bullish
5081        let bull2 = make_ohlcv_bar(dec!(102), dec!(115), dec!(100), dec!(110)); // bullish
5082        assert_eq!(OhlcvBar::bullish_streak(&[bear, bull1, bull2]), 2);
5083    }
5084
5085    #[test]
5086    fn test_bearish_streak_counts_consecutive_tail() {
5087        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); // bullish
5088        let bear1 = make_ohlcv_bar(dec!(108), dec!(109), dec!(90), dec!(95)); // bearish
5089        let bear2 = make_ohlcv_bar(dec!(95), dec!(96), dec!(80), dec!(85)); // bearish
5090        assert_eq!(OhlcvBar::bearish_streak(&[bull, bear1, bear2]), 2);
5091    }
5092
5093    // ── max_drawdown ──────────────────────────────────────────────────────────
5094
5095    #[test]
5096    fn test_max_drawdown_none_when_fewer_than_2_bars() {
5097        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5098        assert!(OhlcvBar::max_drawdown(&[bar]).is_none());
5099        assert!(OhlcvBar::max_drawdown(&[]).is_none());
5100    }
5101
5102    #[test]
5103    fn test_max_drawdown_zero_when_monotone_increasing() {
5104        let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(100));
5105        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
5106        let b3 = make_ohlcv_bar(dec!(105), dec!(115), dec!(104), dec!(110));
5107        let dd = OhlcvBar::max_drawdown(&[b1, b2, b3]).unwrap();
5108        assert_eq!(dd, 0.0);
5109    }
5110
5111    #[test]
5112    fn test_max_drawdown_correct_after_peak_then_drop() {
5113        // closes: 100, 120, 90 → peak=120, drop=(120-90)/120 = 0.25
5114        let b1 = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
5115        let b2 = make_ohlcv_bar(dec!(100), dec!(125), dec!(99), dec!(120));
5116        let b3 = make_ohlcv_bar(dec!(120), dec!(121), dec!(88), dec!(90));
5117        let dd = OhlcvBar::max_drawdown(&[b1, b2, b3]).unwrap();
5118        assert!((dd - 0.25).abs() < 1e-9, "expected 0.25, got {dd}");
5119    }
5120
5121    // ── mean_volume ───────────────────────────────────────────────────────────
5122
5123    #[test]
5124    fn test_mean_volume_none_when_empty() {
5125        assert!(OhlcvBar::mean_volume(&[]).is_none());
5126    }
5127
5128    #[test]
5129    fn test_mean_volume_single_bar() {
5130        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5131        bar.volume = dec!(200);
5132        assert_eq!(OhlcvBar::mean_volume(&[bar]), Some(dec!(200)));
5133    }
5134
5135    #[test]
5136    fn test_mean_volume_multiple_bars() {
5137        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5138        b1.volume = dec!(100);
5139        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5140        b2.volume = dec!(200);
5141        let mut b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5142        b3.volume = dec!(300);
5143        assert_eq!(OhlcvBar::mean_volume(&[b1, b2, b3]), Some(dec!(200)));
5144    }
5145
5146    // ── vwap_deviation ────────────────────────────────────────────────────────
5147
5148    #[test]
5149    fn test_vwap_deviation_none_when_vwap_not_set() {
5150        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5151        assert!(bar.vwap_deviation().is_none());
5152    }
5153
5154    #[test]
5155    fn test_vwap_deviation_zero_when_close_equals_vwap() {
5156        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5157        bar.vwap = Some(dec!(100));
5158        assert_eq!(bar.vwap_deviation(), Some(0.0));
5159    }
5160
5161    #[test]
5162    fn test_vwap_deviation_correct_value() {
5163        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5164        bar.vwap = Some(dec!(100));
5165        // |110-100|/100 = 0.10
5166        let dev = bar.vwap_deviation().unwrap();
5167        assert!((dev - 0.1).abs() < 1e-10);
5168    }
5169
5170    // ── high_close_ratio ──────────────────────────────────────────────────────
5171
5172    #[test]
5173    fn test_high_close_ratio_none_when_high_zero() {
5174        let bar = OhlcvBar {
5175            symbol: "X".into(),
5176            timeframe: Timeframe::Minutes(1),
5177            open: dec!(0),
5178            high: dec!(0),
5179            low: dec!(0),
5180            close: dec!(0),
5181            volume: dec!(1),
5182            bar_start_ms: 0,
5183            trade_count: 1,
5184            is_complete: false,
5185            is_gap_fill: false,
5186            vwap: None,
5187        };
5188        assert!(bar.high_close_ratio().is_none());
5189    }
5190
5191    #[test]
5192    fn test_high_close_ratio_one_when_close_equals_high() {
5193        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5194        let ratio = bar.high_close_ratio().unwrap();
5195        assert!((ratio - 1.0).abs() < 1e-10);
5196    }
5197
5198    #[test]
5199    fn test_high_close_ratio_less_than_one_when_close_below_high() {
5200        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(99));
5201        let ratio = bar.high_close_ratio().unwrap();
5202        assert!(ratio < 1.0);
5203    }
5204
5205    // ── lower_shadow_pct ──────────────────────────────────────────────────────
5206
5207    #[test]
5208    fn test_lower_shadow_pct_none_when_range_zero() {
5209        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5210        assert!(bar.lower_shadow_pct().is_none());
5211    }
5212
5213    #[test]
5214    fn test_lower_shadow_pct_zero_when_no_lower_shadow() {
5215        // open=low=90, close=high=110 → lower_shadow=0
5216        let bar = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
5217        let pct = bar.lower_shadow_pct().unwrap();
5218        assert!(pct.abs() < 1e-10);
5219    }
5220
5221    #[test]
5222    fn test_lower_shadow_pct_correct_value() {
5223        // open=100, close=105, high=110, low=90 → lower_shadow=min(100,105)-90=10, range=20 → 0.5
5224        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5225        let pct = bar.lower_shadow_pct().unwrap();
5226        assert!((pct - 0.5).abs() < 1e-10);
5227    }
5228
5229    // ── open_close_ratio ──────────────────────────────────────────────────────
5230
5231    #[test]
5232    fn test_open_close_ratio_none_when_open_zero() {
5233        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
5234        assert!(bar.open_close_ratio().is_none());
5235    }
5236
5237    #[test]
5238    fn test_open_close_ratio_one_when_flat() {
5239        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5240        let ratio = bar.open_close_ratio().unwrap();
5241        assert!((ratio - 1.0).abs() < 1e-10);
5242    }
5243
5244    #[test]
5245    fn test_open_close_ratio_above_one_for_bullish_bar() {
5246        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
5247        let ratio = bar.open_close_ratio().unwrap();
5248        assert!(ratio > 1.0);
5249    }
5250
5251    // ── is_wide_range_bar ─────────────────────────────────────────────────────
5252
5253    #[test]
5254    fn test_is_wide_range_bar_true_when_range_exceeds_threshold() {
5255        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110)); // range=25
5256        assert!(bar.is_wide_range_bar(dec!(20)));
5257    }
5258
5259    #[test]
5260    fn test_is_wide_range_bar_false_when_range_equals_threshold() {
5261        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(100), dec!(110)); // range=20
5262        assert!(!bar.is_wide_range_bar(dec!(20)));
5263    }
5264
5265    // ── close_to_low_ratio ────────────────────────────────────────────────────
5266
5267    #[test]
5268    fn test_close_to_low_ratio_none_when_range_zero() {
5269        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5270        assert!(bar.close_to_low_ratio().is_none());
5271    }
5272
5273    #[test]
5274    fn test_close_to_low_ratio_one_when_closed_at_high() {
5275        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5276        let ratio = bar.close_to_low_ratio().unwrap();
5277        assert!((ratio - 1.0).abs() < 1e-10);
5278    }
5279
5280    #[test]
5281    fn test_close_to_low_ratio_zero_when_closed_at_low() {
5282        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
5283        let ratio = bar.close_to_low_ratio().unwrap();
5284        assert!(ratio.abs() < 1e-10);
5285    }
5286
5287    #[test]
5288    fn test_close_to_low_ratio_half_at_midpoint() {
5289        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5290        // (100-90)/(110-90) = 10/20 = 0.5
5291        let ratio = bar.close_to_low_ratio().unwrap();
5292        assert!((ratio - 0.5).abs() < 1e-10);
5293    }
5294
5295    // ── volume_per_trade ──────────────────────────────────────────────────────
5296
5297    #[test]
5298    fn test_volume_per_trade_none_when_trade_count_zero() {
5299        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5300        bar.trade_count = 0;
5301        assert!(bar.volume_per_trade().is_none());
5302    }
5303
5304    #[test]
5305    fn test_volume_per_trade_correct_value() {
5306        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5307        bar.volume = dec!(500);
5308        bar.trade_count = 5;
5309        assert_eq!(bar.volume_per_trade(), Some(dec!(100)));
5310    }
5311
5312    // ── price_range_overlap ───────────────────────────────────────────────────
5313
5314    #[test]
5315    fn test_price_range_overlap_true_when_ranges_overlap() {
5316        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5317        let b = make_ohlcv_bar(dec!(105), dec!(120), dec!(95), dec!(110));
5318        assert!(a.price_range_overlap(&b));
5319    }
5320
5321    #[test]
5322    fn test_price_range_overlap_false_when_no_overlap() {
5323        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5324        let b = make_ohlcv_bar(dec!(120), dec!(130), dec!(115), dec!(125));
5325        assert!(!a.price_range_overlap(&b));
5326    }
5327
5328    #[test]
5329    fn test_price_range_overlap_true_at_exact_touch() {
5330        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5331        let b = make_ohlcv_bar(dec!(115), dec!(125), dec!(110), dec!(120));
5332        assert!(a.price_range_overlap(&b));
5333    }
5334
5335    // ── bar_height_pct ────────────────────────────────────────────────────────
5336
5337    #[test]
5338    fn test_bar_height_pct_none_when_open_zero() {
5339        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
5340        assert!(bar.bar_height_pct().is_none());
5341    }
5342
5343    #[test]
5344    fn test_bar_height_pct_correct_value() {
5345        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // range=20
5346        // 20/100 = 0.2
5347        let pct = bar.bar_height_pct().unwrap();
5348        assert!((pct - 0.2).abs() < 1e-10);
5349    }
5350
5351    // ── is_bullish_engulfing ──────────────────────────────────────────────────
5352
5353    #[test]
5354    fn test_is_bullish_engulfing_true_for_valid_pattern() {
5355        // prev: bearish bar (open=110, close=100), this: bullish, engulfs (open=98, close=112)
5356        let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
5357        let bar = make_ohlcv_bar(dec!(98), dec!(115), dec!(95), dec!(112));
5358        assert!(bar.is_bullish_engulfing(&prev));
5359    }
5360
5361    #[test]
5362    fn test_is_bullish_engulfing_false_when_bearish() {
5363        let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
5364        let bar = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(95));
5365        assert!(!bar.is_bullish_engulfing(&prev));
5366    }
5367
5368    // ── close_gap ─────────────────────────────────────────────────────────────
5369
5370    #[test]
5371    fn test_close_gap_positive_for_gap_up() {
5372        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
5373        let bar = make_ohlcv_bar(dec!(106), dec!(110), dec!(103), dec!(108)); // open=106 > prev close=102
5374        assert_eq!(bar.close_gap(&prev), dec!(4));
5375    }
5376
5377    #[test]
5378    fn test_close_gap_negative_for_gap_down() {
5379        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
5380        let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97)); // open=98 < prev close=102
5381        assert_eq!(bar.close_gap(&prev), dec!(-4));
5382    }
5383
5384    #[test]
5385    fn test_close_gap_zero_when_no_gap() {
5386        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
5387        let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108));
5388        assert_eq!(bar.close_gap(&prev), dec!(0));
5389    }
5390
5391    // ── close_above_midpoint ──────────────────────────────────────────────────
5392
5393    #[test]
5394    fn test_close_above_midpoint_true_when_above_mid() {
5395        // high=110, low=90 → mid=100; close=105 > 100
5396        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5397        assert!(bar.close_above_midpoint());
5398    }
5399
5400    #[test]
5401    fn test_close_above_midpoint_false_when_at_mid() {
5402        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // close=mid=100
5403        assert!(!bar.close_above_midpoint());
5404    }
5405
5406    // ── close_momentum ────────────────────────────────────────────────────────
5407
5408    #[test]
5409    fn test_close_momentum_positive_when_rising() {
5410        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5411        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
5412        assert_eq!(bar.close_momentum(&prev), dec!(10));
5413    }
5414
5415    #[test]
5416    fn test_close_momentum_zero_when_unchanged() {
5417        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5418        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(100));
5419        assert_eq!(bar.close_momentum(&prev), dec!(0));
5420    }
5421
5422    // ── bar_range ─────────────────────────────────────────────────────────────
5423
5424    #[test]
5425    fn test_bar_range_correct() {
5426        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
5427        assert_eq!(bar.bar_range(), dec!(30));
5428    }
5429
5430    // ── linear_regression_slope ───────────────────────────────────────────────
5431
5432    #[test]
5433    fn test_linear_regression_slope_none_for_single_bar() {
5434        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5435        assert!(OhlcvBar::linear_regression_slope(&[bar]).is_none());
5436    }
5437
5438    #[test]
5439    fn test_linear_regression_slope_positive_for_rising_closes() {
5440        let bars = vec![
5441            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5442            make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110)),
5443            make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(120)),
5444        ];
5445        let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
5446        assert!(slope > 0.0, "slope should be positive for rising closes");
5447    }
5448
5449    #[test]
5450    fn test_linear_regression_slope_negative_for_falling_closes() {
5451        let bars = vec![
5452            make_ohlcv_bar(dec!(120), dec!(125), dec!(115), dec!(120)),
5453            make_ohlcv_bar(dec!(120), dec!(115), dec!(105), dec!(110)),
5454            make_ohlcv_bar(dec!(110), dec!(108), dec!(95), dec!(100)),
5455        ];
5456        let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
5457        assert!(slope < 0.0, "slope should be negative for falling closes");
5458    }
5459
5460    #[test]
5461    fn test_linear_regression_slope_near_zero_for_flat_closes() {
5462        let bars = vec![
5463            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5464            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5465            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5466        ];
5467        let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
5468        assert!(slope.abs() < 1e-10, "slope should be ~0 for identical closes");
5469    }
5470
5471    // ── volume_slope ──────────────────────────────────────────────────────────
5472
5473    #[test]
5474    fn test_volume_slope_none_for_single_bar() {
5475        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5476        assert!(OhlcvBar::volume_slope(&[bar]).is_none());
5477    }
5478
5479    #[test]
5480    fn test_volume_slope_positive_for_rising_volume() {
5481        let make_bar_with_vol = |v: u64| {
5482            let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5483            b.volume = Decimal::from(v);
5484            b
5485        };
5486        let bars = vec![make_bar_with_vol(100), make_bar_with_vol(200), make_bar_with_vol(300)];
5487        assert!(OhlcvBar::volume_slope(&bars).unwrap() > 0.0);
5488    }
5489
5490    // ── highest_close / lowest_close ──────────────────────────────────────────
5491
5492    #[test]
5493    fn test_highest_close_none_for_empty_slice() {
5494        assert!(OhlcvBar::highest_close(&[]).is_none());
5495    }
5496
5497    #[test]
5498    fn test_highest_close_returns_max_close() {
5499        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5500        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
5501        let b3 = make_ohlcv_bar(dec!(100), dec!(108), dec!(90), dec!(102));
5502        assert_eq!(OhlcvBar::highest_close(&[b1, b2, b3]), Some(dec!(115)));
5503    }
5504
5505    #[test]
5506    fn test_lowest_close_none_for_empty_slice() {
5507        assert!(OhlcvBar::lowest_close(&[]).is_none());
5508    }
5509
5510    #[test]
5511    fn test_lowest_close_returns_min_close() {
5512        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5513        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
5514        let b3 = make_ohlcv_bar(dec!(100), dec!(108), dec!(90), dec!(102));
5515        assert_eq!(OhlcvBar::lowest_close(&[b1, b2, b3]), Some(dec!(102)));
5516    }
5517
5518    // ── close_range / momentum ────────────────────────────────────────────────
5519
5520    #[test]
5521    fn test_close_range_none_for_empty_slice() {
5522        assert!(OhlcvBar::close_range(&[]).is_none());
5523    }
5524
5525    #[test]
5526    fn test_close_range_correct() {
5527        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(102));
5528        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
5529        // highest=115, lowest=102, range=13
5530        assert_eq!(OhlcvBar::close_range(&[b1, b2]), Some(dec!(13)));
5531    }
5532
5533    #[test]
5534    fn test_momentum_none_for_insufficient_bars() {
5535        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5536        assert!(OhlcvBar::momentum(&[bar], 1).is_none());
5537    }
5538
5539    #[test]
5540    fn test_momentum_positive_for_rising_close() {
5541        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5542        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
5543        // (110 - 100) / 100 = 0.10
5544        let mom = OhlcvBar::momentum(&[b1, b2], 1).unwrap();
5545        assert!((mom - 0.1).abs() < 1e-10);
5546    }
5547
5548    #[test]
5549    fn test_momentum_negative_for_falling_close() {
5550        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5551        let b2 = make_ohlcv_bar(dec!(100), dec!(108), dec!(88), dec!(99));
5552        // (99 - 110) / 110 ≈ -0.10
5553        let mom = OhlcvBar::momentum(&[b1, b2], 1).unwrap();
5554        assert!(mom < 0.0);
5555    }
5556
5557    // ── mean_close ────────────────────────────────────────────────────────────
5558
5559    #[test]
5560    fn test_mean_close_none_for_empty_slice() {
5561        assert!(OhlcvBar::mean_close(&[]).is_none());
5562    }
5563
5564    #[test]
5565    fn test_mean_close_single_bar() {
5566        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5567        assert_eq!(OhlcvBar::mean_close(&[bar]), Some(dec!(105)));
5568    }
5569
5570    #[test]
5571    fn test_mean_close_multiple_bars() {
5572        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5573        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5574        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120));
5575        // (100 + 110 + 120) / 3 = 110
5576        assert_eq!(OhlcvBar::mean_close(&[b1, b2, b3]), Some(dec!(110)));
5577    }
5578
5579    // ── close_std_dev ─────────────────────────────────────────────────────────
5580
5581    #[test]
5582    fn test_close_std_dev_none_for_single_bar() {
5583        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5584        assert!(OhlcvBar::close_std_dev(&[bar]).is_none());
5585    }
5586
5587    #[test]
5588    fn test_close_std_dev_zero_for_identical_closes() {
5589        let bars = vec![
5590            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5591            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5592        ];
5593        let sd = OhlcvBar::close_std_dev(&bars).unwrap();
5594        assert!(sd.abs() < 1e-10, "std_dev should be ~0 for identical closes");
5595    }
5596
5597    #[test]
5598    fn test_close_std_dev_positive_for_varied_closes() {
5599        let bars = vec![
5600            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)),
5601            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
5602        ];
5603        assert!(OhlcvBar::close_std_dev(&bars).unwrap() > 0.0);
5604    }
5605
5606    // ── price_efficiency_ratio ────────────────────────────────────────────────
5607
5608    #[test]
5609    fn test_price_efficiency_ratio_none_for_single_bar() {
5610        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5611        assert!(OhlcvBar::price_efficiency_ratio(&[bar]).is_none());
5612    }
5613
5614    #[test]
5615    fn test_price_efficiency_ratio_one_for_trending_price() {
5616        // All bars with same range, monotonically rising closes
5617        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5618        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(110));
5619        let b3 = make_ohlcv_bar(dec!(120), dec!(130), dec!(110), dec!(120));
5620        // net move = 20, total path = 3 * 20 = 60; ratio = 20/60 ≈ 0.333
5621        let ratio = OhlcvBar::price_efficiency_ratio(&[b1, b2, b3]).unwrap();
5622        assert!(ratio > 0.0 && ratio <= 1.0);
5623    }
5624
5625    #[test]
5626    fn test_price_efficiency_ratio_none_for_zero_total_range() {
5627        // Zero-range bars (high == low)
5628        let bars = vec![
5629            make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)),
5630            make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)),
5631        ];
5632        assert!(OhlcvBar::price_efficiency_ratio(&bars).is_none());
5633    }
5634
5635    // ── close_location_value / mean_clv ───────────────────────────────────────
5636
5637    #[test]
5638    fn test_clv_plus_one_when_close_at_high() {
5639        // close == high: CLV = ((high-low)-(0)) / (high-low) = 1
5640        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5641        let clv = bar.close_location_value().unwrap();
5642        assert!((clv - 1.0).abs() < 1e-10, "CLV should be 1.0 when close == high, got {clv}");
5643    }
5644
5645    #[test]
5646    fn test_clv_minus_one_when_close_at_low() {
5647        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
5648        let clv = bar.close_location_value().unwrap();
5649        assert!((clv + 1.0).abs() < 1e-10, "CLV should be -1.0 when close == low, got {clv}");
5650    }
5651
5652    #[test]
5653    fn test_clv_zero_when_close_at_midpoint() {
5654        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5655        let clv = bar.close_location_value().unwrap();
5656        assert!(clv.abs() < 1e-10, "CLV should be 0 at midpoint, got {clv}");
5657    }
5658
5659    #[test]
5660    fn test_clv_none_for_zero_range_bar() {
5661        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5662        assert!(bar.close_location_value().is_none());
5663    }
5664
5665    #[test]
5666    fn test_mean_clv_none_for_empty_slice() {
5667        assert!(OhlcvBar::mean_clv(&[]).is_none());
5668    }
5669
5670    #[test]
5671    fn test_mean_clv_positive_for_bullish_closes() {
5672        let bars = vec![
5673            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)), // close near high
5674            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(106)), // close above mid
5675        ];
5676        let clv = OhlcvBar::mean_clv(&bars).unwrap();
5677        assert!(clv > 0.0, "mean CLV should be positive when closes are near highs");
5678    }
5679
5680    #[test]
5681    fn test_mean_range_none_for_empty_slice() {
5682        assert!(OhlcvBar::mean_range(&[]).is_none());
5683    }
5684
5685    #[test]
5686    fn test_mean_range_single_bar() {
5687        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5688        assert_eq!(OhlcvBar::mean_range(&[bar]), Some(dec!(20)));
5689    }
5690
5691    #[test]
5692    fn test_mean_range_multiple_bars() {
5693        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range 20
5694        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100)); // range 40
5695        assert_eq!(OhlcvBar::mean_range(&[b1, b2]), Some(dec!(30)));
5696    }
5697
5698    #[test]
5699    fn test_close_z_score_none_for_empty_slice() {
5700        assert!(OhlcvBar::close_z_score(&[], dec!(100)).is_none());
5701    }
5702
5703    #[test]
5704    fn test_close_z_score_of_mean_is_zero() {
5705        let bars = vec![
5706            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5707            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5708            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
5709        ];
5710        // mean close = (100+100+110)/3 ≈ 103.33; z-score of mean should be ≈ 0
5711        let mean = (dec!(100) + dec!(100) + dec!(110)) / dec!(3);
5712        let z = OhlcvBar::close_z_score(&bars, mean).unwrap();
5713        assert!(z.abs() < 1e-6);
5714    }
5715
5716    #[test]
5717    fn test_close_z_score_positive_above_mean() {
5718        let bars = vec![
5719            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5720            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5721            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
5722        ];
5723        let z = OhlcvBar::close_z_score(&bars, dec!(120)).unwrap();
5724        assert!(z > 0.0);
5725    }
5726
5727    #[test]
5728    fn test_bollinger_band_width_none_for_empty_slice() {
5729        assert!(OhlcvBar::bollinger_band_width(&[]).is_none());
5730    }
5731
5732    #[test]
5733    fn test_bollinger_band_width_zero_for_identical_closes() {
5734        let bars = vec![
5735            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5736            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5737        ];
5738        assert_eq!(OhlcvBar::bollinger_band_width(&bars), Some(0.0));
5739    }
5740
5741    #[test]
5742    fn test_bollinger_band_width_positive_for_varying_closes() {
5743        let bars = vec![
5744            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)),
5745            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5746            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
5747        ];
5748        let bw = OhlcvBar::bollinger_band_width(&bars).unwrap();
5749        assert!(bw > 0.0);
5750    }
5751
5752    #[test]
5753    fn test_up_down_ratio_none_for_no_bearish_bars() {
5754        let bars = vec![
5755            make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105)), // bullish
5756        ];
5757        assert!(OhlcvBar::up_down_ratio(&bars).is_none());
5758    }
5759
5760    #[test]
5761    fn test_up_down_ratio_two_to_one() {
5762        let bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
5763        let bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
5764        let bars = vec![bull.clone(), bull, bear];
5765        let ratio = OhlcvBar::up_down_ratio(&bars).unwrap();
5766        assert!((ratio - 2.0).abs() < 1e-9);
5767    }
5768
5769    // ── OhlcvBar::volume_weighted_close ───────────────────────────────────────
5770
5771    #[test]
5772    fn test_volume_weighted_close_none_for_empty_slice() {
5773        assert!(OhlcvBar::volume_weighted_close(&[]).is_none());
5774    }
5775
5776    #[test]
5777    fn test_volume_weighted_close_single_bar() {
5778        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5779        bar.volume = dec!(10);
5780        assert_eq!(OhlcvBar::volume_weighted_close(&[bar]), Some(dec!(105)));
5781    }
5782
5783    #[test]
5784    fn test_volume_weighted_close_weights_by_volume() {
5785        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5786        b1.volume = dec!(1);
5787        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
5788        b2.volume = dec!(3);
5789        // vwc = (100*1 + 200*3) / (1+3) = 700 / 4 = 175
5790        assert_eq!(OhlcvBar::volume_weighted_close(&[b1, b2]), Some(dec!(175)));
5791    }
5792
5793    // ── OhlcvBar::rolling_return ──────────────────────────────────────────────
5794
5795    #[test]
5796    fn test_rolling_return_none_for_empty_slice() {
5797        assert!(OhlcvBar::rolling_return(&[]).is_none());
5798    }
5799
5800    #[test]
5801    fn test_rolling_return_none_for_single_bar() {
5802        assert!(OhlcvBar::rolling_return(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]).is_none());
5803    }
5804
5805    #[test]
5806    fn test_rolling_return_positive_when_close_rises() {
5807        let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5808        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
5809        let ret = OhlcvBar::rolling_return(&[b1, b2]).unwrap();
5810        assert!((ret - 0.1).abs() < 1e-9);
5811    }
5812
5813    // ── OhlcvBar::average_high / average_low ─────────────────────────────────
5814
5815    #[test]
5816    fn test_average_high_none_for_empty_slice() {
5817        assert!(OhlcvBar::average_high(&[]).is_none());
5818    }
5819
5820    #[test]
5821    fn test_average_high_single_bar() {
5822        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105));
5823        assert_eq!(OhlcvBar::average_high(&[bar]), Some(dec!(120)));
5824    }
5825
5826    #[test]
5827    fn test_average_high_multiple_bars() {
5828        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5829        let b2 = make_ohlcv_bar(dec!(100), dec!(130), dec!(90), dec!(105));
5830        assert_eq!(OhlcvBar::average_high(&[b1, b2]), Some(dec!(120)));
5831    }
5832
5833    #[test]
5834    fn test_average_low_none_for_empty_slice() {
5835        assert!(OhlcvBar::average_low(&[]).is_none());
5836    }
5837
5838    #[test]
5839    fn test_average_low_single_bar() {
5840        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(105));
5841        assert_eq!(OhlcvBar::average_low(&[bar]), Some(dec!(80)));
5842    }
5843
5844    #[test]
5845    fn test_average_low_multiple_bars() {
5846        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(80), dec!(105));
5847        let b2 = make_ohlcv_bar(dec!(100), dec!(130), dec!(60), dec!(105));
5848        assert_eq!(OhlcvBar::average_low(&[b1, b2]), Some(dec!(70)));
5849    }
5850
5851    // ── OhlcvBar::min_body / max_body ─────────────────────────────────────────
5852
5853    #[test]
5854    fn test_min_body_none_for_empty_slice() {
5855        assert!(OhlcvBar::min_body(&[]).is_none());
5856    }
5857
5858    #[test]
5859    fn test_min_body_returns_smallest_body() {
5860        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // body=5
5861        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(115)); // body=15
5862        assert_eq!(OhlcvBar::min_body(&[b1, b2]), Some(dec!(5)));
5863    }
5864
5865    #[test]
5866    fn test_max_body_none_for_empty_slice() {
5867        assert!(OhlcvBar::max_body(&[]).is_none());
5868    }
5869
5870    #[test]
5871    fn test_max_body_returns_largest_body() {
5872        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // body=5
5873        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(115)); // body=15
5874        assert_eq!(OhlcvBar::max_body(&[b1, b2]), Some(dec!(15)));
5875    }
5876
5877    // ── OhlcvBar::atr_pct ────────────────────────────────────────────────────
5878
5879    #[test]
5880    fn test_atr_pct_none_for_single_bar() {
5881        assert!(OhlcvBar::atr_pct(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]).is_none());
5882    }
5883
5884    #[test]
5885    fn test_atr_pct_positive_for_normal_bars() {
5886        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5887        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5888        let pct = OhlcvBar::atr_pct(&[b1, b2]).unwrap();
5889        assert!(pct > 0.0);
5890    }
5891
5892    // ── OhlcvBar::breakout_count ──────────────────────────────────────────────
5893
5894    #[test]
5895    fn test_breakout_count_zero_for_empty_slice() {
5896        assert_eq!(OhlcvBar::breakout_count(&[]), 0);
5897    }
5898
5899    #[test]
5900    fn test_breakout_count_zero_for_single_bar() {
5901        assert_eq!(OhlcvBar::breakout_count(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), 0);
5902    }
5903
5904    #[test]
5905    fn test_breakout_count_detects_close_above_prev_high() {
5906        // b1: high=110; b2: close=115 > 110 → breakout
5907        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5908        let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(105), dec!(115));
5909        assert_eq!(OhlcvBar::breakout_count(&[b1, b2]), 1);
5910    }
5911
5912    #[test]
5913    fn test_breakout_count_zero_when_close_at_prev_high() {
5914        // close == prev high → not a strict breakout
5915        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5916        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(110));
5917        assert_eq!(OhlcvBar::breakout_count(&[b1, b2]), 0);
5918    }
5919
5920    // ── OhlcvBar::doji_count ──────────────────────────────────────────────────
5921
5922    #[test]
5923    fn test_doji_count_zero_for_empty_slice() {
5924        assert_eq!(OhlcvBar::doji_count(&[], dec!(0.001)), 0);
5925    }
5926
5927    #[test]
5928    fn test_doji_count_detects_doji_bars() {
5929        let doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // body=0
5930        let non_doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)); // body=10
5931        assert_eq!(OhlcvBar::doji_count(&[doji, non_doji], dec!(1)), 1);
5932    }
5933
5934    // ── OhlcvBar::channel_width ───────────────────────────────────────────────
5935
5936    #[test]
5937    fn test_channel_width_none_for_empty_slice() {
5938        assert!(OhlcvBar::channel_width(&[]).is_none());
5939    }
5940
5941    #[test]
5942    fn test_channel_width_correct() {
5943        let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105));
5944        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(80), dec!(100));
5945        // highest_high = 120, lowest_low = 80, width = 40
5946        assert_eq!(OhlcvBar::channel_width(&[b1, b2]), Some(dec!(40)));
5947    }
5948
5949    // ── OhlcvBar::sma ─────────────────────────────────────────────────────────
5950
5951    #[test]
5952    fn test_sma_none_for_zero_period() {
5953        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5954        assert!(OhlcvBar::sma(&[bar], 0).is_none());
5955    }
5956
5957    #[test]
5958    fn test_sma_none_when_fewer_bars_than_period() {
5959        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5960        assert!(OhlcvBar::sma(&[bar], 3).is_none());
5961    }
5962
5963    #[test]
5964    fn test_sma_correct_for_last_n_bars() {
5965        let bars = vec![
5966            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
5967            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
5968            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120)),
5969        ];
5970        // sma(3) = (100 + 110 + 120) / 3 = 110
5971        assert_eq!(OhlcvBar::sma(&bars, 3), Some(dec!(110)));
5972    }
5973
5974    // ── OhlcvBar::mean_wick_ratio ─────────────────────────────────────────────
5975
5976    #[test]
5977    fn test_mean_wick_ratio_none_for_empty_slice() {
5978        assert!(OhlcvBar::mean_wick_ratio(&[]).is_none());
5979    }
5980
5981    #[test]
5982    fn test_mean_wick_ratio_in_range_zero_to_one() {
5983        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5984        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(100));
5985        let ratio = OhlcvBar::mean_wick_ratio(&[b1, b2]).unwrap();
5986        assert!(ratio >= 0.0 && ratio <= 1.0);
5987    }
5988
5989    // ── OhlcvBar::bullish_volume / bearish_volume ─────────────────────────────
5990
5991    #[test]
5992    fn test_bullish_volume_zero_for_empty_slice() {
5993        assert_eq!(OhlcvBar::bullish_volume(&[]), dec!(0));
5994    }
5995
5996    #[test]
5997    fn test_bullish_volume_sums_bullish_bars() {
5998        let mut bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
5999        bull.volume = dec!(100);
6000        let mut bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
6001        bear.volume = dec!(50);
6002        assert_eq!(OhlcvBar::bullish_volume(&[bull, bear]), dec!(100));
6003    }
6004
6005    #[test]
6006    fn test_bearish_volume_zero_for_empty_slice() {
6007        assert_eq!(OhlcvBar::bearish_volume(&[]), dec!(0));
6008    }
6009
6010    #[test]
6011    fn test_bearish_volume_sums_bearish_bars() {
6012        let mut bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
6013        bull.volume = dec!(100);
6014        let mut bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
6015        bear.volume = dec!(50);
6016        assert_eq!(OhlcvBar::bearish_volume(&[bull, bear]), dec!(50));
6017    }
6018
6019    // ── OhlcvBar::close_above_mid_count ──────────────────────────────────────
6020
6021    #[test]
6022    fn test_close_above_mid_count_zero_for_empty_slice() {
6023        assert_eq!(OhlcvBar::close_above_mid_count(&[]), 0);
6024    }
6025
6026    #[test]
6027    fn test_close_above_mid_count_correct() {
6028        let above_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110)); // mid=100, close=110 > 100
6029        let at_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100)); // mid=100, close=100 not > 100
6030        let below_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(85)); // mid=100, close=85 < 100
6031        assert_eq!(OhlcvBar::close_above_mid_count(&[above_mid, at_mid, below_mid]), 1);
6032    }
6033
6034    // ── OhlcvBar::ema ─────────────────────────────────────────────────────────
6035
6036    #[test]
6037    fn test_ema_none_for_empty_slice() {
6038        assert!(OhlcvBar::ema(&[], 0.5).is_none());
6039    }
6040
6041    #[test]
6042    fn test_ema_single_bar_equals_close() {
6043        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6044        let e = OhlcvBar::ema(&[bar], 0.5).unwrap();
6045        assert!((e - 105.0).abs() < 1e-9);
6046    }
6047
6048    #[test]
6049    fn test_ema_alpha_one_equals_last_close() {
6050        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6051        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
6052        let e = OhlcvBar::ema(&[b1, b2], 1.0).unwrap();
6053        assert!((e - 200.0).abs() < 1e-9);
6054    }
6055
6056    // ── OhlcvBar::highest_open / lowest_open ─────────────────────────────────
6057
6058    #[test]
6059    fn test_highest_open_none_for_empty_slice() {
6060        assert!(OhlcvBar::highest_open(&[]).is_none());
6061    }
6062
6063    #[test]
6064    fn test_highest_open_returns_max() {
6065        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6066        let b2 = make_ohlcv_bar(dec!(130), dec!(140), dec!(120), dec!(135));
6067        assert_eq!(OhlcvBar::highest_open(&[b1, b2]), Some(dec!(130)));
6068    }
6069
6070    #[test]
6071    fn test_lowest_open_none_for_empty_slice() {
6072        assert!(OhlcvBar::lowest_open(&[]).is_none());
6073    }
6074
6075    #[test]
6076    fn test_lowest_open_returns_min() {
6077        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6078        let b2 = make_ohlcv_bar(dec!(130), dec!(140), dec!(120), dec!(135));
6079        assert_eq!(OhlcvBar::lowest_open(&[b1, b2]), Some(dec!(100)));
6080    }
6081
6082    // ── OhlcvBar::rising_close_count ─────────────────────────────────────────
6083
6084    #[test]
6085    fn test_rising_close_count_zero_for_empty_slice() {
6086        assert_eq!(OhlcvBar::rising_close_count(&[]), 0);
6087    }
6088
6089    #[test]
6090    fn test_rising_close_count_zero_for_single_bar() {
6091        assert_eq!(OhlcvBar::rising_close_count(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), 0);
6092    }
6093
6094    #[test]
6095    fn test_rising_close_count_correct() {
6096        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6097        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(110)); // close > prev
6098        let b3 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(105)); // close < prev
6099        let b4 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(115)); // close > prev
6100        assert_eq!(OhlcvBar::rising_close_count(&[b1, b2, b3, b4]), 2);
6101    }
6102
6103    // ── OhlcvBar::mean_body_ratio ─────────────────────────────────────────────
6104
6105    #[test]
6106    fn test_mean_body_ratio_none_for_empty_slice() {
6107        assert!(OhlcvBar::mean_body_ratio(&[]).is_none());
6108    }
6109
6110    #[test]
6111    fn test_mean_body_ratio_in_range_zero_to_one() {
6112        let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
6113        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
6114        let ratio = OhlcvBar::mean_body_ratio(&[b1, b2]).unwrap();
6115        assert!(ratio >= 0.0 && ratio <= 1.0);
6116    }
6117
6118    // ── OhlcvBar::volume_std_dev ──────────────────────────────────────────────
6119
6120    #[test]
6121    fn test_volume_std_dev_none_for_single_bar() {
6122        let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6123        b.volume = dec!(100);
6124        assert!(OhlcvBar::volume_std_dev(&[b]).is_none());
6125    }
6126
6127    #[test]
6128    fn test_volume_std_dev_zero_for_identical_volumes() {
6129        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(50);
6130        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(50);
6131        assert_eq!(OhlcvBar::volume_std_dev(&[b1, b2]), Some(0.0));
6132    }
6133
6134    #[test]
6135    fn test_volume_std_dev_positive_for_varied_volumes() {
6136        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
6137        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
6138        let std = OhlcvBar::volume_std_dev(&[b1, b2]).unwrap();
6139        assert!(std > 0.0);
6140    }
6141
6142    // ── OhlcvBar::max_volume_bar / min_volume_bar ─────────────────────────────
6143
6144    #[test]
6145    fn test_max_volume_bar_none_for_empty_slice() {
6146        assert!(OhlcvBar::max_volume_bar(&[]).is_none());
6147    }
6148
6149    #[test]
6150    fn test_max_volume_bar_returns_highest_volume() {
6151        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
6152        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
6153        let bars = [b1, b2];
6154        let bar = OhlcvBar::max_volume_bar(&bars).unwrap();
6155        assert_eq!(bar.volume, dec!(100));
6156    }
6157
6158    #[test]
6159    fn test_min_volume_bar_returns_lowest_volume() {
6160        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
6161        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
6162        let bars = [b1, b2];
6163        let bar = OhlcvBar::min_volume_bar(&bars).unwrap();
6164        assert_eq!(bar.volume, dec!(10));
6165    }
6166
6167    // ── OhlcvBar::gap_sum ─────────────────────────────────────────────────────
6168
6169    #[test]
6170    fn test_gap_sum_zero_for_empty_slice() {
6171        assert_eq!(OhlcvBar::gap_sum(&[]), dec!(0));
6172    }
6173
6174    #[test]
6175    fn test_gap_sum_zero_for_single_bar() {
6176        assert_eq!(OhlcvBar::gap_sum(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), dec!(0));
6177    }
6178
6179    #[test]
6180    fn test_gap_sum_positive_for_gap_up_sequence() {
6181        // b1 close=100, b2 open=110 → gap = +10
6182        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6183        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
6184        assert_eq!(OhlcvBar::gap_sum(&[b1, b2]), dec!(10));
6185    }
6186
6187    #[test]
6188    fn test_gap_sum_negative_for_gap_down_sequence() {
6189        // b1 close=100, b2 open=90 → gap = -10
6190        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6191        let b2 = make_ohlcv_bar(dec!(90), dec!(95), dec!(80), dec!(85));
6192        assert_eq!(OhlcvBar::gap_sum(&[b1, b2]), dec!(-10));
6193    }
6194
6195    // ── OhlcvBar::three_white_soldiers ────────────────────────────────────────
6196
6197    #[test]
6198    fn test_three_white_soldiers_false_for_fewer_than_3_bars() {
6199        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6200        assert!(!OhlcvBar::three_white_soldiers(&[b]));
6201    }
6202
6203    #[test]
6204    fn test_three_white_soldiers_true_for_classic_pattern() {
6205        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
6206        let b2 = make_ohlcv_bar(dec!(112), dec!(128), dec!(110), dec!(125));
6207        let b3 = make_ohlcv_bar(dec!(125), dec!(142), dec!(123), dec!(140));
6208        assert!(OhlcvBar::three_white_soldiers(&[b1, b2, b3]));
6209    }
6210
6211    #[test]
6212    fn test_three_white_soldiers_false_for_bearish_bar_in_sequence() {
6213        // b2 is bearish (close < open)
6214        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
6215        let b2 = make_ohlcv_bar(dec!(115), dec!(120), dec!(105), dec!(108));
6216        let b3 = make_ohlcv_bar(dec!(108), dec!(130), dec!(106), dec!(128));
6217        assert!(!OhlcvBar::three_white_soldiers(&[b1, b2, b3]));
6218    }
6219
6220    // ── OhlcvBar::three_black_crows ───────────────────────────────────────────
6221
6222    #[test]
6223    fn test_three_black_crows_false_for_fewer_than_3_bars() {
6224        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
6225        assert!(!OhlcvBar::three_black_crows(&[b]));
6226    }
6227
6228    #[test]
6229    fn test_three_black_crows_true_for_classic_pattern() {
6230        let b1 = make_ohlcv_bar(dec!(140), dec!(142), dec!(110), dec!(112));
6231        let b2 = make_ohlcv_bar(dec!(112), dec!(114), dec!(95), dec!(97));
6232        let b3 = make_ohlcv_bar(dec!(97), dec!(99), dec!(80), dec!(82));
6233        assert!(OhlcvBar::three_black_crows(&[b1, b2, b3]));
6234    }
6235
6236    #[test]
6237    fn test_three_black_crows_false_for_bullish_bar_in_sequence() {
6238        // b2 is bullish
6239        let b1 = make_ohlcv_bar(dec!(140), dec!(142), dec!(110), dec!(112));
6240        let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(106), dec!(118));
6241        let b3 = make_ohlcv_bar(dec!(115), dec!(116), dec!(90), dec!(92));
6242        assert!(!OhlcvBar::three_black_crows(&[b1, b2, b3]));
6243    }
6244
6245    // ── OhlcvBar::is_gap_bar ─────────────────────────────────────────────────
6246
6247    #[test]
6248    fn test_is_gap_bar_true_when_open_differs_from_prev_close() {
6249        let bar = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(110));
6250        assert!(OhlcvBar::is_gap_bar(&bar, dec!(100)));
6251    }
6252
6253    #[test]
6254    fn test_is_gap_bar_false_when_open_equals_prev_close() {
6255        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
6256        assert!(!OhlcvBar::is_gap_bar(&bar, dec!(100)));
6257    }
6258
6259    // ── OhlcvBar::gap_bars_count ──────────────────────────────────────────────
6260
6261    #[test]
6262    fn test_gap_bars_count_zero_for_empty_slice() {
6263        assert_eq!(OhlcvBar::gap_bars_count(&[]), 0);
6264    }
6265
6266    #[test]
6267    fn test_gap_bars_count_zero_when_no_gaps() {
6268        // b1 close=100, b2 open=100 → no gap
6269        let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
6270        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
6271        assert_eq!(OhlcvBar::gap_bars_count(&[b1, b2]), 0);
6272    }
6273
6274    #[test]
6275    fn test_gap_bars_count_counts_all_gaps() {
6276        let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
6277        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); // gap: open=105 != 100
6278        let b3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115)); // no gap
6279        let b4 = make_ohlcv_bar(dec!(120), dec!(130), dec!(118), dec!(128)); // gap: open=120 != 115
6280        assert_eq!(OhlcvBar::gap_bars_count(&[b1, b2, b3, b4]), 2);
6281    }
6282
6283    // ── OhlcvBar::inside_bar / outside_bar (instance method) ─────────────────
6284
6285    #[test]
6286    fn test_inside_bar_true_when_range_inside_prior_v2() {
6287        let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
6288        let bar   = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(108));
6289        assert!(bar.inside_bar(&prior));
6290    }
6291
6292    #[test]
6293    fn test_inside_bar_false_when_high_exceeds_prior_v2() {
6294        let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
6295        let bar   = make_ohlcv_bar(dec!(105), dec!(125), dec!(90), dec!(118));
6296        assert!(!bar.inside_bar(&prior));
6297    }
6298
6299    #[test]
6300    fn test_outside_bar_true_when_range_engulfs_prior_v2() {
6301        let prior = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(108));
6302        let bar   = make_ohlcv_bar(dec!(95), dec!(120), dec!(85), dec!(112));
6303        assert!(bar.outside_bar(&prior));
6304    }
6305
6306    #[test]
6307    fn test_outside_bar_false_when_range_is_inside_v2() {
6308        let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
6309        let bar   = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(108));
6310        assert!(!bar.outside_bar(&prior));
6311    }
6312
6313    // ── OhlcvBar::bar_efficiency ──────────────────────────────────────────────
6314
6315    #[test]
6316    fn test_bar_efficiency_none_for_zero_range_bar() {
6317        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6318        assert!(OhlcvBar::bar_efficiency(&bar).is_none());
6319    }
6320
6321    #[test]
6322    fn test_bar_efficiency_one_for_full_trend_bar() {
6323        // open=100, high=110, low=100, close=110 → body=10, range=10 → efficiency=1.0
6324        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
6325        let eff = OhlcvBar::bar_efficiency(&bar).unwrap();
6326        assert!((eff - 1.0).abs() < 1e-9);
6327    }
6328
6329    #[test]
6330    fn test_bar_efficiency_between_zero_and_one() {
6331        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(108));
6332        let eff = OhlcvBar::bar_efficiency(&bar).unwrap();
6333        assert!(eff >= 0.0 && eff <= 1.0);
6334    }
6335
6336    // ── OhlcvBar::wicks_sum ───────────────────────────────────────────────────
6337
6338    #[test]
6339    fn test_wicks_sum_zero_for_empty_slice() {
6340        assert_eq!(OhlcvBar::wicks_sum(&[]), dec!(0));
6341    }
6342
6343    #[test]
6344    fn test_wicks_sum_correct_for_doji_like_bar() {
6345        // open=close=100, high=110, low=90 → upper=10, lower=10, total=20
6346        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6347        assert_eq!(OhlcvBar::wicks_sum(&[bar]), dec!(20));
6348    }
6349
6350    // ── OhlcvBar::avg_close_to_high ───────────────────────────────────────────
6351
6352    #[test]
6353    fn test_avg_close_to_high_none_for_empty_slice() {
6354        assert!(OhlcvBar::avg_close_to_high(&[]).is_none());
6355    }
6356
6357    #[test]
6358    fn test_avg_close_to_high_correct_for_two_bars() {
6359        // b1: high=110, close=105 → 5; b2: high=120, close=115 → 5; avg=5.0
6360        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(105));
6361        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115));
6362        let avg = OhlcvBar::avg_close_to_high(&[b1, b2]).unwrap();
6363        assert!((avg - 5.0).abs() < 1e-9);
6364    }
6365
6366    // ── OhlcvBar::avg_range ───────────────────────────────────────────────────
6367
6368    #[test]
6369    fn test_avg_range_r65_none_for_empty() {
6370        assert!(OhlcvBar::avg_range(&[]).is_none());
6371    }
6372
6373    #[test]
6374    fn test_avg_range_r65_correct() {
6375        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6376        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115));
6377        let avg = OhlcvBar::avg_range(&[b1, b2]).unwrap();
6378        assert!((avg - 20.0).abs() < 1e-9);
6379    }
6380
6381    // ── OhlcvBar::max_close / min_close ───────────────────────────────────────
6382
6383    #[test]
6384    fn test_max_close_r65_none_empty() {
6385        assert!(OhlcvBar::max_close(&[]).is_none());
6386    }
6387
6388    #[test]
6389    fn test_max_close_r65_highest() {
6390        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
6391        let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(108), dec!(125));
6392        let b3 = make_ohlcv_bar(dec!(115), dec!(120), dec!(112), dec!(118));
6393        assert_eq!(OhlcvBar::max_close(&[b1, b2, b3]), Some(dec!(125)));
6394    }
6395
6396    #[test]
6397    fn test_min_close_r65_lowest() {
6398        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
6399        let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(108), dec!(125));
6400        let b3 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(95));
6401        assert_eq!(OhlcvBar::min_close(&[b1, b2, b3]), Some(dec!(95)));
6402    }
6403
6404    // ── OhlcvBar::trend_strength ──────────────────────────────────────────────
6405
6406    #[test]
6407    fn test_trend_strength_r65_none_single() {
6408        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6409        assert!(OhlcvBar::trend_strength(&[b]).is_none());
6410    }
6411
6412    #[test]
6413    fn test_trend_strength_r65_one_bullish() {
6414        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(105));
6415        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115));
6416        let b3 = make_ohlcv_bar(dec!(115), dec!(130), dec!(113), dec!(128));
6417        let s = OhlcvBar::trend_strength(&[b1, b2, b3]).unwrap();
6418        assert!((s - 1.0).abs() < 1e-9);
6419    }
6420
6421    #[test]
6422    fn test_trend_strength_r65_zero_bearish() {
6423        let b1 = make_ohlcv_bar(dec!(128), dec!(130), dec!(113), dec!(128));
6424        let b2 = make_ohlcv_bar(dec!(115), dec!(120), dec!(103), dec!(110));
6425        let b3 = make_ohlcv_bar(dec!(105), dec!(110), dec!(95), dec!(100));
6426        let s = OhlcvBar::trend_strength(&[b1, b2, b3]).unwrap();
6427        assert!((s - 0.0).abs() < 1e-9);
6428    }
6429
6430    // ── OhlcvBar::net_change ──────────────────────────────────────────────────
6431
6432    #[test]
6433    fn test_net_change_none_for_empty() {
6434        assert!(OhlcvBar::net_change(&[]).is_none());
6435    }
6436
6437    #[test]
6438    fn test_net_change_positive_for_bullish_bar() {
6439        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
6440        assert_eq!(OhlcvBar::net_change(&[b]), Some(dec!(12)));
6441    }
6442
6443    #[test]
6444    fn test_net_change_negative_for_bearish_bar() {
6445        let b = make_ohlcv_bar(dec!(110), dec!(112), dec!(95), dec!(100));
6446        assert_eq!(OhlcvBar::net_change(&[b]), Some(dec!(-10)));
6447    }
6448
6449    // ── OhlcvBar::open_to_close_pct ───────────────────────────────────────────
6450
6451    #[test]
6452    fn test_open_to_close_pct_none_for_empty() {
6453        assert!(OhlcvBar::open_to_close_pct(&[]).is_none());
6454    }
6455
6456    #[test]
6457    fn test_open_to_close_pct_correct() {
6458        // open=100, close=110 → 10%
6459        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
6460        let pct = OhlcvBar::open_to_close_pct(&[b]).unwrap();
6461        assert!((pct - 10.0).abs() < 1e-9);
6462    }
6463
6464    // ── OhlcvBar::high_to_low_pct ─────────────────────────────────────────────
6465
6466    #[test]
6467    fn test_high_to_low_pct_none_for_empty() {
6468        assert!(OhlcvBar::high_to_low_pct(&[]).is_none());
6469    }
6470
6471    #[test]
6472    fn test_high_to_low_pct_correct() {
6473        // high=200, low=100 → 50%
6474        let b = make_ohlcv_bar(dec!(150), dec!(200), dec!(100), dec!(160));
6475        let pct = OhlcvBar::high_to_low_pct(&[b]).unwrap();
6476        assert!((pct - 50.0).abs() < 1e-9);
6477    }
6478
6479    // ── OhlcvBar::consecutive_highs / consecutive_lows ───────────────────────
6480
6481    #[test]
6482    fn test_consecutive_highs_zero_for_single_bar() {
6483        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6484        assert_eq!(OhlcvBar::consecutive_highs(&[b]), 0);
6485    }
6486
6487    #[test]
6488    fn test_consecutive_highs_counts_trailing_highs() {
6489        // bars with rising highs: 110, 120, 130 → 2 consecutive from end
6490        let b1 = make_ohlcv_bar(dec!(95), dec!(110), dec!(90), dec!(105));
6491        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115));
6492        let b3 = make_ohlcv_bar(dec!(115), dec!(130), dec!(113), dec!(125));
6493        assert_eq!(OhlcvBar::consecutive_highs(&[b1, b2, b3]), 2);
6494    }
6495
6496    #[test]
6497    fn test_consecutive_lows_counts_trailing_lows() {
6498        // bars with falling lows: 90, 80, 70 → 2 consecutive from end
6499        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6500        let b2 = make_ohlcv_bar(dec!(95), dec!(108), dec!(80), dec!(100));
6501        let b3 = make_ohlcv_bar(dec!(90), dec!(102), dec!(70), dec!(95));
6502        assert_eq!(OhlcvBar::consecutive_lows(&[b1, b2, b3]), 2);
6503    }
6504
6505    // ── OhlcvBar::volume_change_pct ───────────────────────────────────────────
6506
6507    #[test]
6508    fn test_volume_change_pct_none_for_single_bar() {
6509        let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6510        b.volume = dec!(100);
6511        assert!(OhlcvBar::volume_change_pct(&[b]).is_none());
6512    }
6513
6514    #[test]
6515    fn test_volume_change_pct_correct() {
6516        // prior vol=100, current vol=150 → +50%
6517        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
6518        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(150);
6519        let pct = OhlcvBar::volume_change_pct(&[b1, b2]).unwrap();
6520        assert!((pct - 50.0).abs() < 1e-9);
6521    }
6522
6523    // ── OhlcvBar::close_location_value (instance method) ─────────────────────
6524
6525    #[test]
6526    fn test_clv_r67_plus_one_at_high() {
6527        // symmetric CLV: +1 when close=high
6528        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6529        let clv = b.close_location_value().unwrap();
6530        assert!((clv - 1.0).abs() < 1e-9);
6531    }
6532
6533    #[test]
6534    fn test_clv_r67_minus_one_at_low() {
6535        // symmetric CLV: -1 when close=low
6536        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
6537        let clv = b.close_location_value().unwrap();
6538        assert!((clv - (-1.0)).abs() < 1e-9);
6539    }
6540
6541    #[test]
6542    fn test_clv_r67_none_for_zero_range() {
6543        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6544        assert!(b.close_location_value().is_none());
6545    }
6546
6547    // ── OhlcvBar::body_pct (instance method) ──────────────────────────────────
6548
6549    #[test]
6550    fn test_body_pct_r67_none_for_zero_range() {
6551        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6552        assert!(b.body_pct().is_none());
6553    }
6554
6555    #[test]
6556    fn test_body_pct_r67_100_for_full_body() {
6557        // open=90, close=110, high=110, low=90 → body_pct=100%
6558        let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
6559        assert_eq!(b.body_pct(), Some(dec!(100)));
6560    }
6561
6562    // ── OhlcvBar::bullish_count / bearish_count ───────────────────────────────
6563
6564    #[test]
6565    fn test_bullish_count_r67_correct() {
6566        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112)); // bullish
6567        let b2 = make_ohlcv_bar(dec!(112), dec!(120), dec!(105), dec!(108)); // bearish
6568        let b3 = make_ohlcv_bar(dec!(108), dec!(125), dec!(106), dec!(120)); // bullish
6569        assert_eq!(OhlcvBar::bullish_count(&[b1, b2, b3]), 2);
6570    }
6571
6572    #[test]
6573    fn test_bearish_count_r67_correct() {
6574        let b1 = make_ohlcv_bar(dec!(115), dec!(118), dec!(100), dec!(105)); // bearish
6575        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(112)); // bullish
6576        assert_eq!(OhlcvBar::bearish_count(&[b1, b2]), 1);
6577    }
6578
6579    // ── OhlcvBar::open_gap_pct ────────────────────────────────────────────────
6580
6581    #[test]
6582    fn test_open_gap_pct_none_for_single_bar() {
6583        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6584        assert!(OhlcvBar::open_gap_pct(&[b]).is_none());
6585    }
6586
6587    #[test]
6588    fn test_open_gap_pct_positive_for_gap_up() {
6589        // prev close=100, current open=105 → 5%
6590        let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
6591        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110));
6592        let pct = OhlcvBar::open_gap_pct(&[b1, b2]).unwrap();
6593        assert!((pct - 5.0).abs() < 1e-9);
6594    }
6595
6596    // ── OhlcvBar::volume_cumulative ───────────────────────────────────────────
6597
6598    #[test]
6599    fn test_volume_cumulative_zero_for_empty() {
6600        assert_eq!(OhlcvBar::volume_cumulative(&[]), dec!(0));
6601    }
6602
6603    #[test]
6604    fn test_volume_cumulative_sums_all_volumes() {
6605        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
6606        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(200);
6607        assert_eq!(OhlcvBar::volume_cumulative(&[b1, b2]), dec!(300));
6608    }
6609
6610    // ── OhlcvBar::price_position ──────────────────────────────────────────────
6611
6612    #[test]
6613    fn test_price_position_none_for_empty() {
6614        assert!(OhlcvBar::price_position(&[]).is_none());
6615    }
6616
6617    #[test]
6618    fn test_price_position_one_when_close_at_highest() {
6619        // bars: high=100 and high=120 (range 80-120=40), last close=120
6620        let b1 = make_ohlcv_bar(dec!(85), dec!(100), dec!(80), dec!(95));
6621        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(120));
6622        let pos = OhlcvBar::price_position(&[b1, b2]).unwrap();
6623        assert!((pos - 1.0).abs() < 1e-9);
6624    }
6625
6626    // ── OhlcvBar::close_above_open_count ──────────────────────────────────────
6627
6628    #[test]
6629    fn test_close_above_open_count_zero_for_empty() {
6630        assert_eq!(OhlcvBar::close_above_open_count(&[]), 0);
6631    }
6632
6633    #[test]
6634    fn test_close_above_open_count_correct() {
6635        // bar1: bullish (close > open), bar2: bearish
6636        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108));
6637        let b2 = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(102));
6638        assert_eq!(OhlcvBar::close_above_open_count(&[b1, b2]), 1);
6639    }
6640
6641    // ── OhlcvBar::volume_price_correlation ────────────────────────────────────
6642
6643    #[test]
6644    fn test_volume_price_correlation_none_for_single_bar() {
6645        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6646        assert!(OhlcvBar::volume_price_correlation(&[b]).is_none());
6647    }
6648
6649    #[test]
6650    fn test_volume_price_correlation_positive_for_comoving() {
6651        // Both volume and close rise together
6652        let mut b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(102)); b1.volume = dec!(100);
6653        let mut b2 = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108)); b2.volume = dec!(200);
6654        let corr = OhlcvBar::volume_price_correlation(&[b1, b2]).unwrap();
6655        assert!(corr > 0.0, "expected positive correlation, got {}", corr);
6656    }
6657
6658    // ── OhlcvBar::body_consistency ────────────────────────────────────────────
6659
6660    #[test]
6661    fn test_body_consistency_none_for_empty() {
6662        assert!(OhlcvBar::body_consistency(&[]).is_none());
6663    }
6664
6665    #[test]
6666    fn test_body_consistency_one_for_all_big_bodies() {
6667        // body = |close - open| = 8, range = high - low = 10 → 8 > 5 ✓
6668        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
6669        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(110), dec!(118));
6670        let r = OhlcvBar::body_consistency(&[b1, b2]).unwrap();
6671        assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
6672    }
6673
6674    // ── OhlcvBar::close_volatility_ratio ──────────────────────────────────────
6675
6676    #[test]
6677    fn test_close_volatility_ratio_none_for_single_bar() {
6678        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6679        assert!(OhlcvBar::close_volatility_ratio(&[b]).is_none());
6680    }
6681
6682    #[test]
6683    fn test_close_volatility_ratio_positive_for_varied_closes() {
6684        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6685        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
6686        let r = OhlcvBar::close_volatility_ratio(&[b1, b2]).unwrap();
6687        assert!(r > 0.0, "expected positive ratio, got {}", r);
6688    }
6689
6690    #[test]
6691    fn test_close_volatility_ratio_zero_for_identical_closes() {
6692        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6693        let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(88), dec!(105));
6694        let r = OhlcvBar::close_volatility_ratio(&[b1, b2]).unwrap();
6695        assert!((r - 0.0).abs() < 1e-9, "expected 0.0 for identical closes, got {}", r);
6696    }
6697
6698    // ── OhlcvBar::is_trending_up / is_trending_down ───────────────────────────
6699
6700    #[test]
6701    fn test_is_trending_up_false_for_empty() {
6702        assert!(!OhlcvBar::is_trending_up(&[], 3));
6703    }
6704
6705    #[test]
6706    fn test_is_trending_up_false_for_n_less_than_2() {
6707        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6708        assert!(!OhlcvBar::is_trending_up(&[b], 1));
6709    }
6710
6711    #[test]
6712    fn test_is_trending_up_true_for_rising_closes() {
6713        let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(102));
6714        let b2 = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(107));
6715        let b3 = make_ohlcv_bar(dec!(107), dec!(115), dec!(105), dec!(112));
6716        assert!(OhlcvBar::is_trending_up(&[b1, b2, b3], 3));
6717    }
6718
6719    #[test]
6720    fn test_is_trending_down_true_for_falling_closes() {
6721        let b1 = make_ohlcv_bar(dec!(112), dec!(115), dec!(105), dec!(110));
6722        let b2 = make_ohlcv_bar(dec!(110), dec!(112), dec!(100), dec!(105));
6723        let b3 = make_ohlcv_bar(dec!(105), dec!(108), dec!(95), dec!(98));
6724        assert!(OhlcvBar::is_trending_down(&[b1, b2, b3], 3));
6725    }
6726
6727    // ── OhlcvBar::volume_acceleration ────────────────────────────────────────
6728
6729    #[test]
6730    fn test_volume_acceleration_none_for_single_bar() {
6731        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6732        assert!(OhlcvBar::volume_acceleration(&[b]).is_none());
6733    }
6734
6735    #[test]
6736    fn test_volume_acceleration_positive_when_volume_rises() {
6737        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
6738        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(150);
6739        let acc = OhlcvBar::volume_acceleration(&[b1, b2]).unwrap();
6740        assert!(acc > 0.0, "volume rose so acceleration should be positive, got {}", acc);
6741    }
6742
6743    // ── OhlcvBar::wick_body_ratio ─────────────────────────────────────────────
6744
6745    #[test]
6746    fn test_wick_body_ratio_none_for_empty() {
6747        assert!(OhlcvBar::wick_body_ratio(&[]).is_none());
6748    }
6749
6750    #[test]
6751    fn test_wick_body_ratio_none_for_doji_bar() {
6752        // open == close → zero body, should be skipped
6753        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6754        assert!(OhlcvBar::wick_body_ratio(&[b]).is_none());
6755    }
6756
6757    #[test]
6758    fn test_wick_body_ratio_positive_for_wicked_bar() {
6759        // open=100, close=105 → body=5; high=115, low=95 → wicks=10+5=15
6760        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
6761        let r = OhlcvBar::wick_body_ratio(&[b]).unwrap();
6762        assert!(r > 0.0, "expected positive wick/body ratio, got {}", r);
6763    }
6764
6765    // ── OhlcvBar::close_momentum_score ────────────────────────────────────────
6766
6767    #[test]
6768    fn test_close_momentum_score_none_for_empty() {
6769        assert!(OhlcvBar::close_momentum_score(&[]).is_none());
6770    }
6771
6772    #[test]
6773    fn test_close_momentum_score_half_for_symmetric() {
6774        // Two bars: closes [90, 110] → mean=100; 90 < 100, 110 > 100 → 1/2
6775        let b1 = make_ohlcv_bar(dec!(88), dec!(95), dec!(85), dec!(90));
6776        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(110));
6777        let score = OhlcvBar::close_momentum_score(&[b1, b2]).unwrap();
6778        assert!((score - 0.5).abs() < 1e-9, "expected 0.5, got {}", score);
6779    }
6780
6781    // ── OhlcvBar::range_expansion_count ──────────────────────────────────────
6782
6783    #[test]
6784    fn test_range_expansion_count_zero_for_single_bar() {
6785        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6786        assert_eq!(OhlcvBar::range_expansion_count(&[b]), 0);
6787    }
6788
6789    #[test]
6790    fn test_range_expansion_count_correct() {
6791        // b1 range=20, b2 range=30 → expansion
6792        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6793        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(90), dec!(110));
6794        assert_eq!(OhlcvBar::range_expansion_count(&[b1, b2]), 1);
6795    }
6796
6797    // ── OhlcvBar::gap_count ────────────────────────────────────────────────────
6798
6799    #[test]
6800    fn test_gap_count_zero_for_single_bar() {
6801        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6802        assert_eq!(OhlcvBar::gap_count(&[b]), 0);
6803    }
6804
6805    #[test]
6806    fn test_gap_count_detects_gap() {
6807        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6808        // b2 opens at 108, prev close=105 → gap
6809        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(106), dec!(112));
6810        assert_eq!(OhlcvBar::gap_count(&[b1, b2]), 1);
6811    }
6812
6813    #[test]
6814    fn test_gap_count_zero_when_open_equals_close() {
6815        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6816        // b2 opens at exactly prev close=105
6817        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(112));
6818        assert_eq!(OhlcvBar::gap_count(&[b1, b2]), 0);
6819    }
6820
6821    // ── OhlcvBar::avg_wick_size ───────────────────────────────────────────────
6822
6823    #[test]
6824    fn test_avg_wick_size_none_for_empty() {
6825        assert!(OhlcvBar::avg_wick_size(&[]).is_none());
6826    }
6827
6828    #[test]
6829    fn test_avg_wick_size_correct() {
6830        // open=100, close=105, high=115, low=95
6831        // upper wick = 115 - 105 = 10, lower wick = 100 - 95 = 5, total = 15
6832        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
6833        let ws = OhlcvBar::avg_wick_size(&[b]).unwrap();
6834        assert!((ws - 15.0).abs() < 1e-6, "expected 15.0, got {}", ws);
6835    }
6836
6837    // ── OhlcvBar::mean_volume_ratio ────────────────────────────────────────────
6838
6839    #[test]
6840    fn test_mean_volume_ratio_empty_for_empty_slice() {
6841        assert!(OhlcvBar::mean_volume_ratio(&[]).is_empty());
6842    }
6843
6844    #[test]
6845    fn test_mean_volume_ratio_sums_to_n_times_mean() {
6846        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
6847        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(300);
6848        // mean = 200; ratios: 0.5, 1.5
6849        let ratios = OhlcvBar::mean_volume_ratio(&[b1, b2]);
6850        assert_eq!(ratios.len(), 2);
6851        let r0 = ratios[0].unwrap();
6852        let r1 = ratios[1].unwrap();
6853        assert!((r0 - 0.5).abs() < 1e-6, "expected 0.5, got {}", r0);
6854        assert!((r1 - 1.5).abs() < 1e-6, "expected 1.5, got {}", r1);
6855    }
6856
6857    // ── OhlcvBar::price_compression_ratio ────────────────────────────────────
6858
6859    #[test]
6860    fn test_price_compression_ratio_none_for_zero_range() {
6861        // open==high==low==close → range=0
6862        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6863        assert!(OhlcvBar::price_compression_ratio(&[b]).is_none());
6864    }
6865
6866    #[test]
6867    fn test_price_compression_ratio_in_range() {
6868        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6869        let r = OhlcvBar::price_compression_ratio(&[b]).unwrap();
6870        assert!(r >= 0.0 && r <= 1.0, "expected value in [0,1], got {}", r);
6871    }
6872
6873    // ── OhlcvBar::open_close_spread ───────────────────────────────────────────
6874
6875    #[test]
6876    fn test_open_close_spread_none_for_empty() {
6877        assert!(OhlcvBar::open_close_spread(&[]).is_none());
6878    }
6879
6880    #[test]
6881    fn test_open_close_spread_zero_for_doji() {
6882        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6883        let s = OhlcvBar::open_close_spread(&[b]).unwrap();
6884        assert!((s - 0.0).abs() < 1e-9, "doji should have spread=0, got {}", s);
6885    }
6886
6887    #[test]
6888    fn test_open_close_spread_positive_for_directional_bar() {
6889        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
6890        let s = OhlcvBar::open_close_spread(&[b]).unwrap();
6891        assert!(s > 0.0, "directional bar should have positive spread, got {}", s);
6892    }
6893
6894    // ── OhlcvBar::close_above_high_ma ────────────────────────────────────────
6895
6896    #[test]
6897    fn test_close_above_high_ma_zero_for_too_few_bars() {
6898        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6899        assert_eq!(OhlcvBar::close_above_high_ma(&[b], 2), 0);
6900    }
6901
6902    #[test]
6903    fn test_close_above_high_ma_detects_breakout() {
6904        // 2-bar high MA = (110+120)/2=115; close of b2=118 > 115
6905        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6906        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(118));
6907        assert_eq!(OhlcvBar::close_above_high_ma(&[b1, b2], 2), 1);
6908    }
6909
6910    // ── OhlcvBar::max_consecutive_gains / max_consecutive_losses ──────────────
6911
6912    #[test]
6913    fn test_max_consecutive_gains_zero_for_single_bar() {
6914        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6915        assert_eq!(OhlcvBar::max_consecutive_gains(&[b]), 0);
6916    }
6917
6918    #[test]
6919    fn test_max_consecutive_gains_correct() {
6920        // closes: 100, 105, 110, 108, 115 → gains: 1,1,0,1 → max run=2
6921        let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
6922        let b2 = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
6923        let b3 = make_ohlcv_bar(dec!(105), dec!(112), dec!(104), dec!(110));
6924        let b4 = make_ohlcv_bar(dec!(110), dec!(111), dec!(105), dec!(108));
6925        let b5 = make_ohlcv_bar(dec!(108), dec!(116), dec!(107), dec!(115));
6926        assert_eq!(OhlcvBar::max_consecutive_gains(&[b1, b2, b3, b4, b5]), 2);
6927    }
6928
6929    #[test]
6930    fn test_max_consecutive_losses_correct() {
6931        // closes: 110, 105, 100, 108 → losses: 1,1,0 → max run=2
6932        let b1 = make_ohlcv_bar(dec!(112), dec!(115), dec!(108), dec!(110));
6933        let b2 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
6934        let b3 = make_ohlcv_bar(dec!(105), dec!(108), dec!(98), dec!(100));
6935        let b4 = make_ohlcv_bar(dec!(100), dec!(112), dec!(98), dec!(108));
6936        assert_eq!(OhlcvBar::max_consecutive_losses(&[b1, b2, b3, b4]), 2);
6937    }
6938
6939    // ── OhlcvBar::price_path_length ───────────────────────────────────────────
6940
6941    #[test]
6942    fn test_price_path_length_none_for_single_bar() {
6943        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6944        assert!(OhlcvBar::price_path_length(&[b]).is_none());
6945    }
6946
6947    #[test]
6948    fn test_price_path_length_correct() {
6949        // closes: 100, 110, 105 → |10| + |5| = 15
6950        let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
6951        let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(99), dec!(110));
6952        let b3 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
6953        let len = OhlcvBar::price_path_length(&[b1, b2, b3]).unwrap();
6954        assert!((len - 15.0).abs() < 1e-6, "expected 15.0, got {}", len);
6955    }
6956
6957    // ── OhlcvBar::close_reversion_count ──────────────────────────────────────
6958
6959    #[test]
6960    fn test_close_reversion_count_zero_for_single_bar() {
6961        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6962        assert_eq!(OhlcvBar::close_reversion_count(&[b]), 0);
6963    }
6964
6965    #[test]
6966    fn test_close_reversion_count_returns_usize() {
6967        let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
6968        let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(99), dec!(110));
6969        let b3 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
6970        // Just test it runs without panic
6971        let _ = OhlcvBar::close_reversion_count(&[b1, b2, b3]);
6972    }
6973
6974    // ── OhlcvBar::atr_ratio ───────────────────────────────────────────────────
6975
6976    #[test]
6977    fn test_atr_ratio_none_for_single_bar() {
6978        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6979        assert!(OhlcvBar::atr_ratio(&[b]).is_none());
6980    }
6981
6982    #[test]
6983    fn test_atr_ratio_positive_for_valid_bars() {
6984        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6985        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110));
6986        let r = OhlcvBar::atr_ratio(&[b1, b2]).unwrap();
6987        assert!(r > 0.0, "expected positive ATR ratio, got {}", r);
6988    }
6989
6990    // ── OhlcvBar::volume_trend_strength ───────────────────────────────────────
6991
6992    #[test]
6993    fn test_volume_trend_strength_none_for_single_bar() {
6994        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6995        assert!(OhlcvBar::volume_trend_strength(&[b]).is_none());
6996    }
6997
6998    #[test]
6999    fn test_volume_trend_strength_positive_for_rising_volume() {
7000        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
7001        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(200);
7002        let mut b3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115)); b3.volume = dec!(300);
7003        let s = OhlcvBar::volume_trend_strength(&[b1, b2, b3]).unwrap();
7004        assert!(s > 0.0, "rising volume should give positive strength, got {}", s);
7005    }
7006
7007    // ── OhlcvBar::high_close_spread ───────────────────────────────────────────
7008
7009    #[test]
7010    fn test_high_close_spread_none_for_empty() {
7011        assert!(OhlcvBar::high_close_spread(&[]).is_none());
7012    }
7013
7014    #[test]
7015    fn test_high_close_spread_zero_when_close_equals_high() {
7016        // open=100, high=110, low=90, close=110 → upper wick=0
7017        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
7018        let s = OhlcvBar::high_close_spread(&[b]).unwrap();
7019        assert!((s - 0.0).abs() < 1e-9, "expected 0.0, got {}", s);
7020    }
7021
7022    #[test]
7023    fn test_high_close_spread_positive_for_wicked_bar() {
7024        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
7025        let s = OhlcvBar::high_close_spread(&[b]).unwrap();
7026        assert!(s > 0.0, "expected positive spread, got {}", s);
7027    }
7028
7029    // ── OhlcvBar::open_range ──────────────────────────────────────────────────
7030
7031    #[test]
7032    fn test_open_range_none_for_empty() {
7033        assert!(OhlcvBar::open_range(&[]).is_none());
7034    }
7035
7036    #[test]
7037    fn test_open_range_zero_for_doji() {
7038        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7039        let r = OhlcvBar::open_range(&[b]).unwrap();
7040        assert!((r - 0.0).abs() < 1e-9, "doji should have open_range=0, got {}", r);
7041    }
7042
7043    #[test]
7044    fn test_open_range_positive_for_directional() {
7045        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
7046        let r = OhlcvBar::open_range(&[b]).unwrap();
7047        assert!(r > 0.0, "directional bar should have positive open_range, got {}", r);
7048    }
7049
7050    // ── OhlcvBar::normalized_close ────────────────────────────────────────────
7051
7052    #[test]
7053    fn test_normalized_close_none_for_single_bar() {
7054        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7055        assert!(OhlcvBar::normalized_close(&[b]).is_none());
7056    }
7057
7058    #[test]
7059    fn test_normalized_close_one_when_last_close_is_max() {
7060        let b1 = make_ohlcv_bar(dec!(98), dec!(105), dec!(96), dec!(100));
7061        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(99), dec!(110));
7062        let nc = OhlcvBar::normalized_close(&[b1, b2]).unwrap();
7063        assert!((nc - 1.0).abs() < 1e-9, "last close = max should give 1.0, got {}", nc);
7064    }
7065
7066    #[test]
7067    fn test_normalized_close_zero_when_last_close_is_min() {
7068        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
7069        let b2 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
7070        // min_close=90, max_close=100, last_close=100 → 1.0
7071        // Actually min=90, max=100, last=100 → normalized=1.0
7072        let nc = OhlcvBar::normalized_close(&[b1, b2]).unwrap();
7073        assert!(nc >= 0.0 && nc <= 1.0, "normalized close should be in [0,1], got {}", nc);
7074    }
7075
7076    // ── OhlcvBar::candle_score ────────────────────────────────────────────────
7077
7078    #[test]
7079    fn test_candle_score_none_for_empty() {
7080        assert!(OhlcvBar::candle_score(&[]).is_none());
7081    }
7082
7083    #[test]
7084    fn test_candle_score_one_for_strong_bull_bar() {
7085        // open=100, close=108, high=110, low=99 → bullish, body=8, range=11, close_above_mid=yes
7086        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(108));
7087        let s = OhlcvBar::candle_score(&[b]).unwrap();
7088        assert_eq!(s, 1.0, "strong bullish bar should score 1.0, got {}", s);
7089    }
7090
7091    #[test]
7092    fn test_candle_score_zero_for_bear_bar() {
7093        // open=108, close=100 → bearish → score 0
7094        let b = make_ohlcv_bar(dec!(108), dec!(110), dec!(99), dec!(100));
7095        let s = OhlcvBar::candle_score(&[b]).unwrap();
7096        assert_eq!(s, 0.0, "bearish bar should score 0.0, got {}", s);
7097    }
7098
7099    // ── OhlcvBar::bar_speed ───────────────────────────────────────────────────
7100
7101    #[test]
7102    fn test_bar_speed_none_for_empty() {
7103        assert!(OhlcvBar::bar_speed(&[]).is_none());
7104    }
7105
7106    // ── OhlcvBar::higher_highs_count / lower_lows_count ──────────────────────
7107
7108    #[test]
7109    fn test_higher_highs_count_zero_for_single_bar() {
7110        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7111        assert_eq!(OhlcvBar::higher_highs_count(&[b]), 0);
7112    }
7113
7114    #[test]
7115    fn test_higher_highs_count_correct() {
7116        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7117        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115)); // high 120 > 110
7118        let b3 = make_ohlcv_bar(dec!(115), dec!(115), dec!(110), dec!(112)); // high 115 < 120
7119        assert_eq!(OhlcvBar::higher_highs_count(&[b1, b2, b3]), 1);
7120    }
7121
7122    #[test]
7123    fn test_lower_lows_count_correct() {
7124        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7125        let b2 = make_ohlcv_bar(dec!(105), dec!(112), dec!(85), dec!(108)); // low 85 < 90
7126        let b3 = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(112)); // low 95 > 85
7127        assert_eq!(OhlcvBar::lower_lows_count(&[b1, b2, b3]), 1);
7128    }
7129
7130    // ── OhlcvBar::close_minus_open_pct ────────────────────────────────────────
7131
7132    #[test]
7133    fn test_close_minus_open_pct_none_for_empty() {
7134        assert!(OhlcvBar::close_minus_open_pct(&[]).is_none());
7135    }
7136
7137    #[test]
7138    fn test_close_minus_open_pct_positive_for_bull_bar() {
7139        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(98), dec!(110));
7140        let p = OhlcvBar::close_minus_open_pct(&[b]).unwrap();
7141        assert!(p > 0.0, "bullish bar should give positive pct, got {}", p);
7142    }
7143
7144    // ── OhlcvBar::volume_per_range ────────────────────────────────────────────
7145
7146    #[test]
7147    fn test_volume_per_range_none_for_empty() {
7148        assert!(OhlcvBar::volume_per_range(&[]).is_none());
7149    }
7150
7151    #[test]
7152    fn test_volume_per_range_positive_for_valid_bar() {
7153        let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b.volume = dec!(100);
7154        let r = OhlcvBar::volume_per_range(&[b]).unwrap();
7155        assert!(r > 0.0, "expected positive volume/range, got {}", r);
7156    }
7157
7158    #[test]
7159    fn test_up_volume_fraction_none_for_empty() {
7160        assert!(OhlcvBar::up_volume_fraction(&[]).is_none());
7161    }
7162
7163    #[test]
7164    fn test_up_volume_fraction_all_up() {
7165        // close > open for both bars
7166        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108)); b1.volume = dec!(50);
7167        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112)); b2.volume = dec!(50);
7168        let f = OhlcvBar::up_volume_fraction(&[b1, b2]).unwrap();
7169        assert!((f - 1.0).abs() < 1e-9, "all up bars → fraction=1.0, got {}", f);
7170    }
7171
7172    #[test]
7173    fn test_tail_upper_fraction_none_for_empty() {
7174        assert!(OhlcvBar::tail_upper_fraction(&[]).is_none());
7175    }
7176
7177    #[test]
7178    fn test_tail_upper_fraction_correct() {
7179        // bar: open=100, high=110, low=90, close=105 → body_top=105, upper_wick=5, range=20 → 0.25
7180        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7181        let f = OhlcvBar::tail_upper_fraction(&[b]).unwrap();
7182        assert!((f - 0.25).abs() < 1e-9, "expected 0.25, got {}", f);
7183    }
7184
7185    #[test]
7186    fn test_tail_lower_fraction_none_for_empty() {
7187        assert!(OhlcvBar::tail_lower_fraction(&[]).is_none());
7188    }
7189
7190    #[test]
7191    fn test_tail_lower_fraction_correct() {
7192        // bar: open=100, high=110, low=90, close=105 → body_bot=100, lower_wick=10, range=20 → 0.5
7193        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7194        let f = OhlcvBar::tail_lower_fraction(&[b]).unwrap();
7195        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
7196    }
7197
7198    #[test]
7199    fn test_range_std_dev_none_for_single_bar() {
7200        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7201        assert!(OhlcvBar::range_std_dev(&[b]).is_none());
7202    }
7203
7204    #[test]
7205    fn test_range_std_dev_zero_for_equal_ranges() {
7206        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7207        let b2 = make_ohlcv_bar(dec!(200), dec!(210), dec!(190), dec!(205));
7208        let sd = OhlcvBar::range_std_dev(&[b1, b2]).unwrap();
7209        assert!(sd.abs() < 1e-9, "equal ranges → std_dev=0, got {}", sd);
7210    }
7211
7212    #[test]
7213    fn test_body_fraction_none_for_empty() {
7214        assert!(OhlcvBar::body_fraction(&[]).is_none());
7215    }
7216
7217    #[test]
7218    fn test_body_fraction_doji_is_zero() {
7219        // open == close → body = 0 → fraction = 0
7220        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7221        let f = OhlcvBar::body_fraction(&[b]).unwrap();
7222        assert!(f.abs() < 1e-9, "doji → body_fraction=0, got {}", f);
7223    }
7224
7225    #[test]
7226    fn test_bullish_ratio_none_for_empty() {
7227        assert!(OhlcvBar::bullish_ratio(&[]).is_none());
7228    }
7229
7230    #[test]
7231    fn test_bullish_ratio_all_bullish() {
7232        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108));
7233        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112));
7234        let r = OhlcvBar::bullish_ratio(&[b1, b2]).unwrap();
7235        assert!((r - 1.0).abs() < 1e-9, "all bullish → ratio=1.0, got {}", r);
7236    }
7237
7238    #[test]
7239    fn test_peak_trough_close_none_for_empty() {
7240        assert!(OhlcvBar::peak_close(&[]).is_none());
7241        assert!(OhlcvBar::trough_close(&[]).is_none());
7242    }
7243
7244    #[test]
7245    fn test_peak_trough_close_correct() {
7246        let bars = vec![
7247            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
7248            make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115)),
7249            make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(98)),
7250        ];
7251        assert_eq!(OhlcvBar::peak_close(&bars).unwrap(), dec!(115));
7252        assert_eq!(OhlcvBar::trough_close(&bars).unwrap(), dec!(98));
7253    }
7254
7255    // ── round-79 ─────────────────────────────────────────────────────────────
7256
7257    // ── OhlcvBar::close_to_range_position ────────────────────────────────────
7258
7259    #[test]
7260    fn test_close_to_range_position_none_for_empty() {
7261        assert!(OhlcvBar::close_to_range_position(&[]).is_none());
7262    }
7263
7264    #[test]
7265    fn test_close_to_range_position_one_when_close_at_high() {
7266        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(120));
7267        let r = OhlcvBar::close_to_range_position(&[bar]).unwrap();
7268        assert!((r - 1.0).abs() < 1e-9, "close at high → position=1, got {}", r);
7269    }
7270
7271    #[test]
7272    fn test_close_to_range_position_zero_when_close_at_low() {
7273        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(80));
7274        let r = OhlcvBar::close_to_range_position(&[bar]).unwrap();
7275        assert!(r.abs() < 1e-9, "close at low → position=0, got {}", r);
7276    }
7277
7278    // ── OhlcvBar::volume_oscillator ───────────────────────────────────────────
7279
7280    #[test]
7281    fn test_volume_oscillator_none_for_insufficient_bars() {
7282        let bars = vec![make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))];
7283        assert!(OhlcvBar::volume_oscillator(&bars, 1, 3).is_none());
7284    }
7285
7286    #[test]
7287    fn test_volume_oscillator_none_when_short_ge_long() {
7288        let bars: Vec<_> = (0..5)
7289            .map(|_| make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)))
7290            .collect();
7291        assert!(OhlcvBar::volume_oscillator(&bars, 3, 2).is_none());
7292    }
7293
7294    #[test]
7295    fn test_volume_oscillator_zero_for_constant_volume() {
7296        let bars: Vec<_> = (0..5)
7297            .map(|_| make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)))
7298            .collect();
7299        let v = OhlcvBar::volume_oscillator(&bars, 2, 4).unwrap();
7300        assert!(v.abs() < 1e-9, "constant volume → oscillator=0, got {}", v);
7301    }
7302
7303    // ── OhlcvBar::direction_reversal_count ───────────────────────────────────
7304
7305    #[test]
7306    fn test_direction_reversal_count_zero_for_single_bar() {
7307        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7308        assert_eq!(OhlcvBar::direction_reversal_count(&[bar]), 0);
7309    }
7310
7311    #[test]
7312    fn test_direction_reversal_count_zero_for_all_bullish() {
7313        let bars = vec![
7314            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
7315            make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(112)),
7316        ];
7317        assert_eq!(OhlcvBar::direction_reversal_count(&bars), 0);
7318    }
7319
7320    #[test]
7321    fn test_direction_reversal_count_two_for_alternating() {
7322        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
7323        let bear = make_ohlcv_bar(dec!(108), dec!(112), dec!(95), dec!(102));
7324        let bull2 = make_ohlcv_bar(dec!(102), dec!(115), dec!(98), dec!(110));
7325        let bear2 = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(104));
7326        assert_eq!(OhlcvBar::direction_reversal_count(&[bull, bear, bull2, bear2]), 3);
7327    }
7328
7329    // ── OhlcvBar::upper_wick_dominance_fraction ───────────────────────────────
7330
7331    #[test]
7332    fn test_upper_wick_dominance_fraction_none_for_empty() {
7333        assert!(OhlcvBar::upper_wick_dominance_fraction(&[]).is_none());
7334    }
7335
7336    #[test]
7337    fn test_upper_wick_dominance_fraction_one_when_all_upper() {
7338        // high > close and close > low, upper > lower
7339        let bar = make_ohlcv_bar(dec!(100), dec!(130), dec!(99), dec!(101));
7340        let r = OhlcvBar::upper_wick_dominance_fraction(&[bar]).unwrap();
7341        assert!((r - 1.0).abs() < 1e-9, "all upper dominant → 1.0, got {}", r);
7342    }
7343
7344    // ── OhlcvBar::avg_open_to_high_ratio ─────────────────────────────────────
7345
7346    #[test]
7347    fn test_avg_open_to_high_ratio_none_for_empty() {
7348        assert!(OhlcvBar::avg_open_to_high_ratio(&[]).is_none());
7349    }
7350
7351    #[test]
7352    fn test_avg_open_to_high_ratio_one_when_open_at_low() {
7353        // open == low → (high - open) / range == 1
7354        let bar = make_ohlcv_bar(dec!(80), dec!(120), dec!(80), dec!(100));
7355        let r = OhlcvBar::avg_open_to_high_ratio(&[bar]).unwrap();
7356        assert!((r - 1.0).abs() < 1e-9, "open at low → ratio=1, got {}", r);
7357    }
7358
7359    // ── OhlcvBar::volume_weighted_range ──────────────────────────────────────
7360
7361    #[test]
7362    fn test_volume_weighted_range_none_for_empty() {
7363        assert!(OhlcvBar::volume_weighted_range(&[]).is_none());
7364    }
7365
7366    #[test]
7367    fn test_volume_weighted_range_positive() {
7368        let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
7369        let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(100), dec!(120));
7370        let r = OhlcvBar::volume_weighted_range(&[b1, b2]).unwrap();
7371        assert!(r > 0.0, "should be positive, got {}", r);
7372    }
7373
7374    // ── OhlcvBar::bar_strength_index ─────────────────────────────────────────
7375
7376    #[test]
7377    fn test_bar_strength_index_none_for_empty() {
7378        assert!(OhlcvBar::bar_strength_index(&[]).is_none());
7379    }
7380
7381    #[test]
7382    fn test_bar_strength_index_positive_when_closes_near_high() {
7383        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(120));
7384        let s = OhlcvBar::bar_strength_index(&[bar]).unwrap();
7385        assert!(s > 0.0, "close at high → positive strength, got {}", s);
7386    }
7387
7388    // ── OhlcvBar::shadow_to_body_ratio ────────────────────────────────────────
7389
7390    #[test]
7391    fn test_shadow_to_body_ratio_none_for_empty() {
7392        assert!(OhlcvBar::shadow_to_body_ratio(&[]).is_none());
7393    }
7394
7395    #[test]
7396    fn test_shadow_to_body_ratio_zero_for_marubozu() {
7397        // Marubozu: open==low and close==high → no wicks
7398        let bar = make_ohlcv_bar(dec!(80), dec!(120), dec!(80), dec!(120));
7399        let r = OhlcvBar::shadow_to_body_ratio(&[bar]).unwrap();
7400        assert!(r.abs() < 1e-9, "marubozu → ratio=0, got {}", r);
7401    }
7402
7403    // ── OhlcvBar::first_last_close_pct ───────────────────────────────────────
7404
7405    #[test]
7406    fn test_first_last_close_pct_none_for_empty() {
7407        assert!(OhlcvBar::first_last_close_pct(&[]).is_none());
7408    }
7409
7410    #[test]
7411    fn test_first_last_close_pct_zero_for_same_close() {
7412        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7413        let r = OhlcvBar::first_last_close_pct(&[bar]).unwrap();
7414        assert!(r.abs() < 1e-9, "same open/close → pct=0, got {}", r);
7415    }
7416
7417    #[test]
7418    fn test_first_last_close_pct_positive_for_rise() {
7419        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7420        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110));
7421        let r = OhlcvBar::first_last_close_pct(&[b1, b2]).unwrap();
7422        assert!(r > 0.0, "price rose → positive pct, got {}", r);
7423    }
7424
7425    // ── OhlcvBar::open_to_close_volatility ───────────────────────────────────
7426
7427    #[test]
7428    fn test_open_to_close_volatility_none_for_single_bar() {
7429        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7430        assert!(OhlcvBar::open_to_close_volatility(&[bar]).is_none());
7431    }
7432
7433    #[test]
7434    fn test_open_to_close_volatility_zero_for_identical_bars() {
7435        let bars = vec![
7436            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
7437            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
7438            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
7439        ];
7440        let v = OhlcvBar::open_to_close_volatility(&bars).unwrap();
7441        assert!(v.abs() < 1e-9, "identical bars → volatility=0, got {}", v);
7442    }
7443
7444    // ── round-80 tests ────────────────────────────────────────────────────────
7445
7446    #[test]
7447    fn test_close_recovery_ratio_none_for_empty() {
7448        assert!(OhlcvBar::close_recovery_ratio(&[]).is_none());
7449    }
7450
7451    #[test]
7452    fn test_close_recovery_ratio_one_for_close_at_high() {
7453        // close == high → ratio = 1.0
7454        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
7455        let r = OhlcvBar::close_recovery_ratio(&[b]).unwrap();
7456        assert!((r - 1.0).abs() < 1e-9, "close at high → ratio=1, got {}", r);
7457    }
7458
7459    #[test]
7460    fn test_median_range_none_for_empty() {
7461        assert!(OhlcvBar::median_range(&[]).is_none());
7462    }
7463
7464    #[test]
7465    fn test_median_range_correct_odd() {
7466        let bars = vec![
7467            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),  // range=20
7468            make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(105)),  // range=25
7469            make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105)),  // range=30
7470        ];
7471        assert_eq!(OhlcvBar::median_range(&bars).unwrap(), dec!(25));
7472    }
7473
7474    #[test]
7475    fn test_mean_typical_price_none_for_empty() {
7476        assert!(OhlcvBar::mean_typical_price(&[]).is_none());
7477    }
7478
7479    #[test]
7480    fn test_mean_typical_price_correct() {
7481        // typical = (110 + 90 + 105) / 3 = 101.666...
7482        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7483        let tp = OhlcvBar::mean_typical_price(&[b]).unwrap();
7484        assert_eq!(tp, b.typical_price());
7485    }
7486
7487    #[test]
7488    fn test_directional_volume_ratio_none_for_empty() {
7489        assert!(OhlcvBar::directional_volume_ratio(&[]).is_none());
7490    }
7491
7492    #[test]
7493    fn test_directional_volume_ratio_one_for_all_bullish() {
7494        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); b1.volume = dec!(50);
7495        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112)); b2.volume = dec!(50);
7496        let r = OhlcvBar::directional_volume_ratio(&[b1, b2]).unwrap();
7497        assert!((r - 1.0).abs() < 1e-9, "all bullish → ratio=1, got {}", r);
7498    }
7499
7500    #[test]
7501    fn test_inside_bar_fraction_none_for_single_bar() {
7502        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7503        assert!(OhlcvBar::inside_bar_fraction(&[b]).is_none());
7504    }
7505
7506    #[test]
7507    fn test_body_momentum_empty_is_zero() {
7508        assert_eq!(OhlcvBar::body_momentum(&[]), Decimal::ZERO);
7509    }
7510
7511    #[test]
7512    fn test_body_momentum_bullish_positive() {
7513        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
7514        let m = OhlcvBar::body_momentum(&[b]);
7515        assert!(m > Decimal::ZERO, "bullish bar → positive body momentum");
7516    }
7517
7518    #[test]
7519    fn test_avg_trade_count_none_for_empty() {
7520        assert!(OhlcvBar::avg_trade_count(&[]).is_none());
7521    }
7522
7523    #[test]
7524    fn test_max_trade_count_none_for_empty() {
7525        assert!(OhlcvBar::max_trade_count(&[]).is_none());
7526    }
7527
7528    #[test]
7529    fn test_max_trade_count_returns_max() {
7530        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.trade_count = 5;
7531        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.trade_count = 10;
7532        assert_eq!(OhlcvBar::max_trade_count(&[b1, b2]).unwrap(), 10);
7533    }
7534}