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    // ── round-81 ─────────────────────────────────────────────────────────────
2635
2636    /// Standard deviation of `(close − high)` across bars.
2637    ///
2638    /// Measures how consistently close prices approach the bar high.
2639    /// Returns `None` if fewer than 2 bars are provided.
2640    pub fn close_to_high_std(bars: &[OhlcvBar]) -> Option<f64> {
2641        use rust_decimal::prelude::ToPrimitive;
2642        if bars.len() < 2 {
2643            return None;
2644        }
2645        let vals: Vec<f64> = bars.iter().filter_map(|b| (b.high - b.close).to_f64()).collect();
2646        if vals.len() < 2 {
2647            return None;
2648        }
2649        let n = vals.len() as f64;
2650        let mean = vals.iter().sum::<f64>() / n;
2651        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2652        Some(variance.sqrt())
2653    }
2654
2655    /// Mean ratio of `volume / open` across bars.
2656    ///
2657    /// Normalises bar volume by the opening price level, useful for comparing
2658    /// activity across different price regimes. Bars with zero open are skipped.
2659    /// Returns `None` if no bars have a non-zero open.
2660    pub fn avg_open_volume_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2661        use rust_decimal::prelude::ToPrimitive;
2662        let vals: Vec<f64> = bars
2663            .iter()
2664            .filter_map(|b| {
2665                if b.open.is_zero() {
2666                    return None;
2667                }
2668                (b.volume / b.open).to_f64()
2669            })
2670            .collect();
2671        if vals.is_empty() {
2672            return None;
2673        }
2674        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2675    }
2676
2677    /// Standard deviation of typical prices `(high + low + close) / 3` across bars.
2678    ///
2679    /// Returns `None` if fewer than 2 bars are provided.
2680    pub fn typical_price_std(bars: &[OhlcvBar]) -> Option<f64> {
2681        use rust_decimal::prelude::ToPrimitive;
2682        if bars.len() < 2 {
2683            return None;
2684        }
2685        let vals: Vec<f64> = bars.iter().filter_map(|b| b.typical_price().to_f64()).collect();
2686        if vals.len() < 2 {
2687            return None;
2688        }
2689        let n = vals.len() as f64;
2690        let mean = vals.iter().sum::<f64>() / n;
2691        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2692        Some(variance.sqrt())
2693    }
2694
2695    /// Mean absolute deviation of bar VWAPs from the slice mean VWAP.
2696    ///
2697    /// Measures how spread the intrabar VWAPs are across the slice.
2698    /// Bars without a VWAP (gap-fill bars) are skipped.
2699    /// Returns `None` if fewer than 1 bar has a VWAP.
2700    pub fn vwap_deviation_avg(bars: &[OhlcvBar]) -> Option<f64> {
2701        use rust_decimal::prelude::ToPrimitive;
2702        let vwaps: Vec<f64> = bars
2703            .iter()
2704            .filter_map(|b| b.vwap?.to_f64())
2705            .collect();
2706        if vwaps.is_empty() {
2707            return None;
2708        }
2709        let mean = vwaps.iter().sum::<f64>() / vwaps.len() as f64;
2710        let mad = vwaps.iter().map(|v| (v - mean).abs()).sum::<f64>() / vwaps.len() as f64;
2711        Some(mad)
2712    }
2713
2714    /// Mean ratio of `high / low` across bars.
2715    ///
2716    /// A value near 1.0 means bars are narrow; higher values indicate wider ranges.
2717    /// Bars with zero low are skipped. Returns `None` if no bars have non-zero low.
2718    pub fn avg_high_low_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2719        use rust_decimal::prelude::ToPrimitive;
2720        let vals: Vec<f64> = bars
2721            .iter()
2722            .filter_map(|b| {
2723                if b.low.is_zero() {
2724                    return None;
2725                }
2726                (b.high / b.low).to_f64()
2727            })
2728            .collect();
2729        if vals.is_empty() {
2730            return None;
2731        }
2732        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2733    }
2734
2735    /// Fraction of bars that are gap-fill bars (`is_gap_fill == true`).
2736    ///
2737    /// Returns `None` if the slice is empty.
2738    pub fn gap_fill_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2739        if bars.is_empty() {
2740            return None;
2741        }
2742        let gap_bars = bars.iter().filter(|b| b.is_gap_fill).count();
2743        Some(gap_bars as f64 / bars.len() as f64)
2744    }
2745
2746    /// Count of complete bars (where `is_complete == true`).
2747    pub fn complete_bar_count(bars: &[OhlcvBar]) -> usize {
2748        bars.iter().filter(|b| b.is_complete).count()
2749    }
2750
2751    /// Minimum `trade_count` seen across the slice.
2752    ///
2753    /// Returns `None` if the slice is empty.
2754    pub fn min_trade_count(bars: &[OhlcvBar]) -> Option<u64> {
2755        bars.iter().map(|b| b.trade_count).min()
2756    }
2757
2758    // ── round-82 ─────────────────────────────────────────────────────────────
2759
2760    /// Mean of `high − low` across bars.
2761    pub fn avg_bar_range(bars: &[OhlcvBar]) -> Option<Decimal> {
2762        if bars.is_empty() {
2763            return None;
2764        }
2765        let sum: Decimal = bars.iter().map(|b| b.high - b.low).sum();
2766        Some(sum / Decimal::from(bars.len()))
2767    }
2768
2769    /// Largest single-bar upward body (`max(close − open, 0)`).
2770    pub fn max_up_move(bars: &[OhlcvBar]) -> Option<Decimal> {
2771        bars.iter().map(|b| (b.close - b.open).max(Decimal::ZERO)).max()
2772    }
2773
2774    /// Largest single-bar downward body (`max(open − close, 0)`).
2775    pub fn max_down_move(bars: &[OhlcvBar]) -> Option<Decimal> {
2776        bars.iter().map(|b| (b.open - b.close).max(Decimal::ZERO)).max()
2777    }
2778
2779    /// Mean of `(close − low) / range` where range > 0; position of close within each bar's range.
2780    pub fn avg_close_position(bars: &[OhlcvBar]) -> Option<f64> {
2781        use rust_decimal::prelude::ToPrimitive;
2782        let vals: Vec<f64> = bars
2783            .iter()
2784            .filter_map(|b| {
2785                let range = b.high - b.low;
2786                if range.is_zero() {
2787                    return None;
2788                }
2789                let pos = (b.close - b.low).to_f64()? / range.to_f64()?;
2790                Some(pos)
2791            })
2792            .collect();
2793        if vals.is_empty() {
2794            return None;
2795        }
2796        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2797    }
2798
2799    /// Std dev of volume across bars; requires ≥ 2 bars.
2800    pub fn volume_std(bars: &[OhlcvBar]) -> Option<f64> {
2801        use rust_decimal::prelude::ToPrimitive;
2802        if bars.len() < 2 {
2803            return None;
2804        }
2805        let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
2806        let n = vols.len() as f64;
2807        if n < 2.0 {
2808            return None;
2809        }
2810        let mean = vols.iter().sum::<f64>() / n;
2811        let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2812        Some(var.sqrt())
2813    }
2814
2815    /// Mean of `total_wick / range` per bar (proportion of range that is wick); excludes doji bars.
2816    pub fn avg_wick_ratio(bars: &[OhlcvBar]) -> Option<f64> {
2817        use rust_decimal::prelude::ToPrimitive;
2818        let vals: Vec<f64> = bars
2819            .iter()
2820            .filter_map(|b| {
2821                let range = b.high - b.low;
2822                if range.is_zero() {
2823                    return None;
2824                }
2825                let upper = b.high - b.close.max(b.open);
2826                let lower = b.close.min(b.open) - b.low;
2827                let wick = upper + lower;
2828                let ratio = wick.to_f64()? / range.to_f64()?;
2829                Some(ratio)
2830            })
2831            .collect();
2832        if vals.is_empty() {
2833            return None;
2834        }
2835        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2836    }
2837
2838    /// Mean of `|open_i − close_{i-1}| / close_{i-1}` across bars from the second onward; measures gap size.
2839    pub fn open_gap_mean(bars: &[OhlcvBar]) -> Option<f64> {
2840        use rust_decimal::prelude::ToPrimitive;
2841        if bars.len() < 2 {
2842            return None;
2843        }
2844        let vals: Vec<f64> = bars
2845            .windows(2)
2846            .filter_map(|w| {
2847                let prev_close = w[0].close;
2848                if prev_close.is_zero() {
2849                    return None;
2850                }
2851                let gap = (w[1].open - prev_close).abs().to_f64()? / prev_close.to_f64()?;
2852                Some(gap)
2853            })
2854            .collect();
2855        if vals.is_empty() {
2856            return None;
2857        }
2858        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2859    }
2860
2861    /// Net directional move: `(last_close − first_open) / first_open`; overall percentage move across all bars.
2862    pub fn net_directional_move(bars: &[OhlcvBar]) -> Option<f64> {
2863        use rust_decimal::prelude::ToPrimitive;
2864        if bars.is_empty() {
2865            return None;
2866        }
2867        let first_open = bars.first()?.open;
2868        let last_close = bars.last()?.close;
2869        if first_open.is_zero() {
2870            return None;
2871        }
2872        let pct = (last_close - first_open).to_f64()? / first_open.to_f64()?;
2873        Some(pct)
2874    }
2875
2876    // ── round-83 ─────────────────────────────────────────────────────────────
2877
2878    /// Fraction of bars where the close is above the bar's median price
2879    /// `(high + low) / 2`.
2880    ///
2881    /// A high fraction indicates persistently bullish closes; near 0.5 means
2882    /// closes tend to land at the midpoint. Returns `None` for empty slices.
2883    pub fn close_above_median_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2884        if bars.is_empty() {
2885            return None;
2886        }
2887        let above = bars.iter().filter(|b| b.close > b.high_low_midpoint()).count();
2888        Some(above as f64 / bars.len() as f64)
2889    }
2890
2891    /// Mean of `(high − low) / open` across bars — intrabar range relative to
2892    /// opening price.
2893    ///
2894    /// Returns `None` when no bars have a non-zero open.
2895    pub fn avg_range_to_open(bars: &[OhlcvBar]) -> Option<f64> {
2896        use rust_decimal::prelude::ToPrimitive;
2897        let vals: Vec<f64> = bars
2898            .iter()
2899            .filter_map(|b| {
2900                if b.open.is_zero() { return None; }
2901                ((b.high - b.low) / b.open).to_f64()
2902            })
2903            .collect();
2904        if vals.is_empty() {
2905            return None;
2906        }
2907        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2908    }
2909
2910    /// Sum of all close prices across the slice.
2911    ///
2912    /// Useful as a component in rolling sum-based indicators.
2913    /// Returns `Decimal::ZERO` for an empty slice.
2914    pub fn close_sum(bars: &[OhlcvBar]) -> Decimal {
2915        bars.iter().map(|b| b.close).sum()
2916    }
2917
2918    /// Count of bars where volume strictly exceeds the slice average volume.
2919    ///
2920    /// Returns 0 for empty slices or when average volume cannot be computed.
2921    pub fn above_avg_volume_count(bars: &[OhlcvBar]) -> usize {
2922        let avg = Self::mean_volume(bars).unwrap_or(Decimal::ZERO);
2923        if avg.is_zero() {
2924            return 0;
2925        }
2926        bars.iter().filter(|b| b.volume > avg).count()
2927    }
2928
2929    /// Median close price across the slice.
2930    ///
2931    /// Returns `None` for empty slices.
2932    pub fn median_close(bars: &[OhlcvBar]) -> Option<Decimal> {
2933        if bars.is_empty() {
2934            return None;
2935        }
2936        let mut closes: Vec<Decimal> = bars.iter().map(|b| b.close).collect();
2937        closes.sort();
2938        let n = closes.len();
2939        if n % 2 == 1 {
2940            Some(closes[n / 2])
2941        } else {
2942            Some((closes[n / 2 - 1] + closes[n / 2]) / Decimal::from(2u64))
2943        }
2944    }
2945
2946    /// Fraction of bars that are flat (open == close, i.e., doji-like).
2947    ///
2948    /// Returns `None` for empty slices.
2949    pub fn flat_bar_fraction(bars: &[OhlcvBar]) -> Option<f64> {
2950        if bars.is_empty() {
2951            return None;
2952        }
2953        let flat = bars.iter().filter(|b| b.open == b.close).count();
2954        Some(flat as f64 / bars.len() as f64)
2955    }
2956
2957    /// Mean of `body / range` per bar — average fraction of the range that
2958    /// is body. Bars with zero range are excluded.
2959    ///
2960    /// Returns `None` when no bars have non-zero range.
2961    pub fn avg_body_to_range(bars: &[OhlcvBar]) -> Option<f64> {
2962        use rust_decimal::prelude::ToPrimitive;
2963        let vals: Vec<f64> = bars
2964            .iter()
2965            .filter_map(|b| {
2966                let r = b.range();
2967                if r.is_zero() { return None; }
2968                (b.body() / r).to_f64()
2969            })
2970            .collect();
2971        if vals.is_empty() {
2972            return None;
2973        }
2974        Some(vals.iter().sum::<f64>() / vals.len() as f64)
2975    }
2976
2977    /// Largest single-bar price gap (open vs. previous close) in the slice.
2978    ///
2979    /// Returns `None` for fewer than 2 bars.
2980    pub fn max_open_gap(bars: &[OhlcvBar]) -> Option<Decimal> {
2981        if bars.len() < 2 {
2982            return None;
2983        }
2984        bars.windows(2)
2985            .map(|w| (w[1].open - w[0].close).abs())
2986            .max()
2987    }
2988
2989    /// OLS linear regression slope of bar volume over bar index.
2990    ///
2991    /// A positive slope means volume is trending up; negative means trending
2992    /// down. Returns `None` for fewer than 2 bars.
2993    pub fn volume_trend_slope(bars: &[OhlcvBar]) -> Option<f64> {
2994        use rust_decimal::prelude::ToPrimitive;
2995        let n = bars.len();
2996        if n < 2 {
2997            return None;
2998        }
2999        let n_f = n as f64;
3000        let x_mean = (n_f - 1.0) / 2.0;
3001        let y: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
3002        if y.len() < 2 {
3003            return None;
3004        }
3005        let y_mean = y.iter().sum::<f64>() / y.len() as f64;
3006        let num: f64 = y.iter().enumerate().map(|(i, &v)| (i as f64 - x_mean) * (v - y_mean)).sum();
3007        let den: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();
3008        if den == 0.0 { None } else { Some(num / den) }
3009    }
3010
3011    /// Fraction of bars where close > previous close (i.e., up-close bars).
3012    ///
3013    /// Returns `None` for fewer than 2 bars.
3014    pub fn up_close_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3015        if bars.len() < 2 {
3016            return None;
3017        }
3018        let up = bars.windows(2).filter(|w| w[1].close > w[0].close).count();
3019        Some(up as f64 / (bars.len() - 1) as f64)
3020    }
3021
3022    /// Mean of the upper-shadow-to-range ratio across bars.
3023    ///
3024    /// `upper_shadow / range` for each bar with non-zero range.
3025    /// Returns `None` when no bars have non-zero range.
3026    pub fn avg_upper_shadow_ratio(bars: &[OhlcvBar]) -> Option<f64> {
3027        use rust_decimal::prelude::ToPrimitive;
3028        let vals: Vec<f64> = bars
3029            .iter()
3030            .filter_map(|b| {
3031                let r = b.range();
3032                if r.is_zero() { return None; }
3033                (b.upper_shadow() / r).to_f64()
3034            })
3035            .collect();
3036        if vals.is_empty() {
3037            return None;
3038        }
3039        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3040    }
3041
3042    // ── round-84 ─────────────────────────────────────────────────────────────
3043
3044    /// Mean of `lower_shadow / range` per bar; excludes doji bars.
3045    pub fn avg_lower_shadow_ratio(bars: &[OhlcvBar]) -> Option<f64> {
3046        use rust_decimal::prelude::ToPrimitive;
3047        let vals: Vec<f64> = bars
3048            .iter()
3049            .filter_map(|b| {
3050                let r = b.range();
3051                if r.is_zero() { return None; }
3052                (b.lower_shadow() / r).to_f64()
3053            })
3054            .collect();
3055        if vals.is_empty() {
3056            return None;
3057        }
3058        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3059    }
3060
3061    /// Mean of `(close - open) / (high - low)` per bar with non-zero range; signed body position.
3062    pub fn close_to_open_range_ratio(bars: &[OhlcvBar]) -> Option<f64> {
3063        use rust_decimal::prelude::ToPrimitive;
3064        let vals: Vec<f64> = bars
3065            .iter()
3066            .filter_map(|b| {
3067                let r = b.range();
3068                if r.is_zero() { return None; }
3069                ((b.close - b.open) / r).to_f64()
3070            })
3071            .collect();
3072        if vals.is_empty() {
3073            return None;
3074        }
3075        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3076    }
3077
3078    /// Maximum high price across all bars.
3079    pub fn max_high(bars: &[OhlcvBar]) -> Option<Decimal> {
3080        bars.iter().map(|b| b.high).max()
3081    }
3082
3083    /// Minimum low price across all bars.
3084    pub fn min_low(bars: &[OhlcvBar]) -> Option<Decimal> {
3085        bars.iter().map(|b| b.low).min()
3086    }
3087
3088    /// Mean `|close − open| / range` across non-doji bars; how much of the range became directional body.
3089    pub fn avg_bar_efficiency(bars: &[OhlcvBar]) -> Option<f64> {
3090        use rust_decimal::prelude::ToPrimitive;
3091        let vals: Vec<f64> = bars
3092            .iter()
3093            .filter_map(|b| {
3094                let r = b.range();
3095                if r.is_zero() { return None; }
3096                ((b.close - b.open).abs() / r).to_f64()
3097            })
3098            .collect();
3099        if vals.is_empty() {
3100            return None;
3101        }
3102        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3103    }
3104
3105    /// Fraction of bars where `open` lies in the upper half of `[low, high]`.
3106    pub fn open_range_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3107        if bars.is_empty() {
3108            return None;
3109        }
3110        let count = bars
3111            .iter()
3112            .filter(|b| {
3113                let mid = (b.high + b.low) / Decimal::from(2);
3114                b.open >= mid
3115            })
3116            .count();
3117        Some(count as f64 / bars.len() as f64)
3118    }
3119
3120    /// Skewness of close prices across bars.
3121    pub fn close_skewness(bars: &[OhlcvBar]) -> Option<f64> {
3122        use rust_decimal::prelude::ToPrimitive;
3123        if bars.len() < 3 {
3124            return None;
3125        }
3126        let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
3127        let n = vals.len() as f64;
3128        let mean = vals.iter().sum::<f64>() / n;
3129        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
3130        let std = var.sqrt();
3131        if std < 1e-12 {
3132            return None;
3133        }
3134        let skew = vals.iter().map(|v| ((v - mean) / std).powi(3)).sum::<f64>() / n;
3135        Some(skew)
3136    }
3137
3138    /// Fraction of bars whose volume exceeds the median bar volume.
3139    pub fn volume_above_median_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3140        if bars.is_empty() {
3141            return None;
3142        }
3143        let mut vols: Vec<Decimal> = bars.iter().map(|b| b.volume).collect();
3144        vols.sort();
3145        let mid = vols.len() / 2;
3146        let median = if vols.len() % 2 == 0 {
3147            (vols[mid - 1] + vols[mid]) / Decimal::from(2)
3148        } else {
3149            vols[mid]
3150        };
3151        let count = bars.iter().filter(|b| b.volume > median).count();
3152        Some(count as f64 / bars.len() as f64)
3153    }
3154
3155    /// Sum of typical prices `(high + low + close) / 3` across bars.
3156    pub fn typical_price_sum(bars: &[OhlcvBar]) -> Decimal {
3157        bars.iter()
3158            .map(|b| (b.high + b.low + b.close) / Decimal::from(3))
3159            .sum()
3160    }
3161
3162    /// Maximum bar body size `|close - open|` across all bars.
3163    pub fn max_body_size(bars: &[OhlcvBar]) -> Option<Decimal> {
3164        bars.iter().map(|b| (b.close - b.open).abs()).max()
3165    }
3166
3167    /// Minimum bar body size `|close - open|` across all bars.
3168    pub fn min_body_size(bars: &[OhlcvBar]) -> Option<Decimal> {
3169        bars.iter().map(|b| (b.close - b.open).abs()).min()
3170    }
3171
3172    /// Mean ratio of lower wick to full bar range; zero-range bars are excluded.
3173    pub fn avg_lower_wick_to_range(bars: &[OhlcvBar]) -> Option<f64> {
3174        use rust_decimal::prelude::ToPrimitive;
3175        let vals: Vec<f64> = bars
3176            .iter()
3177            .filter_map(|b| {
3178                let range = b.high - b.low;
3179                if range.is_zero() {
3180                    return None;
3181                }
3182                let lower_wick = b.open.min(b.close) - b.low;
3183                (lower_wick / range).to_f64()
3184            })
3185            .collect();
3186        if vals.is_empty() {
3187            return None;
3188        }
3189        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3190    }
3191
3192    // ── round-85 ─────────────────────────────────────────────────────────────
3193
3194    /// `high − low` summed across all bars; total accumulated range.
3195    pub fn total_range(bars: &[OhlcvBar]) -> Decimal {
3196        bars.iter().map(|b| b.high - b.low).sum()
3197    }
3198
3199    /// Fraction of bars where the close is strictly equal to the high (outside-bar bullish close).
3200    pub fn close_at_high_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3201        if bars.is_empty() {
3202            return None;
3203        }
3204        let count = bars.iter().filter(|b| b.close == b.high).count();
3205        Some(count as f64 / bars.len() as f64)
3206    }
3207
3208    /// Fraction of bars where the close is strictly equal to the low (bearish exhaustion bar).
3209    pub fn close_at_low_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3210        if bars.is_empty() {
3211            return None;
3212        }
3213        let count = bars.iter().filter(|b| b.close == b.low).count();
3214        Some(count as f64 / bars.len() as f64)
3215    }
3216
3217    /// Mean of `(high - open) / range` across non-doji bars; how far price moved above the open.
3218    pub fn avg_high_above_open_ratio(bars: &[OhlcvBar]) -> Option<f64> {
3219        use rust_decimal::prelude::ToPrimitive;
3220        let vals: Vec<f64> = bars
3221            .iter()
3222            .filter_map(|b| {
3223                let range = b.high - b.low;
3224                if range.is_zero() {
3225                    return None;
3226                }
3227                ((b.high - b.open) / range).to_f64()
3228            })
3229            .collect();
3230        if vals.is_empty() {
3231            return None;
3232        }
3233        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3234    }
3235
3236    /// Count of bars with `high == previous_bar.close` (gap-free continuation bars).
3237    pub fn continuation_bar_count(bars: &[OhlcvBar]) -> usize {
3238        if bars.len() < 2 {
3239            return 0;
3240        }
3241        bars.windows(2)
3242            .filter(|w| w[1].open == w[0].close)
3243            .count()
3244    }
3245
3246    /// Sum of bar volumes where the bar was a down-close.
3247    pub fn down_close_volume(bars: &[OhlcvBar]) -> Decimal {
3248        bars.iter()
3249            .filter(|b| b.close < b.open)
3250            .map(|b| b.volume)
3251            .sum()
3252    }
3253
3254    /// Sum of bar volumes where the bar was an up-close.
3255    pub fn up_close_volume(bars: &[OhlcvBar]) -> Decimal {
3256        bars.iter()
3257            .filter(|b| b.close > b.open)
3258            .map(|b| b.volume)
3259            .sum()
3260    }
3261
3262    // ── round-86 ─────────────────────────────────────────────────────────────
3263
3264    /// Mean open price across bars.
3265    pub fn mean_open(bars: &[OhlcvBar]) -> Option<Decimal> {
3266        if bars.is_empty() {
3267            return None;
3268        }
3269        let sum: Decimal = bars.iter().map(|b| b.open).sum();
3270        Some(sum / Decimal::from(bars.len() as i64))
3271    }
3272
3273    /// Count of bars where `high` is strictly greater than all previous bar highs.
3274    pub fn new_high_count(bars: &[OhlcvBar]) -> usize {
3275        if bars.is_empty() {
3276            return 0;
3277        }
3278        let mut running_max = bars[0].high;
3279        let mut count = 0usize;
3280        for b in bars.iter().skip(1) {
3281            if b.high > running_max {
3282                count += 1;
3283                running_max = b.high;
3284            }
3285        }
3286        count
3287    }
3288
3289    /// Count of bars where `low` is strictly less than all previous bar lows.
3290    pub fn new_low_count(bars: &[OhlcvBar]) -> usize {
3291        if bars.is_empty() {
3292            return 0;
3293        }
3294        let mut running_min = bars[0].low;
3295        let mut count = 0usize;
3296        for b in bars.iter().skip(1) {
3297            if b.low < running_min {
3298                count += 1;
3299                running_min = b.low;
3300            }
3301        }
3302        count
3303    }
3304
3305    /// Standard deviation of close prices; requires ≥ 2 bars.
3306    pub fn close_std(bars: &[OhlcvBar]) -> Option<f64> {
3307        use rust_decimal::prelude::ToPrimitive;
3308        if bars.len() < 2 {
3309            return None;
3310        }
3311        let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
3312        let n = vals.len() as f64;
3313        let mean = vals.iter().sum::<f64>() / n;
3314        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
3315        Some(var.sqrt())
3316    }
3317
3318    /// Fraction of bars with zero volume.
3319    pub fn zero_volume_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3320        if bars.is_empty() {
3321            return None;
3322        }
3323        let count = bars.iter().filter(|b| b.volume.is_zero()).count();
3324        Some(count as f64 / bars.len() as f64)
3325    }
3326
3327    // ── round-87 ─────────────────────────────────────────────────────────────
3328
3329    /// Mean of (close − open) across all bars.  Positive means net bullish drift.
3330    pub fn avg_open_to_close(bars: &[OhlcvBar]) -> Option<Decimal> {
3331        if bars.is_empty() {
3332            return None;
3333        }
3334        let sum: Decimal = bars.iter().map(|b| b.close - b.open).sum();
3335        Some(sum / Decimal::from(bars.len() as i64))
3336    }
3337
3338    /// Maximum volume across bars.
3339    pub fn max_bar_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
3340        bars.iter().map(|b| b.volume).max()
3341    }
3342
3343    /// Minimum volume across bars.
3344    pub fn min_bar_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
3345        bars.iter().map(|b| b.volume).min()
3346    }
3347
3348    /// Standard deviation of body-to-range ratios across bars.
3349    /// Body = |close − open|, range = high − low.  Bars with zero range are excluded.
3350    /// Returns `None` if fewer than 2 non-zero-range bars.
3351    pub fn body_to_range_std(bars: &[OhlcvBar]) -> Option<f64> {
3352        use rust_decimal::prelude::ToPrimitive;
3353        let ratios: Vec<f64> = bars
3354            .iter()
3355            .filter(|b| b.high > b.low)
3356            .filter_map(|b| {
3357                let body = (b.close - b.open).abs();
3358                let range = b.high - b.low;
3359                (body / range).to_f64()
3360            })
3361            .collect();
3362        if ratios.len() < 2 {
3363            return None;
3364        }
3365        let n = ratios.len() as f64;
3366        let mean = ratios.iter().sum::<f64>() / n;
3367        let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
3368        Some(var.sqrt())
3369    }
3370
3371    /// Mean wick symmetry: average of min(upper_wick, lower_wick) / max(upper_wick, lower_wick).
3372    /// A value near 1 means wicks are balanced; near 0 means heavily one-sided.
3373    /// Bars with both wicks zero are excluded.
3374    pub fn avg_wick_symmetry(bars: &[OhlcvBar]) -> Option<f64> {
3375        use rust_decimal::prelude::ToPrimitive;
3376        let ratios: Vec<f64> = bars
3377            .iter()
3378            .filter_map(|b| {
3379                let upper = b.high - b.close.max(b.open);
3380                let lower = b.close.min(b.open) - b.low;
3381                if upper.is_zero() && lower.is_zero() {
3382                    return None;
3383                }
3384                let lo = upper.min(lower);
3385                let hi = upper.max(lower);
3386                if hi.is_zero() {
3387                    return None;
3388                }
3389                (lo / hi).to_f64()
3390            })
3391            .collect();
3392        if ratios.is_empty() {
3393            return None;
3394        }
3395        Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
3396    }
3397
3398    // ── round-88 ─────────────────────────────────────────────────────────────
3399
3400    /// Price range of a single bar expressed as a fraction of open price.
3401    ///
3402    /// Returns `None` if open is zero or the slice is empty.
3403    pub fn avg_range_pct_of_open(bars: &[OhlcvBar]) -> Option<f64> {
3404        use rust_decimal::prelude::ToPrimitive;
3405        let vals: Vec<f64> = bars
3406            .iter()
3407            .filter_map(|b| {
3408                if b.open.is_zero() { return None; }
3409                ((b.high - b.low) / b.open).to_f64()
3410            })
3411            .collect();
3412        if vals.is_empty() {
3413            return None;
3414        }
3415        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3416    }
3417
3418    /// Fraction of bars where volume is in the upper half of the volume range.
3419    ///
3420    /// Returns `None` for empty slices or when all volumes are equal.
3421    pub fn high_volume_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3422        if bars.is_empty() {
3423            return None;
3424        }
3425        let max_vol = bars.iter().map(|b| b.volume).max()?;
3426        let min_vol = bars.iter().map(|b| b.volume).min()?;
3427        let mid = (max_vol + min_vol) / Decimal::from(2);
3428        if max_vol == min_vol {
3429            return None;
3430        }
3431        let count = bars.iter().filter(|b| b.volume > mid).count();
3432        Some(count as f64 / bars.len() as f64)
3433    }
3434
3435    /// Count of bars where close is within 1% of the prior bar's close.
3436    ///
3437    /// Returns 0 for slices with fewer than 2 bars.
3438    pub fn close_cluster_count(bars: &[OhlcvBar]) -> usize {
3439        use rust_decimal::prelude::ToPrimitive;
3440        if bars.len() < 2 {
3441            return 0;
3442        }
3443        bars.windows(2)
3444            .filter(|w| {
3445                if w[0].close.is_zero() {
3446                    return false;
3447                }
3448                let pct_diff = ((w[1].close - w[0].close) / w[0].close).abs();
3449                pct_diff <= rust_decimal::Decimal::new(1, 2)
3450            })
3451            .count()
3452    }
3453
3454    /// Mean of `vwap` values across bars that have a VWAP computed.
3455    ///
3456    /// Returns `None` for empty slices or when no bars have a VWAP.
3457    pub fn mean_vwap(bars: &[OhlcvBar]) -> Option<Decimal> {
3458        let vals: Vec<Decimal> = bars.iter().filter_map(|b| b.vwap).collect();
3459        if vals.is_empty() {
3460            return None;
3461        }
3462        let sum: Decimal = vals.iter().copied().sum();
3463        Some(sum / Decimal::from(vals.len() as i64))
3464    }
3465
3466    /// Fraction of bars that are complete (is_complete == true).
3467    ///
3468    /// Returns `None` for empty slices.
3469    pub fn complete_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3470        if bars.is_empty() {
3471            return None;
3472        }
3473        let count = bars.iter().filter(|b| b.is_complete).count();
3474        Some(count as f64 / bars.len() as f64)
3475    }
3476
3477    /// Sum of `(close − open).abs()` across all bars; total body movement.
3478    pub fn total_body_movement(bars: &[OhlcvBar]) -> Decimal {
3479        bars.iter().map(|b| (b.close - b.open).abs()).sum()
3480    }
3481
3482    /// Sample standard deviation of open prices across bars.  Requires ≥ 2 bars.
3483    pub fn open_std(bars: &[OhlcvBar]) -> Option<f64> {
3484        use rust_decimal::prelude::ToPrimitive;
3485        if bars.len() < 2 {
3486            return None;
3487        }
3488        let vals: Vec<f64> = bars.iter().filter_map(|b| b.open.to_f64()).collect();
3489        let n = vals.len() as f64;
3490        let mean = vals.iter().sum::<f64>() / n;
3491        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
3492        Some(var.sqrt())
3493    }
3494
3495    /// Mean of `high / low` ratios across bars (bars with zero `low` are skipped).
3496    pub fn mean_high_low_ratio(bars: &[OhlcvBar]) -> Option<f64> {
3497        use rust_decimal::prelude::ToPrimitive;
3498        let vals: Vec<f64> = bars
3499            .iter()
3500            .filter(|b| !b.low.is_zero())
3501            .filter_map(|b| (b.high / b.low).to_f64())
3502            .collect();
3503        if vals.is_empty() {
3504            return None;
3505        }
3506        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3507    }
3508
3509    // ── round-89 ─────────────────────────────────────────────────────────────
3510
3511    /// Maximum run of consecutive bullish bars (`close > open`).
3512    ///
3513    /// Returns `0` for an empty slice.
3514    pub fn max_consecutive_up_bars(bars: &[OhlcvBar]) -> usize {
3515        let mut max_run = 0usize;
3516        let mut run = 0usize;
3517        for b in bars {
3518            if b.close > b.open {
3519                run += 1;
3520                if run > max_run {
3521                    max_run = run;
3522                }
3523            } else {
3524                run = 0;
3525            }
3526        }
3527        max_run
3528    }
3529
3530    /// Mean upper shadow as a fraction of total bar range.
3531    /// Upper shadow = high − max(open, close).  Bars with zero range are excluded.
3532    pub fn avg_upper_shadow_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3533        use rust_decimal::prelude::ToPrimitive;
3534        let vals: Vec<f64> = bars
3535            .iter()
3536            .filter(|b| b.high > b.low)
3537            .filter_map(|b| {
3538                let range = b.high - b.low;
3539                let upper = b.high - b.close.max(b.open);
3540                (upper / range).to_f64()
3541            })
3542            .collect();
3543        if vals.is_empty() {
3544            return None;
3545        }
3546        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3547    }
3548
3549    /// Ratio of up-bars (close > open) to down-bars (close < open).
3550    /// Returns `None` if no down-bars exist.
3551    pub fn up_down_bar_ratio(bars: &[OhlcvBar]) -> Option<f64> {
3552        let ups = bars.iter().filter(|b| b.close > b.open).count();
3553        let downs = bars.iter().filter(|b| b.close < b.open).count();
3554        if downs == 0 {
3555            return None;
3556        }
3557        Some(ups as f64 / downs as f64)
3558    }
3559
3560    // ── round-90 ─────────────────────────────────────────────────────────────
3561
3562    /// Fraction of bars where `(close − low) / (high − low)` exceeds 0.5 (close in upper half of range).
3563    ///
3564    /// Bars with zero range are excluded. Returns `None` when no valid bars remain.
3565    pub fn close_range_fraction(bars: &[OhlcvBar]) -> Option<f64> {
3566        use rust_decimal::prelude::ToPrimitive;
3567        let half = Decimal::new(5, 1);
3568        let vals: Vec<f64> = bars
3569            .iter()
3570            .filter(|b| b.high > b.low)
3571            .filter_map(|b| {
3572                let r = (b.close - b.low) / (b.high - b.low);
3573                r.to_f64()
3574            })
3575            .collect();
3576        if vals.is_empty() {
3577            return None;
3578        }
3579        let _ = half;
3580        let count = vals.iter().filter(|&&v| v > 0.5).count();
3581        Some(count as f64 / vals.len() as f64)
3582    }
3583
3584    /// Symmetry of upper vs lower shadows: `1 − |upper_shadow − lower_shadow| / range`.
3585    ///
3586    /// Returns `None` for an empty slice or when all bars have zero range.
3587    pub fn tail_symmetry(bars: &[OhlcvBar]) -> Option<f64> {
3588        use rust_decimal::prelude::ToPrimitive;
3589        let vals: Vec<f64> = bars
3590            .iter()
3591            .filter(|b| b.high > b.low)
3592            .filter_map(|b| {
3593                let range = (b.high - b.low).to_f64()?;
3594                let upper = (b.high - b.close.max(b.open)).to_f64()?;
3595                let lower = (b.close.min(b.open) - b.low).to_f64()?;
3596                Some(1.0 - (upper - lower).abs() / range)
3597            })
3598            .collect();
3599        if vals.is_empty() {
3600            return None;
3601        }
3602        Some(vals.iter().sum::<f64>() / vals.len() as f64)
3603    }
3604
3605    /// Fraction of bars where `close` is higher than the previous bar's `close`.
3606    ///
3607    /// Returns `None` for fewer than 2 bars.
3608    pub fn bar_trend_strength(bars: &[OhlcvBar]) -> Option<f64> {
3609        if bars.len() < 2 {
3610            return None;
3611        }
3612        let up_count = bars.windows(2).filter(|w| w[1].close > w[0].close).count();
3613        Some(up_count as f64 / (bars.len() - 1) as f64)
3614    }
3615
3616}
3617
3618impl std::fmt::Display for OhlcvBar {
3619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3620        write!(
3621            f,
3622            "{} {} [{}/{}/{}/{}  v={}]",
3623            self.symbol, self.timeframe, self.open, self.high, self.low, self.close, self.volume
3624        )
3625    }
3626}
3627
3628/// Aggregates ticks into OHLCV bars.
3629pub struct OhlcvAggregator {
3630    symbol: String,
3631    timeframe: Timeframe,
3632    current_bar: Option<OhlcvBar>,
3633    /// The most recently completed bar emitted by `feed` or `flush`.
3634    last_bar: Option<OhlcvBar>,
3635    /// When true, `feed` returns synthetic zero-volume bars for any bar windows
3636    /// that were skipped between the previous tick and the current one.
3637    /// The synthetic bars use the last known close price for all OHLC fields.
3638    emit_empty_bars: bool,
3639    /// Total number of completed bars emitted by this aggregator.
3640    bars_emitted: u64,
3641    /// Running sum of `price × quantity` for VWAP computation in the current bar.
3642    price_volume_sum: Decimal,
3643    /// Cumulative volume across all completed bars (does not include the current partial bar).
3644    total_volume: Decimal,
3645    /// Maximum single-bar volume seen across all completed bars.
3646    peak_volume: Option<Decimal>,
3647    /// Minimum single-bar volume seen across all completed bars.
3648    min_volume: Option<Decimal>,
3649}
3650
3651impl OhlcvAggregator {
3652    /// Create a new aggregator for `symbol` at `timeframe`.
3653    ///
3654    /// Returns an error if `timeframe.duration_ms()` is zero, which would make
3655    /// bar boundary alignment undefined.
3656    pub fn new(symbol: impl Into<String>, timeframe: Timeframe) -> Result<Self, StreamError> {
3657        let tf_dur = timeframe.duration_ms();
3658        if tf_dur == 0 {
3659            return Err(StreamError::ConfigError {
3660                reason: "OhlcvAggregator timeframe duration must be > 0".into(),
3661            });
3662        }
3663        Ok(Self {
3664            symbol: symbol.into(),
3665            timeframe,
3666            current_bar: None,
3667            last_bar: None,
3668            emit_empty_bars: false,
3669            bars_emitted: 0,
3670            price_volume_sum: Decimal::ZERO,
3671            total_volume: Decimal::ZERO,
3672            peak_volume: None,
3673            min_volume: None,
3674        })
3675    }
3676
3677    /// Enable emission of synthetic zero-volume bars for skipped bar windows.
3678    pub fn with_emit_empty_bars(mut self, enabled: bool) -> Self {
3679        self.emit_empty_bars = enabled;
3680        self
3681    }
3682
3683    /// Feed a tick. Returns completed bars (including any empty gap bars when
3684    /// `emit_empty_bars` is true). At most one real completed bar plus zero or
3685    /// more empty bars can be returned per call.
3686    ///
3687    /// Bar boundaries are aligned using the exchange-side timestamp
3688    /// (`exchange_ts_ms`) when available, falling back to the local system
3689    /// clock (`received_at_ms`). Using the exchange timestamp avoids
3690    /// misalignment caused by variable network latency.
3691    #[must_use = "completed bars are returned; ignoring them loses bar data"]
3692    #[inline]
3693    pub fn feed(&mut self, tick: &NormalizedTick) -> Result<Vec<OhlcvBar>, StreamError> {
3694        if tick.symbol != self.symbol {
3695            return Err(StreamError::AggregationError {
3696                reason: format!(
3697                    "tick symbol '{}' does not match aggregator '{}'",
3698                    tick.symbol, self.symbol
3699                ),
3700            });
3701        }
3702
3703        // Prefer the authoritative exchange timestamp; fall back to local clock.
3704        let tick_ts = tick.exchange_ts_ms.unwrap_or(tick.received_at_ms);
3705        let bar_start = self.timeframe.bar_start_ms(tick_ts);
3706        let mut emitted: Vec<OhlcvBar> = Vec::new();
3707
3708        // Check whether the incoming tick belongs to a new bar window.
3709        let bar_window_changed = self
3710            .current_bar
3711            .as_ref()
3712            .map_or(false, |b| b.bar_start_ms != bar_start);
3713
3714        if bar_window_changed {
3715            // Take ownership — avoids cloning the current bar.
3716            let mut completed = self.current_bar.take().unwrap_or_else(|| unreachable!());
3717            completed.is_complete = true;
3718            let prev_close = completed.close;
3719            let prev_start = completed.bar_start_ms;
3720            emitted.push(completed);
3721
3722            // Optionally fill any empty bar windows between prev_start and bar_start.
3723            if self.emit_empty_bars {
3724                let dur = self.timeframe.duration_ms();
3725                let mut gap_start = prev_start + dur;
3726                while gap_start < bar_start {
3727                    emitted.push(OhlcvBar {
3728                        symbol: self.symbol.clone(),
3729                        timeframe: self.timeframe,
3730                        bar_start_ms: gap_start,
3731                        open: prev_close,
3732                        high: prev_close,
3733                        low: prev_close,
3734                        close: prev_close,
3735                        volume: Decimal::ZERO,
3736                        trade_count: 0,
3737                        is_complete: true,
3738                        is_gap_fill: true,
3739                        vwap: None,
3740                    });
3741                    gap_start += dur;
3742                }
3743            }
3744        }
3745
3746        // Update price_volume_sum before the match to avoid borrow conflicts.
3747        let tick_value = tick.value();
3748        if self.current_bar.is_some() {
3749            self.price_volume_sum += tick_value;
3750        } else {
3751            self.price_volume_sum = tick_value;
3752        }
3753
3754        match &mut self.current_bar {
3755            Some(bar) => {
3756                if tick.price > bar.high {
3757                    bar.high = tick.price;
3758                }
3759                if tick.price < bar.low {
3760                    bar.low = tick.price;
3761                }
3762                bar.close = tick.price;
3763                bar.volume += tick.quantity;
3764                bar.trade_count += 1;
3765                bar.vwap = if bar.volume.is_zero() {
3766                    None
3767                } else {
3768                    Some(self.price_volume_sum / bar.volume)
3769                };
3770            }
3771            None => {
3772                self.current_bar = Some(OhlcvBar {
3773                    symbol: self.symbol.clone(),
3774                    timeframe: self.timeframe,
3775                    bar_start_ms: bar_start,
3776                    open: tick.price,
3777                    high: tick.price,
3778                    low: tick.price,
3779                    close: tick.price,
3780                    volume: tick.quantity,
3781                    trade_count: 1,
3782                    is_complete: false,
3783                    is_gap_fill: false,
3784                    vwap: Some(tick.price), // single-tick VWAP = price
3785                });
3786            }
3787        }
3788        self.bars_emitted += emitted.len() as u64;
3789        for b in &emitted {
3790            self.total_volume += b.volume;
3791            self.peak_volume = Some(match self.peak_volume {
3792                Some(prev) => prev.max(b.volume),
3793                None => b.volume,
3794            });
3795            self.min_volume = Some(match self.min_volume {
3796                Some(prev) => prev.min(b.volume),
3797                None => b.volume,
3798            });
3799        }
3800        if let Some(b) = emitted.last() {
3801            self.last_bar = Some(b.clone());
3802        }
3803        Ok(emitted)
3804    }
3805
3806    /// Current partial bar (if any).
3807    pub fn current_bar(&self) -> Option<&OhlcvBar> {
3808        self.current_bar.as_ref()
3809    }
3810
3811    /// Flush the current partial bar as complete.
3812    #[must_use = "the flushed bar is returned; ignoring it loses the partial bar"]
3813    pub fn flush(&mut self) -> Option<OhlcvBar> {
3814        let mut bar = self.current_bar.take()?;
3815        bar.is_complete = true;
3816        self.bars_emitted += 1;
3817        self.total_volume += bar.volume;
3818        self.peak_volume = Some(match self.peak_volume {
3819            Some(prev) => prev.max(bar.volume),
3820            None => bar.volume,
3821        });
3822        self.min_volume = Some(match self.min_volume {
3823            Some(prev) => prev.min(bar.volume),
3824            None => bar.volume,
3825        });
3826        self.last_bar = Some(bar.clone());
3827        Some(bar)
3828    }
3829
3830    /// The most recently completed bar emitted by [`feed`](Self::feed) or
3831    /// [`flush`](Self::flush). Returns `None` if no bar has been completed yet.
3832    ///
3833    /// Unlike [`current_bar`](Self::current_bar), this bar is always complete.
3834    pub fn last_bar(&self) -> Option<&OhlcvBar> {
3835        self.last_bar.as_ref()
3836    }
3837
3838    /// Total number of completed bars emitted by this aggregator (via `feed` or `flush`).
3839    pub fn bar_count(&self) -> u64 {
3840        self.bars_emitted
3841    }
3842
3843    /// Discard the in-progress bar and reset the bar counter to zero.
3844    ///
3845    /// Useful for backtesting rewind or when restarting aggregation from a
3846    /// new anchor point. Does not affect the aggregator's symbol or timeframe.
3847    pub fn reset(&mut self) {
3848        self.current_bar = None;
3849        self.last_bar = None;
3850        self.bars_emitted = 0;
3851        self.price_volume_sum = Decimal::ZERO;
3852        self.total_volume = Decimal::ZERO;
3853        self.peak_volume = None;
3854        self.min_volume = None;
3855    }
3856
3857    /// Cumulative traded volume across all completed bars emitted by this aggregator.
3858    ///
3859    /// Does not include the current partial bar's volume. Reset to zero by
3860    /// [`reset`](Self::reset).
3861    pub fn total_volume(&self) -> Decimal {
3862        self.total_volume
3863    }
3864
3865    /// Maximum single-bar volume seen across all completed bars.
3866    ///
3867    /// Returns `None` if no bars have been completed yet. Reset to `None` by
3868    /// [`reset`](Self::reset).
3869    pub fn peak_volume(&self) -> Option<Decimal> {
3870        self.peak_volume
3871    }
3872
3873    /// Minimum single-bar volume seen across all completed bars.
3874    ///
3875    /// Returns `None` if no bars have been completed yet. Reset to `None` by
3876    /// [`reset`](Self::reset).
3877    pub fn min_volume(&self) -> Option<Decimal> {
3878        self.min_volume
3879    }
3880
3881    /// Volume range across completed bars: `(min_volume, peak_volume)`.
3882    ///
3883    /// Returns `None` if no bars have been completed yet. Useful for
3884    /// normalizing volume signals to the observed range.
3885    pub fn volume_range(&self) -> Option<(Decimal, Decimal)> {
3886        Some((self.min_volume?, self.peak_volume?))
3887    }
3888
3889    /// Average volume per completed bar: `total_volume / bars_emitted`.
3890    ///
3891    /// Returns `None` if no bars have been completed yet (avoids division by zero).
3892    pub fn average_volume(&self) -> Option<Decimal> {
3893        if self.bars_emitted == 0 {
3894            return None;
3895        }
3896        Some(self.total_volume / Decimal::from(self.bars_emitted))
3897    }
3898
3899    /// The symbol this aggregator tracks.
3900    pub fn symbol(&self) -> &str {
3901        &self.symbol
3902    }
3903
3904    /// The timeframe used for bar alignment.
3905    pub fn timeframe(&self) -> Timeframe {
3906        self.timeframe
3907    }
3908
3909    /// Fraction of the current bar's time window that has elapsed, in `[0.0, 1.0]`.
3910    ///
3911    /// Returns `None` if no bar is in progress (no ticks seen since last
3912    /// flush/reset). `now_ms` should be ≥ the current bar's `bar_start_ms`;
3913    /// values before the start clamp to `0.0`.
3914    pub fn window_progress(&self, now_ms: u64) -> Option<f64> {
3915        let bar = self.current_bar.as_ref()?;
3916        let elapsed = now_ms.saturating_sub(bar.bar_start_ms);
3917        let duration = self.timeframe.duration_ms();
3918        let progress = elapsed as f64 / duration as f64;
3919        Some(progress.clamp(0.0, 1.0))
3920    }
3921
3922    /// Returns `true` if a bar is currently in progress (at least one tick has
3923    /// been fed since the last flush or reset).
3924    pub fn is_active(&self) -> bool {
3925        self.current_bar.is_some()
3926    }
3927
3928    /// Volume-weighted average price of the current in-progress bar.
3929    ///
3930    /// Returns `None` if no bar is currently being built or the bar has zero
3931    /// volume (should not happen with real ticks).
3932    pub fn vwap_current(&self) -> Option<Decimal> {
3933        let bar = self.current_bar.as_ref()?;
3934        if bar.volume.is_zero() {
3935            return None;
3936        }
3937        Some(self.price_volume_sum / bar.volume)
3938    }
3939}
3940
3941#[cfg(test)]
3942#[allow(deprecated)]
3943mod tests {
3944    use super::*;
3945    use crate::tick::{Exchange, NormalizedTick, TradeSide};
3946    use rust_decimal_macros::dec;
3947
3948    fn make_tick(symbol: &str, price: Decimal, qty: Decimal, ts_ms: u64) -> NormalizedTick {
3949        NormalizedTick {
3950            exchange: Exchange::Binance,
3951            symbol: symbol.to_string(),
3952            price,
3953            quantity: qty,
3954            side: Some(TradeSide::Buy),
3955            trade_id: None,
3956            exchange_ts_ms: None,
3957            received_at_ms: ts_ms,
3958        }
3959    }
3960
3961    fn make_tick_with_exchange_ts(
3962        symbol: &str,
3963        price: Decimal,
3964        qty: Decimal,
3965        exchange_ts_ms: u64,
3966        received_at_ms: u64,
3967    ) -> NormalizedTick {
3968        NormalizedTick {
3969            exchange: Exchange::Binance,
3970            symbol: symbol.to_string(),
3971            price,
3972            quantity: qty,
3973            side: Some(TradeSide::Buy),
3974            trade_id: None,
3975            exchange_ts_ms: Some(exchange_ts_ms),
3976            received_at_ms,
3977        }
3978    }
3979
3980    fn agg(symbol: &str, tf: Timeframe) -> OhlcvAggregator {
3981        OhlcvAggregator::new(symbol, tf).unwrap()
3982    }
3983
3984    #[test]
3985    fn test_timeframe_seconds_duration_ms() {
3986        assert_eq!(Timeframe::Seconds(30).duration_ms(), 30_000);
3987    }
3988
3989    #[test]
3990    fn test_timeframe_minutes_duration_ms() {
3991        assert_eq!(Timeframe::Minutes(5).duration_ms(), 300_000);
3992    }
3993
3994    #[test]
3995    fn test_timeframe_hours_duration_ms() {
3996        assert_eq!(Timeframe::Hours(1).duration_ms(), 3_600_000);
3997    }
3998
3999    #[test]
4000    fn test_timeframe_bar_start_ms_aligns() {
4001        let tf = Timeframe::Minutes(1);
4002        let ts = 61_500; // 1min 1.5sec
4003        assert_eq!(tf.bar_start_ms(ts), 60_000);
4004    }
4005
4006    #[test]
4007    fn test_timeframe_display() {
4008        assert_eq!(Timeframe::Seconds(30).to_string(), "30s");
4009        assert_eq!(Timeframe::Minutes(5).to_string(), "5m");
4010        assert_eq!(Timeframe::Hours(4).to_string(), "4h");
4011    }
4012
4013    #[test]
4014    fn test_timeframe_ord_seconds_lt_minutes() {
4015        assert!(Timeframe::Seconds(30) < Timeframe::Minutes(1));
4016    }
4017
4018    #[test]
4019    fn test_timeframe_ord_minutes_lt_hours() {
4020        assert!(Timeframe::Minutes(59) < Timeframe::Hours(1));
4021    }
4022
4023    #[test]
4024    fn test_timeframe_ord_same_duration_equal() {
4025        assert_eq!(Timeframe::Seconds(60), Timeframe::Seconds(60));
4026        assert_eq!(
4027            Timeframe::Seconds(3600).cmp(&Timeframe::Hours(1)),
4028            std::cmp::Ordering::Equal
4029        );
4030    }
4031
4032    #[test]
4033    fn test_timeframe_ord_sort() {
4034        let mut tfs = vec![
4035            Timeframe::Hours(1),
4036            Timeframe::Seconds(30),
4037            Timeframe::Minutes(5),
4038        ];
4039        tfs.sort();
4040        assert_eq!(tfs[0], Timeframe::Seconds(30));
4041        assert_eq!(tfs[1], Timeframe::Minutes(5));
4042        assert_eq!(tfs[2], Timeframe::Hours(1));
4043    }
4044
4045    #[test]
4046    fn test_ohlcv_aggregator_first_tick_sets_ohlcv() {
4047        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4048        let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 60_000);
4049        let result = agg.feed(&tick).unwrap();
4050        assert!(result.is_empty()); // no completed bar yet
4051        let bar = agg.current_bar().unwrap();
4052        assert_eq!(bar.open, dec!(50000));
4053        assert_eq!(bar.high, dec!(50000));
4054        assert_eq!(bar.low, dec!(50000));
4055        assert_eq!(bar.close, dec!(50000));
4056        assert_eq!(bar.volume, dec!(1));
4057        assert_eq!(bar.trade_count, 1);
4058    }
4059
4060    #[test]
4061    fn test_ohlcv_aggregator_high_low_tracking() {
4062        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4063        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4064            .unwrap();
4065        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
4066            .unwrap();
4067        agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
4068            .unwrap();
4069        let bar = agg.current_bar().unwrap();
4070        assert_eq!(bar.high, dec!(51000));
4071        assert_eq!(bar.low, dec!(49500));
4072        assert_eq!(bar.close, dec!(49500));
4073        assert_eq!(bar.trade_count, 3);
4074    }
4075
4076    #[test]
4077    fn test_ohlcv_aggregator_bar_completes_on_new_window() {
4078        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4079        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4080            .unwrap();
4081        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2), 60_500))
4082            .unwrap();
4083        // Tick in next minute window closes previous bar
4084        let mut bars = agg
4085            .feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
4086            .unwrap();
4087        assert_eq!(bars.len(), 1);
4088        let bar = bars.remove(0);
4089        assert!(bar.is_complete);
4090        assert_eq!(bar.open, dec!(50000));
4091        assert_eq!(bar.close, dec!(50100));
4092        assert_eq!(bar.volume, dec!(3));
4093        assert_eq!(bar.bar_start_ms, 60_000);
4094    }
4095
4096    #[test]
4097    fn test_ohlcv_aggregator_new_bar_started_after_completion() {
4098        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4099        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4100            .unwrap();
4101        agg.feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
4102            .unwrap();
4103        let bar = agg.current_bar().unwrap();
4104        assert_eq!(bar.open, dec!(50200));
4105        assert_eq!(bar.bar_start_ms, 120_000);
4106    }
4107
4108    #[test]
4109    fn test_ohlcv_aggregator_flush_marks_complete() {
4110        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4111        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4112            .unwrap();
4113        let flushed = agg.flush().unwrap();
4114        assert!(flushed.is_complete);
4115        assert!(agg.current_bar().is_none());
4116    }
4117
4118    #[test]
4119    fn test_ohlcv_aggregator_flush_empty_returns_none() {
4120        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4121        assert!(agg.flush().is_none());
4122    }
4123
4124    #[test]
4125    fn test_ohlcv_aggregator_wrong_symbol_returns_error() {
4126        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4127        let tick = make_tick("ETH-USD", dec!(3000), dec!(1), 60_000);
4128        let result = agg.feed(&tick);
4129        assert!(matches!(result, Err(StreamError::AggregationError { .. })));
4130    }
4131
4132    #[test]
4133    fn test_ohlcv_aggregator_volume_accumulates() {
4134        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4135        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1.5), 60_000))
4136            .unwrap();
4137        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2.5), 60_100))
4138            .unwrap();
4139        let bar = agg.current_bar().unwrap();
4140        assert_eq!(bar.volume, dec!(4));
4141    }
4142
4143    #[test]
4144    fn test_ohlcv_bar_symbol_and_timeframe() {
4145        let mut agg = agg("BTC-USD", Timeframe::Minutes(5));
4146        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 300_000))
4147            .unwrap();
4148        let bar = agg.current_bar().unwrap();
4149        assert_eq!(bar.symbol, "BTC-USD");
4150        assert_eq!(bar.timeframe, Timeframe::Minutes(5));
4151    }
4152
4153    #[test]
4154    fn test_ohlcv_aggregator_symbol_accessor() {
4155        let agg = agg("ETH-USD", Timeframe::Hours(1));
4156        assert_eq!(agg.symbol(), "ETH-USD");
4157        assert_eq!(agg.timeframe(), Timeframe::Hours(1));
4158    }
4159
4160    #[test]
4161    fn test_bar_aligned_by_exchange_ts_not_received_ts() {
4162        // exchange_ts_ms puts tick in minute 1 (60_000..120_000)
4163        // received_at_ms puts tick in minute 2 (120_000..180_000) due to latency
4164        // Bar should use the exchange timestamp.
4165        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4166        let tick = make_tick_with_exchange_ts("BTC-USD", dec!(50000), dec!(1), 60_500, 120_100);
4167        agg.feed(&tick).unwrap();
4168        let bar = agg.current_bar().unwrap();
4169        assert_eq!(bar.bar_start_ms, 60_000, "bar should use exchange_ts_ms");
4170    }
4171
4172    #[test]
4173    fn test_bar_falls_back_to_received_ts_when_no_exchange_ts() {
4174        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4175        let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 75_000);
4176        agg.feed(&tick).unwrap();
4177        let bar = agg.current_bar().unwrap();
4178        assert_eq!(bar.bar_start_ms, 60_000);
4179    }
4180
4181    // --- emit_empty_bars tests ---
4182
4183    #[test]
4184    fn test_emit_empty_bars_no_gap_no_empties() {
4185        // Consecutive bars — no gap — should not produce empty bars.
4186        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
4187            .unwrap()
4188            .with_emit_empty_bars(true);
4189        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4190            .unwrap();
4191        let bars = agg
4192            .feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
4193            .unwrap();
4194        // Only the completed bar for the first minute; no empties.
4195        assert_eq!(bars.len(), 1);
4196        assert_eq!(bars[0].bar_start_ms, 60_000);
4197        assert_eq!(bars[0].volume, dec!(1));
4198    }
4199
4200    #[test]
4201    fn test_emit_empty_bars_two_skipped_windows() {
4202        // Gap of 3 minutes: complete bar at 60s, then two empty bars at 120s and 180s,
4203        // then the 240s tick starts a new bar.
4204        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
4205            .unwrap()
4206            .with_emit_empty_bars(true);
4207        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4208            .unwrap();
4209        let bars = agg
4210            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
4211            .unwrap();
4212        // 1 real completed bar + 2 empty gap bars (120_000, 180_000)
4213        assert_eq!(bars.len(), 3);
4214        assert_eq!(bars[0].bar_start_ms, 60_000);
4215        assert!(!bars[0].volume.is_zero()); // real bar
4216        assert_eq!(bars[1].bar_start_ms, 120_000);
4217        assert!(bars[1].volume.is_zero()); // empty
4218        assert_eq!(bars[1].trade_count, 0);
4219        assert_eq!(bars[1].open, dec!(50000)); // last close carried forward
4220        assert_eq!(bars[2].bar_start_ms, 180_000);
4221        assert!(bars[2].volume.is_zero()); // empty
4222    }
4223
4224    #[test]
4225    fn test_emit_empty_bars_disabled_no_empties_on_gap() {
4226        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
4227            .unwrap()
4228            .with_emit_empty_bars(false);
4229        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4230            .unwrap();
4231        let bars = agg
4232            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
4233            .unwrap();
4234        assert_eq!(bars.len(), 1); // only real completed bar, no empties
4235    }
4236
4237    #[test]
4238    fn test_emit_empty_bars_is_complete_true() {
4239        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
4240            .unwrap()
4241            .with_emit_empty_bars(true);
4242        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4243            .unwrap();
4244        let bars = agg
4245            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
4246            .unwrap();
4247        for bar in &bars {
4248            assert!(bar.is_complete, "all emitted bars must be marked complete");
4249        }
4250    }
4251
4252    #[test]
4253    fn test_ohlcv_bar_display() {
4254        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4255        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4256            .unwrap();
4257        let bar = agg.current_bar().unwrap();
4258        let s = bar.to_string();
4259        assert!(s.contains("BTC-USD"));
4260        assert!(s.contains("1m"));
4261        assert!(s.contains("50000"));
4262    }
4263
4264    #[test]
4265    fn test_bar_count_increments_on_feed() {
4266        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4267        assert_eq!(agg.bar_count(), 0);
4268        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4269            .unwrap();
4270        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
4271            .unwrap();
4272        assert_eq!(agg.bar_count(), 1);
4273    }
4274
4275    #[test]
4276    fn test_bar_count_increments_on_flush() {
4277        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4278        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4279            .unwrap();
4280        agg.flush().unwrap();
4281        assert_eq!(agg.bar_count(), 1);
4282    }
4283
4284    #[test]
4285    fn test_ohlcv_bar_range() {
4286        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4287        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4288            .unwrap();
4289        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
4290            .unwrap();
4291        agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
4292            .unwrap();
4293        let bar = agg.current_bar().unwrap();
4294        assert_eq!(bar.range(), dec!(1500)); // 51000 - 49500
4295    }
4296
4297    #[test]
4298    fn test_ohlcv_bar_body_bullish() {
4299        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4300        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4301            .unwrap();
4302        agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_100))
4303            .unwrap();
4304        let bar = agg.current_bar().unwrap();
4305        // open=50000, close=50500 → body = 500
4306        assert_eq!(bar.body(), dec!(500));
4307    }
4308
4309    #[test]
4310    fn test_ohlcv_bar_body_bearish() {
4311        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4312        agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_000))
4313            .unwrap();
4314        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
4315            .unwrap();
4316        let bar = agg.current_bar().unwrap();
4317        // open=50500, close=50000 → body = 500 (abs)
4318        assert_eq!(bar.body(), dec!(500));
4319    }
4320
4321    #[test]
4322    fn test_aggregator_reset_clears_bar_and_count() {
4323        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4324        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4325            .unwrap();
4326        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
4327            .unwrap();
4328        assert_eq!(agg.bar_count(), 1);
4329        assert!(agg.current_bar().is_some());
4330        agg.reset();
4331        assert_eq!(agg.bar_count(), 0);
4332        assert!(agg.current_bar().is_none());
4333    }
4334
4335    #[test]
4336    fn test_ohlcv_bar_is_bullish_when_close_gt_open() {
4337        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4338        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4339            .unwrap();
4340        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
4341            .unwrap();
4342        let bar = agg.current_bar().unwrap();
4343        assert!(bar.is_bullish());
4344        assert!(!bar.is_bearish());
4345    }
4346
4347    #[test]
4348    fn test_ohlcv_bar_is_bearish_when_close_lt_open() {
4349        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4350        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_000))
4351            .unwrap();
4352        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
4353            .unwrap();
4354        let bar = agg.current_bar().unwrap();
4355        assert!(bar.is_bearish());
4356        assert!(!bar.is_bullish());
4357    }
4358
4359    #[test]
4360    fn test_ohlcv_bar_neither_bullish_nor_bearish_on_equal_open_close() {
4361        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4362        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4363            .unwrap();
4364        // Single tick: open == close
4365        let bar = agg.current_bar().unwrap();
4366        assert!(!bar.is_bullish());
4367        assert!(!bar.is_bearish());
4368    }
4369
4370    #[test]
4371    fn test_ohlcv_bar_vwap_single_tick_equals_price() {
4372        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4373        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(2), 60_000))
4374            .unwrap();
4375        let bar = agg.current_bar().unwrap();
4376        assert_eq!(bar.vwap, Some(dec!(50000)));
4377    }
4378
4379    #[test]
4380    fn test_ohlcv_bar_vwap_two_equal_price_ticks() {
4381        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4382        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4383            .unwrap();
4384        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(3), 60_100))
4385            .unwrap();
4386        let bar = agg.current_bar().unwrap();
4387        // vwap = (50000*1 + 50000*3) / (1+3) = 50000
4388        assert_eq!(bar.vwap, Some(dec!(50000)));
4389    }
4390
4391    #[test]
4392    fn test_ohlcv_bar_vwap_two_different_price_ticks() {
4393        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4394        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4395            .unwrap();
4396        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
4397            .unwrap();
4398        let bar = agg.current_bar().unwrap();
4399        // vwap = (50000*1 + 51000*1) / (1+1) = 50500
4400        assert_eq!(bar.vwap, Some(dec!(50500)));
4401    }
4402
4403    #[test]
4404    fn test_ohlcv_bar_vwap_gap_fill_is_none() {
4405        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
4406            .unwrap()
4407            .with_emit_empty_bars(true);
4408        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4409            .unwrap();
4410        let bars = agg
4411            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
4412            .unwrap();
4413        // bars[0] = real, bars[1] and bars[2] = gap-fills
4414        assert!(bars[0].vwap.is_some());
4415        assert!(bars[1].vwap.is_none());
4416        assert!(bars[2].vwap.is_none());
4417    }
4418
4419    #[test]
4420    fn test_aggregator_reset_allows_fresh_start() {
4421        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4422        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
4423            .unwrap();
4424        agg.reset();
4425        agg.feed(&make_tick("BTC-USD", dec!(99999), dec!(2), 60_000))
4426            .unwrap();
4427        let bar = agg.current_bar().unwrap();
4428        assert_eq!(bar.open, dec!(99999));
4429    }
4430
4431    // ── Timeframe::from_duration_ms ───────────────────────────────────────────
4432
4433    #[test]
4434    fn test_from_duration_ms_hours() {
4435        assert_eq!(Timeframe::from_duration_ms(3_600_000), Some(Timeframe::Hours(1)));
4436        assert_eq!(Timeframe::from_duration_ms(7_200_000), Some(Timeframe::Hours(2)));
4437    }
4438
4439    #[test]
4440    fn test_from_duration_ms_minutes() {
4441        assert_eq!(Timeframe::from_duration_ms(300_000), Some(Timeframe::Minutes(5)));
4442        assert_eq!(Timeframe::from_duration_ms(60_000), Some(Timeframe::Minutes(1)));
4443    }
4444
4445    #[test]
4446    fn test_from_duration_ms_seconds() {
4447        assert_eq!(Timeframe::from_duration_ms(15_000), Some(Timeframe::Seconds(15)));
4448        assert_eq!(Timeframe::from_duration_ms(1_000), Some(Timeframe::Seconds(1)));
4449    }
4450
4451    #[test]
4452    fn test_from_duration_ms_zero_returns_none() {
4453        assert_eq!(Timeframe::from_duration_ms(0), None);
4454    }
4455
4456    #[test]
4457    fn test_from_duration_ms_non_whole_second_returns_none() {
4458        assert_eq!(Timeframe::from_duration_ms(1_500), None);
4459    }
4460
4461    #[test]
4462    fn test_from_duration_ms_roundtrip() {
4463        for tf in [Timeframe::Seconds(30), Timeframe::Minutes(5), Timeframe::Hours(4)] {
4464            assert_eq!(Timeframe::from_duration_ms(tf.duration_ms()), Some(tf));
4465        }
4466    }
4467
4468    // ── OhlcvBar::is_doji / wick_upper / wick_lower ──────────────────────────
4469
4470    #[test]
4471    fn test_is_doji_exact_zero_body() {
4472        let bar = OhlcvBar {
4473            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
4474            bar_start_ms: 0, open: dec!(100), high: dec!(105),
4475            low: dec!(95), close: dec!(100),
4476            volume: dec!(1), trade_count: 1, is_complete: true,
4477            is_gap_fill: false, vwap: None,
4478        };
4479        assert!(bar.is_doji(Decimal::ZERO));
4480    }
4481
4482    #[test]
4483    fn test_is_doji_small_epsilon() {
4484        let bar = OhlcvBar {
4485            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
4486            bar_start_ms: 0, open: dec!(100), high: dec!(105),
4487            low: dec!(95), close: dec!(100.005),
4488            volume: dec!(1), trade_count: 1, is_complete: true,
4489            is_gap_fill: false, vwap: None,
4490        };
4491        assert!(bar.is_doji(dec!(0.01)));
4492        assert!(!bar.is_doji(Decimal::ZERO));
4493    }
4494
4495    #[test]
4496    fn test_wick_upper_bullish() {
4497        // open=100, close=104, high=107 → upper wick = 107 - 104 = 3
4498        let bar = OhlcvBar {
4499            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
4500            bar_start_ms: 0, open: dec!(100), high: dec!(107),
4501            low: dec!(98), close: dec!(104),
4502            volume: dec!(1), trade_count: 1, is_complete: true,
4503            is_gap_fill: false, vwap: None,
4504        };
4505        assert_eq!(bar.wick_upper(), dec!(3));
4506    }
4507
4508    #[test]
4509    fn test_wick_lower_bearish() {
4510        // open=104, close=100, low=97 → lower wick = 100 - 97 = 3
4511        let bar = OhlcvBar {
4512            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
4513            bar_start_ms: 0, open: dec!(104), high: dec!(107),
4514            low: dec!(97), close: dec!(100),
4515            volume: dec!(1), trade_count: 1, is_complete: true,
4516            is_gap_fill: false, vwap: None,
4517        };
4518        assert_eq!(bar.wick_lower(), dec!(3));
4519    }
4520
4521    // ── OhlcvAggregator::window_progress ─────────────────────────────────────
4522
4523    #[test]
4524    fn test_window_progress_none_when_no_bar() {
4525        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4526        assert!(agg.window_progress(60_000).is_none());
4527    }
4528
4529    #[test]
4530    fn test_window_progress_at_start_is_zero() {
4531        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4532        // Tick at bar start.
4533        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
4534        assert_eq!(agg.window_progress(60_000), Some(0.0));
4535    }
4536
4537    #[test]
4538    fn test_window_progress_midpoint() {
4539        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4540        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
4541        // 30 s into a 60 s bar → 0.5
4542        let progress = agg.window_progress(90_000).unwrap();
4543        assert!((progress - 0.5).abs() < 1e-9);
4544    }
4545
4546    #[test]
4547    fn test_window_progress_clamps_at_one() {
4548        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4549        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
4550        // 90 s past the bar start (longer than the bar) → clamped to 1.0
4551        assert_eq!(agg.window_progress(150_000), Some(1.0));
4552    }
4553
4554    // ── OhlcvBar::price_change ────────────────────────────────────────────────
4555
4556    #[test]
4557    fn test_price_change_bullish_is_positive() {
4558        let bar = make_bar(dec!(100), dec!(110), dec!(98), dec!(105));
4559        assert_eq!(bar.price_change(), dec!(5));
4560    }
4561
4562    #[test]
4563    fn test_price_change_bearish_is_negative() {
4564        let bar = make_bar(dec!(105), dec!(110), dec!(98), dec!(100));
4565        assert_eq!(bar.price_change(), dec!(-5));
4566    }
4567
4568    #[test]
4569    fn test_price_change_doji_is_zero() {
4570        let bar = make_bar(dec!(100), dec!(102), dec!(98), dec!(100));
4571        assert_eq!(bar.price_change(), dec!(0));
4572    }
4573
4574    // ── OhlcvAggregator::total_volume ─────────────────────────────────────────
4575
4576    #[test]
4577    fn test_total_volume_zero_before_completion() {
4578        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4579        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
4580        // Bar not yet complete; total_volume should be zero
4581        assert_eq!(agg.total_volume(), dec!(0));
4582    }
4583
4584    #[test]
4585    fn test_total_volume_accumulates_across_bars() {
4586        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4587        // Bar 1: volume = 2
4588        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
4589        // Trigger completion of bar 1
4590        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
4591        // Bar 1 completed with volume 2. Bar 2 in progress with volume 3 (not counted).
4592        assert_eq!(agg.total_volume(), dec!(2));
4593        // Trigger completion of bar 2
4594        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
4595        assert_eq!(agg.total_volume(), dec!(5)); // 2 + 3
4596    }
4597
4598    #[test]
4599    fn test_total_volume_reset_clears() {
4600        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4601        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
4602        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
4603        agg.reset();
4604        assert_eq!(agg.total_volume(), dec!(0));
4605    }
4606
4607    // ── OhlcvBar::typical_price / median_price ────────────────────────────────
4608
4609    fn make_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
4610        OhlcvBar {
4611            symbol: "X".into(),
4612            timeframe: Timeframe::Minutes(1),
4613            bar_start_ms: 0,
4614            open,
4615            high,
4616            low,
4617            close,
4618            volume: dec!(1),
4619            trade_count: 1,
4620            is_complete: true,
4621            is_gap_fill: false,
4622            vwap: None,
4623        }
4624    }
4625
4626    #[test]
4627    fn test_typical_price() {
4628        // high=12, low=8, close=10 → (12+8+10)/3 = 10
4629        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
4630        assert_eq!(bar.typical_price(), dec!(10));
4631    }
4632
4633    #[test]
4634    fn test_median_price() {
4635        // high=12, low=8 → (12+8)/2 = 10
4636        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
4637        assert_eq!(bar.median_price(), dec!(10));
4638    }
4639
4640    #[test]
4641    fn test_typical_price_differs_from_median() {
4642        // high=10, low=6, close=10 → typical=(10+6+10)/3 = 26/3, median=(10+6)/2 = 8
4643        let bar = make_bar(dec!(8), dec!(10), dec!(6), dec!(10));
4644        assert_eq!(bar.median_price(), dec!(8));
4645        assert!(bar.typical_price() > bar.median_price());
4646    }
4647
4648    #[test]
4649    fn test_close_location_value_at_high() {
4650        // close == high → CLV = (high - low - 0) / range = 1.0
4651        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(110));
4652        let clv = bar.close_location_value().unwrap();
4653        assert!((clv - 1.0).abs() < 1e-9, "expected 1.0 got {clv}");
4654    }
4655
4656    #[test]
4657    fn test_close_location_value_at_low() {
4658        // close == low → CLV = (low - low - (high - low)) / range = -range/range = -1.0
4659        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(90));
4660        let clv = bar.close_location_value().unwrap();
4661        assert!((clv + 1.0).abs() < 1e-9, "expected -1.0 got {clv}");
4662    }
4663
4664    #[test]
4665    fn test_close_location_value_midpoint_is_zero() {
4666        // close == (high + low) / 2 → CLV = 0.0
4667        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(100));
4668        let clv = bar.close_location_value().unwrap();
4669        assert!(clv.abs() < 1e-9, "expected 0.0 got {clv}");
4670    }
4671
4672    #[test]
4673    fn test_close_location_value_zero_range_returns_none() {
4674        let bar = make_bar(dec!(100), dec!(100), dec!(100), dec!(100));
4675        assert!(bar.close_location_value().is_none());
4676    }
4677
4678    #[test]
4679    fn test_body_direction_bullish() {
4680        let bar = make_bar(dec!(90), dec!(110), dec!(85), dec!(105));
4681        assert_eq!(bar.body_direction(), BarDirection::Bullish);
4682    }
4683
4684    #[test]
4685    fn test_body_direction_bearish() {
4686        let bar = make_bar(dec!(105), dec!(110), dec!(85), dec!(90));
4687        assert_eq!(bar.body_direction(), BarDirection::Bearish);
4688    }
4689
4690    #[test]
4691    fn test_body_direction_neutral() {
4692        let bar = make_bar(dec!(100), dec!(110), dec!(85), dec!(100));
4693        assert_eq!(bar.body_direction(), BarDirection::Neutral);
4694    }
4695
4696    // ── OhlcvAggregator::last_bar ─────────────────────────────────────────────
4697
4698    #[test]
4699    fn test_last_bar_none_before_completion() {
4700        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4701        assert!(agg.last_bar().is_none());
4702    }
4703
4704    #[test]
4705    fn test_last_bar_set_after_bar_completion() {
4706        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4707        // First bar in window [60000, 120000)
4708        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
4709        // Second tick in next window completes the first bar
4710        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
4711        let last = agg.last_bar().unwrap();
4712        assert!(last.is_complete);
4713        assert_eq!(last.close, dec!(100));
4714    }
4715
4716    #[test]
4717    fn test_last_bar_set_after_flush() {
4718        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4719        agg.feed(&make_tick("BTC-USD", dec!(50), dec!(1), 60_000)).unwrap();
4720        let flushed = agg.flush().unwrap();
4721        assert_eq!(agg.last_bar().unwrap().close, flushed.close);
4722    }
4723
4724    #[test]
4725    fn test_last_bar_cleared_on_reset() {
4726        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4727        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
4728        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
4729        assert!(agg.last_bar().is_some());
4730        agg.reset();
4731        assert!(agg.last_bar().is_none());
4732    }
4733
4734    // ── OhlcvBar::weighted_close / price_change_pct / wick_ratio ─────────────
4735
4736    #[test]
4737    fn test_weighted_close_basic() {
4738        // (high + low + close*2) / 4 = (12 + 8 + 10*2) / 4 = 40/4 = 10
4739        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
4740        assert_eq!(bar.weighted_close(), dec!(10));
4741    }
4742
4743    #[test]
4744    fn test_weighted_close_weights_close_more_than_typical() {
4745        // high=100, low=0, close=80 → typical=(100+0+80)/3≈60, weighted=(100+0+80+80)/4=65
4746        let bar = make_bar(dec!(50), dec!(100), dec!(0), dec!(80));
4747        assert_eq!(bar.weighted_close(), dec!(65));
4748    }
4749
4750    #[test]
4751    fn test_price_change_pct_bullish() {
4752        // open=100, close=110 → +10%
4753        let bar = make_bar(dec!(100), dec!(115), dec!(95), dec!(110));
4754        let pct = bar.price_change_pct().unwrap();
4755        assert!((pct - 10.0).abs() < 1e-9, "expected 10.0 got {pct}");
4756    }
4757
4758    #[test]
4759    fn test_price_change_pct_bearish() {
4760        // open=200, close=180 → -10%
4761        let bar = make_bar(dec!(200), dec!(210), dec!(175), dec!(180));
4762        let pct = bar.price_change_pct().unwrap();
4763        assert!((pct - (-10.0)).abs() < 1e-9, "expected -10.0 got {pct}");
4764    }
4765
4766    #[test]
4767    fn test_price_change_pct_zero_open_returns_none() {
4768        let bar = make_bar(dec!(0), dec!(5), dec!(0), dec!(3));
4769        assert!(bar.price_change_pct().is_none());
4770    }
4771
4772    #[test]
4773    fn test_wick_ratio_all_wicks() {
4774        // open=close=5, high=10, low=0 → body=0, wicks=5+5=10, range=10 → ratio=1.0
4775        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
4776        let r = bar.wick_ratio().unwrap();
4777        assert!((r - 1.0).abs() < 1e-9, "expected 1.0 got {r}");
4778    }
4779
4780    #[test]
4781    fn test_wick_ratio_no_wicks() {
4782        // open=low=0, close=high=10 → body=10, wicks=0, range=10 → ratio=0.0
4783        let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
4784        let r = bar.wick_ratio().unwrap();
4785        assert!((r - 0.0).abs() < 1e-9, "expected 0.0 got {r}");
4786    }
4787
4788    #[test]
4789    fn test_wick_ratio_zero_range_returns_none() {
4790        // all prices identical → range=0
4791        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
4792        assert!(bar.wick_ratio().is_none());
4793    }
4794
4795    // ── OhlcvBar::body_ratio ──────────────────────────────────────────────────
4796
4797    #[test]
4798    fn test_body_ratio_no_wicks_is_one() {
4799        // open=low=0, close=high=10 → body=10, range=10 → ratio=1.0
4800        let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
4801        let r = bar.body_ratio().unwrap();
4802        assert!((r - 1.0).abs() < 1e-9);
4803    }
4804
4805    #[test]
4806    fn test_body_ratio_all_wicks_is_zero() {
4807        // doji: open=close=5, high=10, low=0 → body=0, range=10 → ratio=0.0
4808        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
4809        let r = bar.body_ratio().unwrap();
4810        assert!((r - 0.0).abs() < 1e-9);
4811    }
4812
4813    #[test]
4814    fn test_body_ratio_zero_range_returns_none() {
4815        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
4816        assert!(bar.body_ratio().is_none());
4817    }
4818
4819    #[test]
4820    fn test_body_ratio_plus_wick_ratio_equals_one() {
4821        // body + wicks = range → ratios sum to 1
4822        let bar = make_bar(dec!(4), dec!(10), dec!(0), dec!(8));
4823        let body = bar.body_ratio().unwrap();
4824        let wick = bar.wick_ratio().unwrap();
4825        assert!((body + wick - 1.0).abs() < 1e-9);
4826    }
4827
4828    // ── OhlcvAggregator::average_volume ──────────────────────────────────────
4829
4830    #[test]
4831    fn test_average_volume_none_before_bars() {
4832        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4833        assert!(agg.average_volume().is_none());
4834    }
4835
4836    #[test]
4837    fn test_average_volume_one_bar() {
4838        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4839        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
4840        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
4841        // bar 1 complete with volume 4; bar 2 in progress, not counted
4842        assert_eq!(agg.average_volume(), Some(dec!(4)));
4843    }
4844
4845    #[test]
4846    fn test_average_volume_two_bars() {
4847        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4848        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
4849        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(6), 120_000)).unwrap();
4850        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
4851        // bar 1 vol=4, bar 2 vol=6 → avg=5
4852        assert_eq!(agg.average_volume(), Some(dec!(5)));
4853    }
4854
4855    // ── OhlcvBar::true_range / inside_bar / outside_bar ──────────────────────
4856
4857    #[test]
4858    fn test_true_range_no_gap() {
4859        // high=12, low=8, prev_close=10 → HL=4, H-prev=2, L-prev=2 → TR=4
4860        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
4861        assert_eq!(bar.true_range(dec!(10)), dec!(4));
4862    }
4863
4864    #[test]
4865    fn test_true_range_gap_up() {
4866        // high=15, low=12, prev_close=10 → HL=3, H-prev=5, L-prev=2 → TR=5
4867        let bar = make_bar(dec!(12), dec!(15), dec!(12), dec!(13));
4868        assert_eq!(bar.true_range(dec!(10)), dec!(5));
4869    }
4870
4871    #[test]
4872    fn test_true_range_gap_down() {
4873        // high=8, low=5, prev_close=12 → HL=3, H-prev=4, L-prev=7 → TR=7
4874        let bar = make_bar(dec!(7), dec!(8), dec!(5), dec!(6));
4875        assert_eq!(bar.true_range(dec!(12)), dec!(7));
4876    }
4877
4878    #[test]
4879    fn test_inside_bar_true_when_contained() {
4880        let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
4881        let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
4882        assert!(curr.is_inside_bar(&prev));
4883    }
4884
4885    #[test]
4886    fn test_inside_bar_false_when_not_contained() {
4887        let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
4888        let curr = make_bar(dec!(10), dec!(16), dec!(6), dec!(11));
4889        assert!(!curr.is_inside_bar(&prev));
4890    }
4891
4892    #[test]
4893    fn test_outside_bar_true_when_engulfing() {
4894        let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
4895        let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
4896        assert!(curr.outside_bar(&prev));
4897    }
4898
4899    #[test]
4900    fn test_outside_bar_false_when_not_engulfing() {
4901        let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
4902        let curr = make_bar(dec!(10), dec!(11), dec!(9), dec!(10));
4903        assert!(!curr.outside_bar(&prev));
4904    }
4905
4906    // ── OhlcvBar::is_hammer ───────────────────────────────────────────────────
4907
4908    #[test]
4909    fn test_is_hammer_classic() {
4910        // open=9, high=10, low=0, close=9 → body=0, wick_lo=9, wick_hi=1, range=10
4911        // body=0 ≤ 30%, wick_lo=9 ≥ 60%, wick_hi=1 ≤ 10% → hammer
4912        let bar = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
4913        assert!(bar.is_hammer());
4914    }
4915
4916    #[test]
4917    fn test_is_hammer_false_large_upper_wick() {
4918        // open=5, high=10, low=0, close=5 → body=0, wick_hi=5 (50%) → not hammer
4919        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
4920        assert!(!bar.is_hammer());
4921    }
4922
4923    #[test]
4924    fn test_is_hammer_false_zero_range() {
4925        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
4926        assert!(!bar.is_hammer());
4927    }
4928
4929    // ── OhlcvAggregator::peak_volume ─────────────────────────────────────────
4930
4931    #[test]
4932    fn test_peak_volume_none_before_completion() {
4933        let agg = agg("BTC-USD", Timeframe::Minutes(1));
4934        assert!(agg.peak_volume().is_none());
4935    }
4936
4937    #[test]
4938    fn test_peak_volume_tracks_maximum() {
4939        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4940        // Bar 1: vol=3
4941        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
4942        // Trigger bar 1 completion; bar 2 vol=10 in progress
4943        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
4944        assert_eq!(agg.peak_volume(), Some(dec!(3)));
4945        // Trigger bar 2 completion
4946        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
4947        assert_eq!(agg.peak_volume(), Some(dec!(10)));
4948    }
4949
4950    #[test]
4951    fn test_peak_volume_reset_clears() {
4952        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4953        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
4954        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
4955        agg.reset();
4956        assert!(agg.peak_volume().is_none());
4957    }
4958
4959    #[test]
4960    fn test_peak_volume_via_flush() {
4961        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
4962        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(7), 60_000)).unwrap();
4963        agg.flush();
4964        assert_eq!(agg.peak_volume(), Some(dec!(7)));
4965    }
4966
4967    // ── OhlcvBar::is_shooting_star ────────────────────────────────────────────
4968
4969    #[test]
4970    fn test_is_shooting_star_classic() {
4971        // open=1, high=10, low=0, close=1 → body=0, wick_hi=9, wick_lo=1, range=10
4972        // body≤30%, wick_hi=9≥60%, wick_lo=1≤10% → shooting star
4973        let bar = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
4974        assert!(bar.is_shooting_star());
4975    }
4976
4977    #[test]
4978    fn test_is_shooting_star_false_large_lower_wick() {
4979        // open=5, high=10, low=0, close=5 → lower wick = 5 (50%) → not shooting star
4980        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
4981        assert!(!bar.is_shooting_star());
4982    }
4983
4984    #[test]
4985    fn test_is_shooting_star_false_zero_range() {
4986        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
4987        assert!(!bar.is_shooting_star());
4988    }
4989
4990    #[test]
4991    fn test_hammer_and_shooting_star_are_mutually_exclusive_for_typical_bars() {
4992        // Classic hammer: long lower wick
4993        let hammer = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
4994        // Classic shooting star: long upper wick
4995        let star = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
4996        assert!(hammer.is_hammer() && !hammer.is_shooting_star());
4997        assert!(star.is_shooting_star() && !star.is_hammer());
4998    }
4999
5000    // ── OhlcvAggregator::min_volume ───────────────────────────────────────────
5001
5002    #[test]
5003    fn test_min_volume_none_before_completion() {
5004        let agg = agg("BTC-USD", Timeframe::Minutes(1));
5005        assert!(agg.min_volume().is_none());
5006    }
5007
5008    #[test]
5009    fn test_min_volume_tracks_minimum() {
5010        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5011        // Bar 1: vol=10
5012        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(10), 60_000)).unwrap();
5013        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
5014        assert_eq!(agg.min_volume(), Some(dec!(10)));
5015        // Bar 2: vol=1 — should update minimum
5016        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
5017        assert_eq!(agg.min_volume(), Some(dec!(1)));
5018    }
5019
5020    #[test]
5021    fn test_min_volume_reset_clears() {
5022        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5023        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
5024        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
5025        agg.reset();
5026        assert!(agg.min_volume().is_none());
5027    }
5028
5029    // ── OhlcvBar::is_gap_up / is_gap_down ────────────────────────────────────
5030
5031    #[test]
5032    fn test_is_gap_up_true() {
5033        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
5034        let curr = make_bar(dec!(9), dec!(12), dec!(8), dec!(11)); // open=9 > prev.close=8
5035        assert!(curr.is_gap_up(&prev));
5036    }
5037
5038    #[test]
5039    fn test_is_gap_up_false_when_equal() {
5040        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
5041        let curr = make_bar(dec!(8), dec!(12), dec!(7), dec!(11)); // open=8 == prev.close=8
5042        assert!(!curr.is_gap_up(&prev));
5043    }
5044
5045    #[test]
5046    fn test_is_gap_down_true() {
5047        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
5048        let curr = make_bar(dec!(7), dec!(8), dec!(6), dec!(7)); // open=7 < prev.close=8
5049        assert!(curr.is_gap_down(&prev));
5050    }
5051
5052    #[test]
5053    fn test_is_gap_down_false_when_equal() {
5054        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
5055        let curr = make_bar(dec!(8), dec!(9), dec!(7), dec!(8)); // open=8 == prev.close=8
5056        assert!(!curr.is_gap_down(&prev));
5057    }
5058
5059    // ── OhlcvAggregator::volume_range ─────────────────────────────────────────
5060
5061    #[test]
5062    fn test_volume_range_none_before_completion() {
5063        let agg = agg("BTC-USD", Timeframe::Minutes(1));
5064        assert!(agg.volume_range().is_none());
5065    }
5066
5067    #[test]
5068    fn test_volume_range_after_two_bars() {
5069        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5070        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
5071        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
5072        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
5073        // bar1=3, bar2=10 → min=3, peak=10
5074        assert_eq!(agg.volume_range(), Some((dec!(3), dec!(10))));
5075    }
5076
5077    // ── OhlcvBar::body_to_range_ratio ─────────────────────────────────────────
5078
5079    fn make_ohlcv_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
5080        OhlcvBar {
5081            symbol: "X".into(),
5082            timeframe: Timeframe::Minutes(1),
5083            open,
5084            high,
5085            low,
5086            close,
5087            volume: dec!(1),
5088            bar_start_ms: 0,
5089            trade_count: 1,
5090            is_complete: false,
5091            is_gap_fill: false,
5092            vwap: None,
5093        }
5094    }
5095
5096    #[test]
5097    fn test_body_to_range_ratio_bullish_full_body() {
5098        // open=100, close=110, high=110, low=100 → body=10, range=10 → ratio=1.0
5099        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
5100        assert_eq!(bar.body_to_range_ratio(), Some(dec!(1)));
5101    }
5102
5103    #[test]
5104    fn test_body_to_range_ratio_doji_like() {
5105        // open=close → body=0, range>0 → ratio=0
5106        let bar = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
5107        assert_eq!(bar.body_to_range_ratio(), Some(dec!(0)));
5108    }
5109
5110    #[test]
5111    fn test_body_to_range_ratio_none_when_range_zero() {
5112        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5113        assert!(bar.body_to_range_ratio().is_none());
5114    }
5115
5116    // ── OhlcvAggregator::is_active ────────────────────────────────────────────
5117
5118    #[test]
5119    fn test_is_active_false_before_any_ticks() {
5120        let agg = agg("BTC-USD", Timeframe::Minutes(1));
5121        assert!(!agg.is_active());
5122    }
5123
5124    #[test]
5125    fn test_is_active_true_after_first_tick() {
5126        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5127        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
5128        assert!(agg.is_active());
5129    }
5130
5131    #[test]
5132    fn test_is_active_false_after_flush() {
5133        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5134        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
5135        agg.flush();
5136        assert!(!agg.is_active());
5137    }
5138
5139    // ── OhlcvBar::is_long_upper_wick ──────────────────────────────────────────
5140
5141    #[test]
5142    fn test_is_long_upper_wick_true_when_upper_wick_dominates() {
5143        // open=100, close=101, high=110, low=100 → body=1, upper_wick=9
5144        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(101));
5145        assert!(bar.is_long_upper_wick());
5146    }
5147
5148    #[test]
5149    fn test_is_long_upper_wick_false_for_full_body() {
5150        // open=100, close=110, high=110, low=100 → body=10, upper_wick=0
5151        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
5152        assert!(!bar.is_long_upper_wick());
5153    }
5154
5155    #[test]
5156    fn test_is_long_upper_wick_false_when_equal() {
5157        // open=100, close=105, high=110, low=100 → body=5, upper_wick=5
5158        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(105));
5159        assert!(!bar.is_long_upper_wick());
5160    }
5161
5162    // ── OhlcvBar::price_change_abs ────────────────────────────────────────────
5163
5164    #[test]
5165    fn test_price_change_abs_bullish_bar() {
5166        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
5167        assert_eq!(bar.price_change_abs(), dec!(8));
5168    }
5169
5170    #[test]
5171    fn test_price_change_abs_bearish_bar() {
5172        let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(102));
5173        assert_eq!(bar.price_change_abs(), dec!(8));
5174    }
5175
5176    #[test]
5177    fn test_price_change_abs_doji_zero() {
5178        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5179        assert_eq!(bar.price_change_abs(), dec!(0));
5180    }
5181
5182    // ── OhlcvAggregator::vwap_current ────────────────────────────────────────
5183
5184    #[test]
5185    fn test_vwap_current_none_before_any_ticks() {
5186        let agg = agg("BTC-USD", Timeframe::Minutes(1));
5187        assert!(agg.vwap_current().is_none());
5188    }
5189
5190    #[test]
5191    fn test_vwap_current_equals_price_for_single_tick() {
5192        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5193        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(5), 1_000)).unwrap();
5194        // vwap = price*qty / qty = 200
5195        assert_eq!(agg.vwap_current(), Some(dec!(200)));
5196    }
5197
5198    #[test]
5199    fn test_vwap_current_weighted_average() {
5200        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
5201        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
5202        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(3), 2_000)).unwrap();
5203        // vwap = (100*1 + 200*3) / (1+3) = 700/4 = 175
5204        assert_eq!(agg.vwap_current(), Some(dec!(175)));
5205    }
5206
5207    // --- upper_shadow / lower_shadow / is_spinning_top / hlc3 ---
5208
5209    fn bar(o: i64, h: i64, l: i64, c: i64) -> OhlcvBar {
5210        OhlcvBar {
5211            symbol: "X".into(),
5212            timeframe: Timeframe::Minutes(1),
5213            open: Decimal::from(o),
5214            high: Decimal::from(h),
5215            low: Decimal::from(l),
5216            close: Decimal::from(c),
5217            volume: Decimal::ZERO,
5218            bar_start_ms: 0,
5219            trade_count: 0,
5220            is_complete: false,
5221            is_gap_fill: false,
5222            vwap: None,
5223        }
5224    }
5225
5226    #[test]
5227    fn test_upper_shadow_equals_wick_upper() {
5228        let b = bar(100, 120, 90, 110);
5229        assert_eq!(b.upper_shadow(), b.wick_upper());
5230        assert_eq!(b.upper_shadow(), Decimal::from(10)); // 120 - max(100,110)
5231    }
5232
5233    #[test]
5234    fn test_lower_shadow_equals_wick_lower() {
5235        let b = bar(100, 120, 90, 110);
5236        assert_eq!(b.lower_shadow(), b.wick_lower());
5237        assert_eq!(b.lower_shadow(), Decimal::from(10)); // min(100,110) - 90
5238    }
5239
5240    #[test]
5241    fn test_is_spinning_top_true_when_small_body_large_wicks() {
5242        // body = |110-100| = 10, range = 130-80 = 50
5243        // body_pct = 0.3 → max_body = 15; body(10) <= 15
5244        // wick_upper = 130 - 110 = 20 > 10 ✓
5245        // wick_lower = 100 - 80 = 20 > 10 ✓
5246        let b = bar(100, 130, 80, 110);
5247        assert!(b.is_spinning_top(dec!(0.3)));
5248    }
5249
5250    #[test]
5251    fn test_is_spinning_top_false_when_body_too_large() {
5252        // body = 40, range = 50; body_pct=0.3 → max_body=15; 40 > 15
5253        let b = bar(80, 130, 80, 120);
5254        assert!(!b.is_spinning_top(dec!(0.3)));
5255    }
5256
5257    #[test]
5258    fn test_is_spinning_top_false_when_zero_range() {
5259        let b = bar(100, 100, 100, 100);
5260        assert!(!b.is_spinning_top(dec!(0.3)));
5261    }
5262
5263    #[test]
5264    fn test_hlc3_equals_typical_price() {
5265        let b = bar(100, 120, 80, 110);
5266        assert_eq!(b.hlc3(), b.typical_price());
5267        // (120 + 80 + 110) / 3 = 310/3
5268        assert_eq!(b.hlc3(), (Decimal::from(120) + Decimal::from(80) + Decimal::from(110)) / Decimal::from(3));
5269    }
5270
5271    // ── OhlcvBar::is_bearish ──────────────────────────────────────────────────
5272
5273    #[test]
5274    fn test_is_bearish_true_when_close_below_open() {
5275        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
5276        assert!(bar.is_bearish());
5277    }
5278
5279    #[test]
5280    fn test_is_bearish_false_when_close_above_open() {
5281        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
5282        assert!(!bar.is_bearish());
5283    }
5284
5285    #[test]
5286    fn test_is_bearish_false_when_doji() {
5287        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5288        assert!(!bar.is_bearish());
5289    }
5290
5291    // ── OhlcvBar::wick_ratio ──────────────────────────────────────────────────
5292
5293    #[test]
5294    fn test_wick_ratio_zero_for_full_body_no_wicks() {
5295        // open=100, close=110, high=110, low=100 → no wicks → ratio=0
5296        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
5297        let ratio = bar.wick_ratio().unwrap();
5298        assert!(ratio.abs() < 1e-10);
5299    }
5300
5301    #[test]
5302    fn test_wick_ratio_one_for_pure_wick_doji() {
5303        // open=close=105, high=110, low=100 → body=0, upper=5, lower=5, range=10 → ratio=1
5304        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
5305        let ratio = bar.wick_ratio().unwrap();
5306        assert!((ratio - 1.0).abs() < 1e-10);
5307    }
5308
5309    #[test]
5310    fn test_wick_ratio_none_for_zero_range_bar() {
5311        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5312        assert!(bar.wick_ratio().is_none());
5313    }
5314
5315    // ── OhlcvBar::is_bullish ──────────────────────────────────────────────────
5316
5317    #[test]
5318    fn test_is_bullish_true_when_close_above_open() {
5319        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
5320        assert!(bar.is_bullish());
5321    }
5322
5323    #[test]
5324    fn test_is_bullish_false_when_close_below_open() {
5325        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
5326        assert!(!bar.is_bullish());
5327    }
5328
5329    #[test]
5330    fn test_is_bullish_false_when_doji() {
5331        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5332        assert!(!bar.is_bullish());
5333    }
5334
5335    // ── OhlcvBar::bar_duration_ms ─────────────────────────────────────────────
5336
5337    #[test]
5338    fn test_bar_duration_ms_one_minute() {
5339        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5340        assert_eq!(bar.bar_duration_ms(), 60_000);
5341    }
5342
5343    #[test]
5344    fn test_bar_duration_ms_consistent_with_timeframe() {
5345        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5346        bar.timeframe = Timeframe::Hours(1);
5347        assert_eq!(bar.bar_duration_ms(), 3_600_000);
5348    }
5349
5350    #[test]
5351    fn test_bar_duration_ms_seconds_timeframe() {
5352        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5353        bar.timeframe = Timeframe::Seconds(30);
5354        assert_eq!(bar.bar_duration_ms(), 30_000);
5355    }
5356
5357    // --- ohlc4 / is_marubozu / is_engulfing ---
5358
5359    #[test]
5360    fn test_ohlc4_equals_average_of_all_four_prices() {
5361        let b = bar(100, 120, 80, 110);
5362        // (100 + 120 + 80 + 110) / 4 = 410 / 4 = 102.5
5363        let expected = (Decimal::from(100) + Decimal::from(120) + Decimal::from(80) + Decimal::from(110))
5364            / Decimal::from(4);
5365        assert_eq!(b.ohlc4(), expected);
5366    }
5367
5368    #[test]
5369    fn test_is_marubozu_true_when_no_wicks() {
5370        // Bullish marubozu: open=low=100, close=high=110
5371        let b = bar(100, 110, 100, 110);
5372        assert!(b.is_marubozu());
5373    }
5374
5375    #[test]
5376    fn test_is_marubozu_false_when_has_upper_wick() {
5377        let b = bar(100, 115, 100, 110);
5378        assert!(!b.is_marubozu());
5379    }
5380
5381    #[test]
5382    fn test_is_marubozu_false_when_has_lower_wick() {
5383        let b = bar(100, 110, 95, 110);
5384        assert!(!b.is_marubozu());
5385    }
5386
5387    // --- is_harami / tail_length ---
5388
5389    #[test]
5390    fn test_is_harami_true_when_body_inside_prev_body() {
5391        let prev = bar(98, 115, 90, 108); // prev body: 98-108
5392        let curr = bar(100, 110, 95, 105); // curr body: 100-105 — inside 98-108
5393        assert!(curr.is_harami(&prev));
5394    }
5395
5396    #[test]
5397    fn test_is_harami_false_when_body_engulfs_prev() {
5398        let prev = bar(100, 110, 95, 105); // prev body: 100-105
5399        let curr = bar(98, 115, 90, 108);  // curr body: 98-108 — engulfs prev
5400        assert!(!curr.is_harami(&prev));
5401    }
5402
5403    #[test]
5404    fn test_is_harami_false_when_bodies_equal() {
5405        let prev = bar(100, 110, 90, 105);
5406        let curr = bar(100, 110, 90, 105); // equal bodies
5407        assert!(!curr.is_harami(&prev));
5408    }
5409
5410    #[test]
5411    fn test_tail_length_upper_wick_longer() {
5412        // open=100, high=120, low=95, close=105 → upper_wick=15, lower_wick=5
5413        let b = bar(100, 120, 95, 105);
5414        assert_eq!(b.tail_length(), Decimal::from(15));
5415    }
5416
5417    #[test]
5418    fn test_tail_length_lower_wick_longer() {
5419        // open=105, high=110, low=80, close=100 → upper_wick=5, lower_wick=20
5420        let b = bar(105, 110, 80, 100);
5421        assert_eq!(b.tail_length(), Decimal::from(20));
5422    }
5423
5424    #[test]
5425    fn test_tail_length_zero_for_marubozu() {
5426        // open=low=100, close=high=110 → both wicks zero
5427        let b = bar(100, 110, 100, 110);
5428        assert!(b.tail_length().is_zero());
5429    }
5430
5431    // --- is_inside_bar / bar_type ---
5432
5433    #[test]
5434    fn test_is_inside_bar_true_when_range_within_prev() {
5435        let prev = bar(90, 120, 80, 110); // prev range: 80-120
5436        let curr = bar(95, 115, 85, 100); // curr range: 85-115 — inside 80-120
5437        assert!(curr.is_inside_bar(&prev));
5438    }
5439
5440    #[test]
5441    fn test_is_inside_bar_false_when_high_exceeds_prev_high() {
5442        let prev = bar(90, 110, 80, 100); // prev high = 110
5443        let curr = bar(95, 112, 85, 100); // curr high = 112 > 110
5444        assert!(!curr.is_inside_bar(&prev));
5445    }
5446
5447    #[test]
5448    fn test_is_inside_bar_false_when_equal_range() {
5449        let prev = bar(90, 110, 80, 100);
5450        let curr = bar(90, 110, 80, 100); // same high/low — not strictly inside
5451        assert!(!curr.is_inside_bar(&prev));
5452    }
5453
5454    #[test]
5455    fn test_bar_type_bullish() {
5456        let b = bar(100, 110, 90, 105); // close > open
5457        assert_eq!(b.bar_type(), "bullish");
5458    }
5459
5460    #[test]
5461    fn test_bar_type_bearish() {
5462        let b = bar(105, 110, 90, 100); // close < open
5463        assert_eq!(b.bar_type(), "bearish");
5464    }
5465
5466    #[test]
5467    fn test_bar_type_doji() {
5468        let b = bar(100, 110, 90, 100); // close == open
5469        assert_eq!(b.bar_type(), "doji");
5470    }
5471
5472    // --- body_pct / is_bullish_hammer ---
5473
5474    #[test]
5475    fn test_body_pct_none_for_zero_range() {
5476        let b = bar(100, 100, 100, 100);
5477        assert!(b.body_pct().is_none());
5478    }
5479
5480    #[test]
5481    fn test_body_pct_100_for_marubozu() {
5482        // open=low=100, close=high=110 → body=10, range=10, pct=100
5483        let b = bar(100, 110, 100, 110);
5484        assert_eq!(b.body_pct().unwrap(), Decimal::ONE_HUNDRED);
5485    }
5486
5487    #[test]
5488    fn test_body_pct_50_for_half_body() {
5489        // open=100, close=105, high=110, low=100 → body=5, range=10, pct=50
5490        let b = bar(100, 110, 100, 105);
5491        assert_eq!(b.body_pct().unwrap(), Decimal::from(50));
5492    }
5493
5494    #[test]
5495    fn test_is_bullish_hammer_true_for_classic_hammer() {
5496        // long lower wick, small body near top, tiny upper wick
5497        // open=108, high=110, low=100, close=109 → body=1, lower=8, upper=1
5498        let b = bar(108, 110, 100, 109);
5499        assert!(b.is_bullish_hammer());
5500    }
5501
5502    #[test]
5503    fn test_is_bullish_hammer_false_when_lower_wick_not_long_enough() {
5504        // open=100, high=110, low=98, close=108 → body=8, lower=2 < 2*8=16
5505        let b = bar(100, 110, 98, 108);
5506        assert!(!b.is_bullish_hammer());
5507    }
5508
5509    #[test]
5510    fn test_is_bullish_hammer_false_for_doji() {
5511        let b = bar(100, 110, 90, 100); // open == close, body = 0
5512        assert!(!b.is_bullish_hammer());
5513    }
5514
5515    // --- OhlcvBar::is_marubozu ---
5516    #[test]
5517    fn test_is_marubozu_true_when_full_body() {
5518        // open=100, high=100, low=100, close=110 → body=10, range=10 → 100%
5519        let b = bar(100, 110, 100, 110);
5520        assert!(b.is_marubozu());
5521    }
5522
5523    #[test]
5524    fn test_is_marubozu_false_when_large_wicks() {
5525        // open=100, high=120, low=80, close=110 → body=10, range=40 → 25%
5526        let b = bar(100, 120, 80, 110);
5527        assert!(!b.is_marubozu());
5528    }
5529
5530    #[test]
5531    fn test_is_marubozu_true_for_zero_range_flat_bar() {
5532        // flat bar has no wicks → qualifies as marubozu under "no wicks" definition
5533        let b = bar(100, 100, 100, 100);
5534        assert!(b.is_marubozu());
5535    }
5536
5537    // --- OhlcvBar::upper_wick_pct ---
5538    #[test]
5539    fn test_upper_wick_pct_zero_when_no_upper_wick() {
5540        // close is the high
5541        let b = bar(100, 110, 90, 110);
5542        let pct = b.upper_wick_pct().unwrap();
5543        assert!(pct.is_zero(), "expected 0, got {pct}");
5544    }
5545
5546    #[test]
5547    fn test_upper_wick_pct_50_when_half_range() {
5548        // open=100, high=120, low=100, close=110 → upper_wick=10, range=20 → 50%
5549        let b = bar(100, 120, 100, 110);
5550        let pct = b.upper_wick_pct().unwrap();
5551        assert_eq!(pct, dec!(50));
5552    }
5553
5554    #[test]
5555    fn test_upper_wick_pct_none_for_zero_range() {
5556        let b = bar(100, 100, 100, 100);
5557        assert!(b.upper_wick_pct().is_none());
5558    }
5559
5560    // --- OhlcvBar::lower_wick_pct ---
5561    #[test]
5562    fn test_lower_wick_pct_zero_when_no_lower_wick() {
5563        // open is the low
5564        let b = bar(100, 110, 100, 105);
5565        let pct = b.lower_wick_pct().unwrap();
5566        assert!(pct.is_zero(), "expected 0, got {pct}");
5567    }
5568
5569    #[test]
5570    fn test_lower_wick_pct_50_when_half_range() {
5571        // open=110, high=120, low=100, close=115 → lower_wick=10, range=20 → 50%
5572        let b = bar(110, 120, 100, 115);
5573        let pct = b.lower_wick_pct().unwrap();
5574        assert_eq!(pct, dec!(50));
5575    }
5576
5577    #[test]
5578    fn test_lower_wick_pct_none_for_zero_range() {
5579        let b = bar(100, 100, 100, 100);
5580        assert!(b.lower_wick_pct().is_none());
5581    }
5582
5583    // --- OhlcvBar::is_bearish_engulfing ---
5584    #[test]
5585    fn test_is_bearish_engulfing_true_for_bearish_engulf() {
5586        let prev = bar(100, 115, 95, 110); // bullish, body 100-110
5587        let curr = bar(112, 115, 88, 90);  // bearish, body 112-90, engulfs 100-110
5588        assert!(curr.is_bearish_engulfing(&prev));
5589    }
5590
5591    #[test]
5592    fn test_is_bearish_engulfing_false_for_bullish_engulf() {
5593        let prev = bar(110, 115, 95, 100); // bearish, body 110-100
5594        let curr = bar(98, 120, 95, 115);  // bullish, body 98-115 engulfs but not bearish
5595        assert!(!curr.is_bearish_engulfing(&prev));
5596    }
5597
5598    #[test]
5599    fn test_is_engulfing_true_when_body_contains_prev_body() {
5600        let prev = bar(100, 110, 95, 105); // prev body: 100-105
5601        let curr = bar(98, 115, 95, 108);  // curr body: 98-108 engulfs 100-105
5602        assert!(curr.is_engulfing(&prev));
5603    }
5604
5605    #[test]
5606    fn test_is_engulfing_false_when_only_partial_overlap() {
5607        let prev = bar(100, 115, 90, 112); // prev body: 100-112
5608        let curr = bar(101, 115, 90, 113); // curr body: 101-113 — lo=101 > 100, not engulfing
5609        assert!(!curr.is_engulfing(&prev));
5610    }
5611
5612    #[test]
5613    fn test_is_engulfing_false_for_equal_bodies() {
5614        let prev = bar(100, 110, 90, 108);
5615        let curr = bar(100, 110, 90, 108); // exactly equal
5616        assert!(!curr.is_engulfing(&prev));
5617    }
5618
5619    // ── OhlcvBar::has_upper_wick / has_lower_wick ─────────────────────────────
5620
5621    #[test]
5622    fn test_has_upper_wick_true_when_high_above_max_oc() {
5623        // open=100, close=110, high=115 → upper wick = 5
5624        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(100), dec!(110));
5625        assert!(bar.has_upper_wick());
5626    }
5627
5628    #[test]
5629    fn test_has_upper_wick_false_for_full_body() {
5630        // open=100, close=110, high=110 → no upper wick
5631        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
5632        assert!(!bar.has_upper_wick());
5633    }
5634
5635    #[test]
5636    fn test_has_lower_wick_true_when_low_below_min_oc() {
5637        // open=105, close=110, low=100 → lower wick = 5
5638        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(110));
5639        assert!(bar.has_lower_wick());
5640    }
5641
5642    #[test]
5643    fn test_has_lower_wick_false_for_full_body() {
5644        // open=100, close=110, low=100 → no lower wick
5645        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
5646        assert!(!bar.has_lower_wick());
5647    }
5648
5649    // ── OhlcvBar::is_gravestone_doji ──────────────────────────────────────────
5650
5651    #[test]
5652    fn test_is_gravestone_doji_true() {
5653        // open=close=low=100, high=110 → body=0, close≈low → gravestone
5654        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(100));
5655        assert!(bar.is_gravestone_doji(dec!(0)));
5656    }
5657
5658    #[test]
5659    fn test_is_gravestone_doji_false_when_close_above_low() {
5660        // open=100, close=105, low=99, high=110 → body=5 → not a doji
5661        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
5662        assert!(!bar.is_gravestone_doji(dec!(1)));
5663    }
5664
5665    // ── OhlcvBar::is_dragonfly_doji ───────────────────────────────────────────
5666
5667    #[test]
5668    fn test_is_dragonfly_doji_true() {
5669        // open=close=high=110, low=100 → body=0, close≈high → dragonfly
5670        let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(110));
5671        assert!(bar.is_dragonfly_doji(dec!(0)));
5672    }
5673
5674    #[test]
5675    fn test_is_dragonfly_doji_false_when_close_below_high() {
5676        // close=105, high=110 → close not near high
5677        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
5678        assert!(!bar.is_dragonfly_doji(dec!(1)));
5679    }
5680
5681    // ── OhlcvBar::is_flat / close_to_high_ratio / close_open_ratio ──────────
5682
5683    #[test]
5684    fn test_is_flat_true() {
5685        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5686        assert!(bar.is_flat());
5687    }
5688
5689    #[test]
5690    fn test_is_flat_false_when_range_exists() {
5691        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5692        assert!(!bar.is_flat());
5693    }
5694
5695    #[test]
5696    fn test_close_to_high_ratio_normal() {
5697        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5698        // close=110, high=110 → ratio=1.0
5699        let r = bar.close_to_high_ratio().unwrap();
5700        assert!((r - 1.0).abs() < 1e-9);
5701    }
5702
5703    #[test]
5704    fn test_close_to_high_ratio_none_when_high_zero() {
5705        let bar = make_ohlcv_bar(dec!(0), dec!(0), dec!(0), dec!(0));
5706        assert!(bar.close_to_high_ratio().is_none());
5707    }
5708
5709    #[test]
5710    fn test_close_open_ratio_normal() {
5711        // close=110, open=100 → ratio=1.1
5712        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
5713        let r = bar.close_open_ratio().unwrap();
5714        assert!((r - 1.1).abs() < 1e-9);
5715    }
5716
5717    #[test]
5718    fn test_close_open_ratio_none_when_open_zero() {
5719        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
5720        assert!(bar.close_open_ratio().is_none());
5721    }
5722
5723    // ── OhlcvBar::true_range_with_prev ────────────────────────────────────────
5724
5725    #[test]
5726    fn test_true_range_simple_hl_dominates() {
5727        // high=110, low=90, prev_close=100 → hl=20, hc=10, lc=10 → TR=20
5728        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5729        assert_eq!(bar.true_range_with_prev(dec!(100)), dec!(20));
5730    }
5731
5732    #[test]
5733    fn test_true_range_gap_up_dominates() {
5734        // prev_close=80, high=100, low=90 → hl=10, hc=20, lc=10 → TR=20
5735        let bar = make_ohlcv_bar(dec!(91), dec!(100), dec!(90), dec!(95));
5736        assert_eq!(bar.true_range_with_prev(dec!(80)), dec!(20));
5737    }
5738
5739    #[test]
5740    fn test_true_range_gap_down_dominates() {
5741        // prev_close=120, high=100, low=95 → hl=5, hc=20, lc=25 → TR=25
5742        let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97));
5743        assert_eq!(bar.true_range_with_prev(dec!(120)), dec!(25));
5744    }
5745
5746    // ── OhlcvBar::is_outside_bar / high_low_midpoint ─────────────────────────
5747
5748    #[test]
5749    fn test_is_outside_bar_true() {
5750        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5751        let bar  = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5752        assert!(bar.is_outside_bar(&prev));
5753    }
5754
5755    #[test]
5756    fn test_is_outside_bar_false_when_inside() {
5757        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5758        let bar  = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5759        assert!(!bar.is_outside_bar(&prev));
5760    }
5761
5762    #[test]
5763    fn test_high_low_midpoint_correct() {
5764        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5765        // (110 + 90) / 2 = 100
5766        assert_eq!(bar.high_low_midpoint(), dec!(100));
5767    }
5768
5769    #[test]
5770    fn test_high_low_midpoint_uneven() {
5771        let bar = make_ohlcv_bar(dec!(100), dec!(111), dec!(90), dec!(100));
5772        // (111 + 90) / 2 = 100.5
5773        assert_eq!(bar.high_low_midpoint(), dec!(100.5));
5774    }
5775
5776    // ── OhlcvBar::gap_up / gap_down ──────────────────────────────────────────
5777
5778    #[test]
5779    fn test_gap_up_true() {
5780        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
5781        let bar  = make_ohlcv_bar(dec!(102), dec!(110), dec!(101), dec!(108));
5782        assert!(bar.gap_up(&prev));
5783    }
5784
5785    #[test]
5786    fn test_gap_up_false_when_no_gap() {
5787        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
5788        let bar  = make_ohlcv_bar(dec!(99), dec!(105), dec!(98), dec!(104));
5789        assert!(!bar.gap_up(&prev));
5790    }
5791
5792    #[test]
5793    fn test_gap_down_true() {
5794        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
5795        let bar  = make_ohlcv_bar(dec!(88), dec!(89), dec!(85), dec!(86));
5796        assert!(bar.gap_down(&prev));
5797    }
5798
5799    #[test]
5800    fn test_gap_down_false_when_no_gap() {
5801        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
5802        let bar  = make_ohlcv_bar(dec!(91), dec!(95), dec!(89), dec!(93));
5803        assert!(!bar.gap_down(&prev));
5804    }
5805
5806    // ── OhlcvBar::range_pct ──────────────────────────────────────────────────
5807
5808    #[test]
5809    fn test_range_pct_correct() {
5810        // open=100, high=110, low=90 → range=20, 20/100 * 100 = 20%
5811        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5812        let pct = bar.range_pct().unwrap();
5813        assert!((pct - 20.0).abs() < 1e-9);
5814    }
5815
5816    #[test]
5817    fn test_range_pct_none_when_open_zero() {
5818        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
5819        assert!(bar.range_pct().is_none());
5820    }
5821
5822    #[test]
5823    fn test_range_pct_zero_for_flat_bar() {
5824        // high == low → range = 0
5825        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
5826        let pct = bar.range_pct().unwrap();
5827        assert_eq!(pct, 0.0);
5828    }
5829
5830    // ── OhlcvBar::body_size ──────────────────────────────────────────────────
5831
5832    #[test]
5833    fn test_body_size_bullish_bar() {
5834        // open=100, close=110 → body = 10
5835        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
5836        assert_eq!(bar.body_size(), dec!(10));
5837    }
5838
5839    #[test]
5840    fn test_body_size_bearish_bar() {
5841        // open=110, close=100 → body = 10
5842        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
5843        assert_eq!(bar.body_size(), dec!(10));
5844    }
5845
5846    #[test]
5847    fn test_body_size_doji() {
5848        // open == close → body = 0
5849        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
5850        assert_eq!(bar.body_size(), dec!(0));
5851    }
5852
5853    // ── OhlcvBar::volume_delta / is_consolidating ────────────────────────────
5854
5855    #[test]
5856    fn test_volume_delta_positive_when_increasing() {
5857        let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
5858        prev.volume = dec!(1000);
5859        let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
5860        bar.volume = dec!(1500);
5861        assert_eq!(bar.volume_delta(&prev), dec!(500));
5862    }
5863
5864    #[test]
5865    fn test_volume_delta_negative_when_decreasing() {
5866        let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
5867        prev.volume = dec!(1500);
5868        let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
5869        bar.volume = dec!(1000);
5870        assert_eq!(bar.volume_delta(&prev), dec!(-500));
5871    }
5872
5873    #[test]
5874    fn test_is_consolidating_true_when_small_range() {
5875        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
5876        let bar  = make_ohlcv_bar(dec!(102), dec!(106), dec!(100), dec!(104)); // range=6 < 10
5877        assert!(bar.is_consolidating(&prev));
5878    }
5879
5880    #[test]
5881    fn test_is_consolidating_false_when_large_range() {
5882        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
5883        let bar  = make_ohlcv_bar(dec!(102), dec!(115), dec!(95), dec!(110)); // range=20, not < 10
5884        assert!(!bar.is_consolidating(&prev));
5885    }
5886
5887    // ── OhlcvBar::relative_volume / intraday_reversal ─────────────────────────
5888
5889    #[test]
5890    fn test_relative_volume_correct() {
5891        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
5892        // bar.volume = dec!(1) (default), avg = 2 → ratio = 0.5
5893        let rv = bar.relative_volume(dec!(2)).unwrap();
5894        assert!((rv - 0.5).abs() < 1e-9);
5895    }
5896
5897    #[test]
5898    fn test_relative_volume_none_when_avg_zero() {
5899        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
5900        assert!(bar.relative_volume(dec!(0)).is_none());
5901    }
5902
5903    #[test]
5904    fn test_intraday_reversal_true_for_bullish_then_bearish() {
5905        // prev: open=100, close=105 (bullish)
5906        let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
5907        // this: opens at 105 (≥ prev close), closes below prev open (100) → reversal
5908        let bar = make_ohlcv_bar(dec!(105), dec!(107), dec!(97), dec!(98));
5909        assert!(bar.intraday_reversal(&prev));
5910    }
5911
5912    #[test]
5913    fn test_intraday_reversal_false_for_continuation() {
5914        // prev: open=100, close=105 (bullish), this also bullish at lower open
5915        let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
5916        let bar = make_ohlcv_bar(dec!(104), dec!(115), dec!(103), dec!(113));
5917        assert!(!bar.intraday_reversal(&prev));
5918    }
5919
5920    // ── OhlcvBar::price_at_pct ───────────────────────────────────────────────
5921
5922    #[test]
5923    fn test_price_at_pct_zero_returns_low() {
5924        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5925        assert_eq!(bar.price_at_pct(0.0), dec!(90));
5926    }
5927
5928    #[test]
5929    fn test_price_at_pct_one_returns_high() {
5930        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5931        assert_eq!(bar.price_at_pct(1.0), dec!(110));
5932    }
5933
5934    #[test]
5935    fn test_price_at_pct_half_returns_midpoint() {
5936        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5937        // low=90, range=20, 0.5*20=10 → 90+10=100
5938        assert_eq!(bar.price_at_pct(0.5), dec!(100));
5939    }
5940
5941    #[test]
5942    fn test_price_at_pct_clamped_above_one() {
5943        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5944        assert_eq!(bar.price_at_pct(2.0), dec!(110));
5945    }
5946
5947    // ── average_true_range ────────────────────────────────────────────────────
5948
5949    #[test]
5950    fn test_average_true_range_none_when_fewer_than_two_bars() {
5951        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5952        assert!(OhlcvBar::average_true_range(&[bar]).is_none());
5953        assert!(OhlcvBar::average_true_range(&[]).is_none());
5954    }
5955
5956    #[test]
5957    fn test_average_true_range_two_bars_no_gap() {
5958        // bar1: high=110 low=90 close=100
5959        // bar2: high=115 low=95 close=110  tr = max(115-95, |115-100|, |95-100|) = max(20,15,5) = 20
5960        let bar1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5961        let bar2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
5962        let atr = OhlcvBar::average_true_range(&[bar1, bar2]).unwrap();
5963        assert_eq!(atr, dec!(20)); // only one TR value: bar2 vs bar1.close=100
5964    }
5965
5966    #[test]
5967    fn test_average_true_range_three_bars_mean() {
5968        // bar1: close=100
5969        // bar2: h=110 l=90 c=105; tr = max(20, |110-100|, |90-100|) = max(20,10,10) = 20
5970        // bar3: h=120 l=100 c=115; tr = max(20, |120-105|, |100-105|) = max(20,15,5) = 20
5971        let bar1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
5972        let bar2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
5973        let bar3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(115));
5974        let atr = OhlcvBar::average_true_range(&[bar1, bar2, bar3]).unwrap();
5975        assert_eq!(atr, dec!(20));
5976    }
5977
5978    // ── average_body ──────────────────────────────────────────────────────────
5979
5980    #[test]
5981    fn test_average_body_none_when_empty() {
5982        assert!(OhlcvBar::average_body(&[]).is_none());
5983    }
5984
5985    #[test]
5986    fn test_average_body_single_bar() {
5987        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
5988        // body = |108 - 100| = 8
5989        assert_eq!(OhlcvBar::average_body(&[bar]), Some(dec!(8)));
5990    }
5991
5992    #[test]
5993    fn test_average_body_multiple_bars() {
5994        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)); // body=10
5995        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(100)); // body=10
5996        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120)); // body=20
5997        let avg = OhlcvBar::average_body(&[b1, b2, b3]).unwrap();
5998        // (10 + 10 + 20) / 3 = 40/3
5999        assert_eq!(avg, dec!(40) / dec!(3));
6000    }
6001
6002    // ── bullish_count / bearish_count / win_rate ──────────────────────────────
6003
6004    #[test]
6005    fn test_bullish_count_zero_for_empty_slice() {
6006        assert_eq!(OhlcvBar::bullish_count(&[]), 0);
6007    }
6008
6009    #[test]
6010    fn test_bullish_count_all_bullish() {
6011        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); // bullish
6012        let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(105), dec!(115)); // bullish
6013        assert_eq!(OhlcvBar::bullish_count(&[b1, b2]), 2);
6014    }
6015
6016    #[test]
6017    fn test_bearish_count_correct() {
6018        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6019        let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
6020        let doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6021        assert_eq!(OhlcvBar::bearish_count(&[bull, bear, doji]), 1);
6022    }
6023
6024    #[test]
6025    fn test_win_rate_none_when_empty() {
6026        assert!(OhlcvBar::win_rate(&[]).is_none());
6027    }
6028
6029    #[test]
6030    fn test_win_rate_all_bullish() {
6031        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6032        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(112));
6033        let wr = OhlcvBar::win_rate(&[b1, b2]).unwrap();
6034        assert!((wr - 1.0).abs() < 1e-9);
6035    }
6036
6037    #[test]
6038    fn test_win_rate_half_and_half() {
6039        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6040        let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
6041        let wr = OhlcvBar::win_rate(&[bull, bear]).unwrap();
6042        assert!((wr - 0.5).abs() < 1e-9);
6043    }
6044
6045    // ── bullish_streak / bearish_streak ──────────────────────────────────────
6046
6047    #[test]
6048    fn test_bullish_streak_zero_for_empty_slice() {
6049        assert_eq!(OhlcvBar::bullish_streak(&[]), 0);
6050    }
6051
6052    #[test]
6053    fn test_bullish_streak_zero_when_last_bar_bearish() {
6054        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6055        let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
6056        assert_eq!(OhlcvBar::bullish_streak(&[bull, bear]), 0);
6057    }
6058
6059    #[test]
6060    fn test_bullish_streak_counts_consecutive_tail() {
6061        let bear = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)); // bearish
6062        let bull1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(102)); // bullish
6063        let bull2 = make_ohlcv_bar(dec!(102), dec!(115), dec!(100), dec!(110)); // bullish
6064        assert_eq!(OhlcvBar::bullish_streak(&[bear, bull1, bull2]), 2);
6065    }
6066
6067    #[test]
6068    fn test_bearish_streak_counts_consecutive_tail() {
6069        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); // bullish
6070        let bear1 = make_ohlcv_bar(dec!(108), dec!(109), dec!(90), dec!(95)); // bearish
6071        let bear2 = make_ohlcv_bar(dec!(95), dec!(96), dec!(80), dec!(85)); // bearish
6072        assert_eq!(OhlcvBar::bearish_streak(&[bull, bear1, bear2]), 2);
6073    }
6074
6075    // ── max_drawdown ──────────────────────────────────────────────────────────
6076
6077    #[test]
6078    fn test_max_drawdown_none_when_fewer_than_2_bars() {
6079        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6080        assert!(OhlcvBar::max_drawdown(&[bar]).is_none());
6081        assert!(OhlcvBar::max_drawdown(&[]).is_none());
6082    }
6083
6084    #[test]
6085    fn test_max_drawdown_zero_when_monotone_increasing() {
6086        let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(100));
6087        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
6088        let b3 = make_ohlcv_bar(dec!(105), dec!(115), dec!(104), dec!(110));
6089        let dd = OhlcvBar::max_drawdown(&[b1, b2, b3]).unwrap();
6090        assert_eq!(dd, 0.0);
6091    }
6092
6093    #[test]
6094    fn test_max_drawdown_correct_after_peak_then_drop() {
6095        // closes: 100, 120, 90 → peak=120, drop=(120-90)/120 = 0.25
6096        let b1 = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
6097        let b2 = make_ohlcv_bar(dec!(100), dec!(125), dec!(99), dec!(120));
6098        let b3 = make_ohlcv_bar(dec!(120), dec!(121), dec!(88), dec!(90));
6099        let dd = OhlcvBar::max_drawdown(&[b1, b2, b3]).unwrap();
6100        assert!((dd - 0.25).abs() < 1e-9, "expected 0.25, got {dd}");
6101    }
6102
6103    // ── mean_volume ───────────────────────────────────────────────────────────
6104
6105    #[test]
6106    fn test_mean_volume_none_when_empty() {
6107        assert!(OhlcvBar::mean_volume(&[]).is_none());
6108    }
6109
6110    #[test]
6111    fn test_mean_volume_single_bar() {
6112        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6113        bar.volume = dec!(200);
6114        assert_eq!(OhlcvBar::mean_volume(&[bar]), Some(dec!(200)));
6115    }
6116
6117    #[test]
6118    fn test_mean_volume_multiple_bars() {
6119        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6120        b1.volume = dec!(100);
6121        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6122        b2.volume = dec!(200);
6123        let mut b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6124        b3.volume = dec!(300);
6125        assert_eq!(OhlcvBar::mean_volume(&[b1, b2, b3]), Some(dec!(200)));
6126    }
6127
6128    // ── vwap_deviation ────────────────────────────────────────────────────────
6129
6130    #[test]
6131    fn test_vwap_deviation_none_when_vwap_not_set() {
6132        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6133        assert!(bar.vwap_deviation().is_none());
6134    }
6135
6136    #[test]
6137    fn test_vwap_deviation_zero_when_close_equals_vwap() {
6138        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6139        bar.vwap = Some(dec!(100));
6140        assert_eq!(bar.vwap_deviation(), Some(0.0));
6141    }
6142
6143    #[test]
6144    fn test_vwap_deviation_correct_value() {
6145        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6146        bar.vwap = Some(dec!(100));
6147        // |110-100|/100 = 0.10
6148        let dev = bar.vwap_deviation().unwrap();
6149        assert!((dev - 0.1).abs() < 1e-10);
6150    }
6151
6152    // ── high_close_ratio ──────────────────────────────────────────────────────
6153
6154    #[test]
6155    fn test_high_close_ratio_none_when_high_zero() {
6156        let bar = OhlcvBar {
6157            symbol: "X".into(),
6158            timeframe: Timeframe::Minutes(1),
6159            open: dec!(0),
6160            high: dec!(0),
6161            low: dec!(0),
6162            close: dec!(0),
6163            volume: dec!(1),
6164            bar_start_ms: 0,
6165            trade_count: 1,
6166            is_complete: false,
6167            is_gap_fill: false,
6168            vwap: None,
6169        };
6170        assert!(bar.high_close_ratio().is_none());
6171    }
6172
6173    #[test]
6174    fn test_high_close_ratio_one_when_close_equals_high() {
6175        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6176        let ratio = bar.high_close_ratio().unwrap();
6177        assert!((ratio - 1.0).abs() < 1e-10);
6178    }
6179
6180    #[test]
6181    fn test_high_close_ratio_less_than_one_when_close_below_high() {
6182        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(99));
6183        let ratio = bar.high_close_ratio().unwrap();
6184        assert!(ratio < 1.0);
6185    }
6186
6187    // ── lower_shadow_pct ──────────────────────────────────────────────────────
6188
6189    #[test]
6190    fn test_lower_shadow_pct_none_when_range_zero() {
6191        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6192        assert!(bar.lower_shadow_pct().is_none());
6193    }
6194
6195    #[test]
6196    fn test_lower_shadow_pct_zero_when_no_lower_shadow() {
6197        // open=low=90, close=high=110 → lower_shadow=0
6198        let bar = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
6199        let pct = bar.lower_shadow_pct().unwrap();
6200        assert!(pct.abs() < 1e-10);
6201    }
6202
6203    #[test]
6204    fn test_lower_shadow_pct_correct_value() {
6205        // open=100, close=105, high=110, low=90 → lower_shadow=min(100,105)-90=10, range=20 → 0.5
6206        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6207        let pct = bar.lower_shadow_pct().unwrap();
6208        assert!((pct - 0.5).abs() < 1e-10);
6209    }
6210
6211    // ── open_close_ratio ──────────────────────────────────────────────────────
6212
6213    #[test]
6214    fn test_open_close_ratio_none_when_open_zero() {
6215        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
6216        assert!(bar.open_close_ratio().is_none());
6217    }
6218
6219    #[test]
6220    fn test_open_close_ratio_one_when_flat() {
6221        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6222        let ratio = bar.open_close_ratio().unwrap();
6223        assert!((ratio - 1.0).abs() < 1e-10);
6224    }
6225
6226    #[test]
6227    fn test_open_close_ratio_above_one_for_bullish_bar() {
6228        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
6229        let ratio = bar.open_close_ratio().unwrap();
6230        assert!(ratio > 1.0);
6231    }
6232
6233    // ── is_wide_range_bar ─────────────────────────────────────────────────────
6234
6235    #[test]
6236    fn test_is_wide_range_bar_true_when_range_exceeds_threshold() {
6237        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110)); // range=25
6238        assert!(bar.is_wide_range_bar(dec!(20)));
6239    }
6240
6241    #[test]
6242    fn test_is_wide_range_bar_false_when_range_equals_threshold() {
6243        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(100), dec!(110)); // range=20
6244        assert!(!bar.is_wide_range_bar(dec!(20)));
6245    }
6246
6247    // ── close_to_low_ratio ────────────────────────────────────────────────────
6248
6249    #[test]
6250    fn test_close_to_low_ratio_none_when_range_zero() {
6251        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6252        assert!(bar.close_to_low_ratio().is_none());
6253    }
6254
6255    #[test]
6256    fn test_close_to_low_ratio_one_when_closed_at_high() {
6257        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6258        let ratio = bar.close_to_low_ratio().unwrap();
6259        assert!((ratio - 1.0).abs() < 1e-10);
6260    }
6261
6262    #[test]
6263    fn test_close_to_low_ratio_zero_when_closed_at_low() {
6264        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
6265        let ratio = bar.close_to_low_ratio().unwrap();
6266        assert!(ratio.abs() < 1e-10);
6267    }
6268
6269    #[test]
6270    fn test_close_to_low_ratio_half_at_midpoint() {
6271        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6272        // (100-90)/(110-90) = 10/20 = 0.5
6273        let ratio = bar.close_to_low_ratio().unwrap();
6274        assert!((ratio - 0.5).abs() < 1e-10);
6275    }
6276
6277    // ── volume_per_trade ──────────────────────────────────────────────────────
6278
6279    #[test]
6280    fn test_volume_per_trade_none_when_trade_count_zero() {
6281        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6282        bar.trade_count = 0;
6283        assert!(bar.volume_per_trade().is_none());
6284    }
6285
6286    #[test]
6287    fn test_volume_per_trade_correct_value() {
6288        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6289        bar.volume = dec!(500);
6290        bar.trade_count = 5;
6291        assert_eq!(bar.volume_per_trade(), Some(dec!(100)));
6292    }
6293
6294    // ── price_range_overlap ───────────────────────────────────────────────────
6295
6296    #[test]
6297    fn test_price_range_overlap_true_when_ranges_overlap() {
6298        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6299        let b = make_ohlcv_bar(dec!(105), dec!(120), dec!(95), dec!(110));
6300        assert!(a.price_range_overlap(&b));
6301    }
6302
6303    #[test]
6304    fn test_price_range_overlap_false_when_no_overlap() {
6305        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6306        let b = make_ohlcv_bar(dec!(120), dec!(130), dec!(115), dec!(125));
6307        assert!(!a.price_range_overlap(&b));
6308    }
6309
6310    #[test]
6311    fn test_price_range_overlap_true_at_exact_touch() {
6312        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6313        let b = make_ohlcv_bar(dec!(115), dec!(125), dec!(110), dec!(120));
6314        assert!(a.price_range_overlap(&b));
6315    }
6316
6317    // ── bar_height_pct ────────────────────────────────────────────────────────
6318
6319    #[test]
6320    fn test_bar_height_pct_none_when_open_zero() {
6321        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
6322        assert!(bar.bar_height_pct().is_none());
6323    }
6324
6325    #[test]
6326    fn test_bar_height_pct_correct_value() {
6327        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // range=20
6328        // 20/100 = 0.2
6329        let pct = bar.bar_height_pct().unwrap();
6330        assert!((pct - 0.2).abs() < 1e-10);
6331    }
6332
6333    // ── is_bullish_engulfing ──────────────────────────────────────────────────
6334
6335    #[test]
6336    fn test_is_bullish_engulfing_true_for_valid_pattern() {
6337        // prev: bearish bar (open=110, close=100), this: bullish, engulfs (open=98, close=112)
6338        let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
6339        let bar = make_ohlcv_bar(dec!(98), dec!(115), dec!(95), dec!(112));
6340        assert!(bar.is_bullish_engulfing(&prev));
6341    }
6342
6343    #[test]
6344    fn test_is_bullish_engulfing_false_when_bearish() {
6345        let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
6346        let bar = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(95));
6347        assert!(!bar.is_bullish_engulfing(&prev));
6348    }
6349
6350    // ── close_gap ─────────────────────────────────────────────────────────────
6351
6352    #[test]
6353    fn test_close_gap_positive_for_gap_up() {
6354        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
6355        let bar = make_ohlcv_bar(dec!(106), dec!(110), dec!(103), dec!(108)); // open=106 > prev close=102
6356        assert_eq!(bar.close_gap(&prev), dec!(4));
6357    }
6358
6359    #[test]
6360    fn test_close_gap_negative_for_gap_down() {
6361        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
6362        let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97)); // open=98 < prev close=102
6363        assert_eq!(bar.close_gap(&prev), dec!(-4));
6364    }
6365
6366    #[test]
6367    fn test_close_gap_zero_when_no_gap() {
6368        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
6369        let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108));
6370        assert_eq!(bar.close_gap(&prev), dec!(0));
6371    }
6372
6373    // ── close_above_midpoint ──────────────────────────────────────────────────
6374
6375    #[test]
6376    fn test_close_above_midpoint_true_when_above_mid() {
6377        // high=110, low=90 → mid=100; close=105 > 100
6378        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6379        assert!(bar.close_above_midpoint());
6380    }
6381
6382    #[test]
6383    fn test_close_above_midpoint_false_when_at_mid() {
6384        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // close=mid=100
6385        assert!(!bar.close_above_midpoint());
6386    }
6387
6388    // ── close_momentum ────────────────────────────────────────────────────────
6389
6390    #[test]
6391    fn test_close_momentum_positive_when_rising() {
6392        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
6393        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
6394        assert_eq!(bar.close_momentum(&prev), dec!(10));
6395    }
6396
6397    #[test]
6398    fn test_close_momentum_zero_when_unchanged() {
6399        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
6400        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(100));
6401        assert_eq!(bar.close_momentum(&prev), dec!(0));
6402    }
6403
6404    // ── bar_range ─────────────────────────────────────────────────────────────
6405
6406    #[test]
6407    fn test_bar_range_correct() {
6408        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
6409        assert_eq!(bar.bar_range(), dec!(30));
6410    }
6411
6412    // ── linear_regression_slope ───────────────────────────────────────────────
6413
6414    #[test]
6415    fn test_linear_regression_slope_none_for_single_bar() {
6416        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6417        assert!(OhlcvBar::linear_regression_slope(&[bar]).is_none());
6418    }
6419
6420    #[test]
6421    fn test_linear_regression_slope_positive_for_rising_closes() {
6422        let bars = vec![
6423            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6424            make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110)),
6425            make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(120)),
6426        ];
6427        let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
6428        assert!(slope > 0.0, "slope should be positive for rising closes");
6429    }
6430
6431    #[test]
6432    fn test_linear_regression_slope_negative_for_falling_closes() {
6433        let bars = vec![
6434            make_ohlcv_bar(dec!(120), dec!(125), dec!(115), dec!(120)),
6435            make_ohlcv_bar(dec!(120), dec!(115), dec!(105), dec!(110)),
6436            make_ohlcv_bar(dec!(110), dec!(108), dec!(95), dec!(100)),
6437        ];
6438        let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
6439        assert!(slope < 0.0, "slope should be negative for falling closes");
6440    }
6441
6442    #[test]
6443    fn test_linear_regression_slope_near_zero_for_flat_closes() {
6444        let bars = vec![
6445            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6446            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6447            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6448        ];
6449        let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
6450        assert!(slope.abs() < 1e-10, "slope should be ~0 for identical closes");
6451    }
6452
6453    // ── volume_slope ──────────────────────────────────────────────────────────
6454
6455    #[test]
6456    fn test_volume_slope_none_for_single_bar() {
6457        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6458        assert!(OhlcvBar::volume_slope(&[bar]).is_none());
6459    }
6460
6461    #[test]
6462    fn test_volume_slope_positive_for_rising_volume() {
6463        let make_bar_with_vol = |v: u64| {
6464            let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6465            b.volume = Decimal::from(v);
6466            b
6467        };
6468        let bars = vec![make_bar_with_vol(100), make_bar_with_vol(200), make_bar_with_vol(300)];
6469        assert!(OhlcvBar::volume_slope(&bars).unwrap() > 0.0);
6470    }
6471
6472    // ── highest_close / lowest_close ──────────────────────────────────────────
6473
6474    #[test]
6475    fn test_highest_close_none_for_empty_slice() {
6476        assert!(OhlcvBar::highest_close(&[]).is_none());
6477    }
6478
6479    #[test]
6480    fn test_highest_close_returns_max_close() {
6481        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6482        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
6483        let b3 = make_ohlcv_bar(dec!(100), dec!(108), dec!(90), dec!(102));
6484        assert_eq!(OhlcvBar::highest_close(&[b1, b2, b3]), Some(dec!(115)));
6485    }
6486
6487    #[test]
6488    fn test_lowest_close_none_for_empty_slice() {
6489        assert!(OhlcvBar::lowest_close(&[]).is_none());
6490    }
6491
6492    #[test]
6493    fn test_lowest_close_returns_min_close() {
6494        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6495        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
6496        let b3 = make_ohlcv_bar(dec!(100), dec!(108), dec!(90), dec!(102));
6497        assert_eq!(OhlcvBar::lowest_close(&[b1, b2, b3]), Some(dec!(102)));
6498    }
6499
6500    // ── close_range / momentum ────────────────────────────────────────────────
6501
6502    #[test]
6503    fn test_close_range_none_for_empty_slice() {
6504        assert!(OhlcvBar::close_range(&[]).is_none());
6505    }
6506
6507    #[test]
6508    fn test_close_range_correct() {
6509        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(102));
6510        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
6511        // highest=115, lowest=102, range=13
6512        assert_eq!(OhlcvBar::close_range(&[b1, b2]), Some(dec!(13)));
6513    }
6514
6515    #[test]
6516    fn test_momentum_none_for_insufficient_bars() {
6517        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6518        assert!(OhlcvBar::momentum(&[bar], 1).is_none());
6519    }
6520
6521    #[test]
6522    fn test_momentum_positive_for_rising_close() {
6523        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6524        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
6525        // (110 - 100) / 100 = 0.10
6526        let mom = OhlcvBar::momentum(&[b1, b2], 1).unwrap();
6527        assert!((mom - 0.1).abs() < 1e-10);
6528    }
6529
6530    #[test]
6531    fn test_momentum_negative_for_falling_close() {
6532        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6533        let b2 = make_ohlcv_bar(dec!(100), dec!(108), dec!(88), dec!(99));
6534        // (99 - 110) / 110 ≈ -0.10
6535        let mom = OhlcvBar::momentum(&[b1, b2], 1).unwrap();
6536        assert!(mom < 0.0);
6537    }
6538
6539    // ── mean_close ────────────────────────────────────────────────────────────
6540
6541    #[test]
6542    fn test_mean_close_none_for_empty_slice() {
6543        assert!(OhlcvBar::mean_close(&[]).is_none());
6544    }
6545
6546    #[test]
6547    fn test_mean_close_single_bar() {
6548        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6549        assert_eq!(OhlcvBar::mean_close(&[bar]), Some(dec!(105)));
6550    }
6551
6552    #[test]
6553    fn test_mean_close_multiple_bars() {
6554        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6555        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6556        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120));
6557        // (100 + 110 + 120) / 3 = 110
6558        assert_eq!(OhlcvBar::mean_close(&[b1, b2, b3]), Some(dec!(110)));
6559    }
6560
6561    // ── close_std_dev ─────────────────────────────────────────────────────────
6562
6563    #[test]
6564    fn test_close_std_dev_none_for_single_bar() {
6565        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6566        assert!(OhlcvBar::close_std_dev(&[bar]).is_none());
6567    }
6568
6569    #[test]
6570    fn test_close_std_dev_zero_for_identical_closes() {
6571        let bars = vec![
6572            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6573            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6574        ];
6575        let sd = OhlcvBar::close_std_dev(&bars).unwrap();
6576        assert!(sd.abs() < 1e-10, "std_dev should be ~0 for identical closes");
6577    }
6578
6579    #[test]
6580    fn test_close_std_dev_positive_for_varied_closes() {
6581        let bars = vec![
6582            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)),
6583            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
6584        ];
6585        assert!(OhlcvBar::close_std_dev(&bars).unwrap() > 0.0);
6586    }
6587
6588    // ── price_efficiency_ratio ────────────────────────────────────────────────
6589
6590    #[test]
6591    fn test_price_efficiency_ratio_none_for_single_bar() {
6592        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6593        assert!(OhlcvBar::price_efficiency_ratio(&[bar]).is_none());
6594    }
6595
6596    #[test]
6597    fn test_price_efficiency_ratio_one_for_trending_price() {
6598        // All bars with same range, monotonically rising closes
6599        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6600        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(110));
6601        let b3 = make_ohlcv_bar(dec!(120), dec!(130), dec!(110), dec!(120));
6602        // net move = 20, total path = 3 * 20 = 60; ratio = 20/60 ≈ 0.333
6603        let ratio = OhlcvBar::price_efficiency_ratio(&[b1, b2, b3]).unwrap();
6604        assert!(ratio > 0.0 && ratio <= 1.0);
6605    }
6606
6607    #[test]
6608    fn test_price_efficiency_ratio_none_for_zero_total_range() {
6609        // Zero-range bars (high == low)
6610        let bars = vec![
6611            make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)),
6612            make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)),
6613        ];
6614        assert!(OhlcvBar::price_efficiency_ratio(&bars).is_none());
6615    }
6616
6617    // ── close_location_value / mean_clv ───────────────────────────────────────
6618
6619    #[test]
6620    fn test_clv_plus_one_when_close_at_high() {
6621        // close == high: CLV = ((high-low)-(0)) / (high-low) = 1
6622        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
6623        let clv = bar.close_location_value().unwrap();
6624        assert!((clv - 1.0).abs() < 1e-10, "CLV should be 1.0 when close == high, got {clv}");
6625    }
6626
6627    #[test]
6628    fn test_clv_minus_one_when_close_at_low() {
6629        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
6630        let clv = bar.close_location_value().unwrap();
6631        assert!((clv + 1.0).abs() < 1e-10, "CLV should be -1.0 when close == low, got {clv}");
6632    }
6633
6634    #[test]
6635    fn test_clv_zero_when_close_at_midpoint() {
6636        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6637        let clv = bar.close_location_value().unwrap();
6638        assert!(clv.abs() < 1e-10, "CLV should be 0 at midpoint, got {clv}");
6639    }
6640
6641    #[test]
6642    fn test_clv_none_for_zero_range_bar() {
6643        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
6644        assert!(bar.close_location_value().is_none());
6645    }
6646
6647    #[test]
6648    fn test_mean_clv_none_for_empty_slice() {
6649        assert!(OhlcvBar::mean_clv(&[]).is_none());
6650    }
6651
6652    #[test]
6653    fn test_mean_clv_positive_for_bullish_closes() {
6654        let bars = vec![
6655            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)), // close near high
6656            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(106)), // close above mid
6657        ];
6658        let clv = OhlcvBar::mean_clv(&bars).unwrap();
6659        assert!(clv > 0.0, "mean CLV should be positive when closes are near highs");
6660    }
6661
6662    #[test]
6663    fn test_mean_range_none_for_empty_slice() {
6664        assert!(OhlcvBar::mean_range(&[]).is_none());
6665    }
6666
6667    #[test]
6668    fn test_mean_range_single_bar() {
6669        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6670        assert_eq!(OhlcvBar::mean_range(&[bar]), Some(dec!(20)));
6671    }
6672
6673    #[test]
6674    fn test_mean_range_multiple_bars() {
6675        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range 20
6676        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100)); // range 40
6677        assert_eq!(OhlcvBar::mean_range(&[b1, b2]), Some(dec!(30)));
6678    }
6679
6680    #[test]
6681    fn test_close_z_score_none_for_empty_slice() {
6682        assert!(OhlcvBar::close_z_score(&[], dec!(100)).is_none());
6683    }
6684
6685    #[test]
6686    fn test_close_z_score_of_mean_is_zero() {
6687        let bars = vec![
6688            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6689            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6690            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
6691        ];
6692        // mean close = (100+100+110)/3 ≈ 103.33; z-score of mean should be ≈ 0
6693        let mean = (dec!(100) + dec!(100) + dec!(110)) / dec!(3);
6694        let z = OhlcvBar::close_z_score(&bars, mean).unwrap();
6695        assert!(z.abs() < 1e-6);
6696    }
6697
6698    #[test]
6699    fn test_close_z_score_positive_above_mean() {
6700        let bars = vec![
6701            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6702            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6703            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
6704        ];
6705        let z = OhlcvBar::close_z_score(&bars, dec!(120)).unwrap();
6706        assert!(z > 0.0);
6707    }
6708
6709    #[test]
6710    fn test_bollinger_band_width_none_for_empty_slice() {
6711        assert!(OhlcvBar::bollinger_band_width(&[]).is_none());
6712    }
6713
6714    #[test]
6715    fn test_bollinger_band_width_zero_for_identical_closes() {
6716        let bars = vec![
6717            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6718            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6719        ];
6720        assert_eq!(OhlcvBar::bollinger_band_width(&bars), Some(0.0));
6721    }
6722
6723    #[test]
6724    fn test_bollinger_band_width_positive_for_varying_closes() {
6725        let bars = vec![
6726            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)),
6727            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6728            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
6729        ];
6730        let bw = OhlcvBar::bollinger_band_width(&bars).unwrap();
6731        assert!(bw > 0.0);
6732    }
6733
6734    #[test]
6735    fn test_up_down_ratio_none_for_no_bearish_bars() {
6736        let bars = vec![
6737            make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105)), // bullish
6738        ];
6739        assert!(OhlcvBar::up_down_ratio(&bars).is_none());
6740    }
6741
6742    #[test]
6743    fn test_up_down_ratio_two_to_one() {
6744        let bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
6745        let bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
6746        let bars = vec![bull.clone(), bull, bear];
6747        let ratio = OhlcvBar::up_down_ratio(&bars).unwrap();
6748        assert!((ratio - 2.0).abs() < 1e-9);
6749    }
6750
6751    // ── OhlcvBar::volume_weighted_close ───────────────────────────────────────
6752
6753    #[test]
6754    fn test_volume_weighted_close_none_for_empty_slice() {
6755        assert!(OhlcvBar::volume_weighted_close(&[]).is_none());
6756    }
6757
6758    #[test]
6759    fn test_volume_weighted_close_single_bar() {
6760        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6761        bar.volume = dec!(10);
6762        assert_eq!(OhlcvBar::volume_weighted_close(&[bar]), Some(dec!(105)));
6763    }
6764
6765    #[test]
6766    fn test_volume_weighted_close_weights_by_volume() {
6767        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6768        b1.volume = dec!(1);
6769        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
6770        b2.volume = dec!(3);
6771        // vwc = (100*1 + 200*3) / (1+3) = 700 / 4 = 175
6772        assert_eq!(OhlcvBar::volume_weighted_close(&[b1, b2]), Some(dec!(175)));
6773    }
6774
6775    // ── OhlcvBar::rolling_return ──────────────────────────────────────────────
6776
6777    #[test]
6778    fn test_rolling_return_none_for_empty_slice() {
6779        assert!(OhlcvBar::rolling_return(&[]).is_none());
6780    }
6781
6782    #[test]
6783    fn test_rolling_return_none_for_single_bar() {
6784        assert!(OhlcvBar::rolling_return(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]).is_none());
6785    }
6786
6787    #[test]
6788    fn test_rolling_return_positive_when_close_rises() {
6789        let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
6790        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
6791        let ret = OhlcvBar::rolling_return(&[b1, b2]).unwrap();
6792        assert!((ret - 0.1).abs() < 1e-9);
6793    }
6794
6795    // ── OhlcvBar::average_high / average_low ─────────────────────────────────
6796
6797    #[test]
6798    fn test_average_high_none_for_empty_slice() {
6799        assert!(OhlcvBar::average_high(&[]).is_none());
6800    }
6801
6802    #[test]
6803    fn test_average_high_single_bar() {
6804        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105));
6805        assert_eq!(OhlcvBar::average_high(&[bar]), Some(dec!(120)));
6806    }
6807
6808    #[test]
6809    fn test_average_high_multiple_bars() {
6810        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6811        let b2 = make_ohlcv_bar(dec!(100), dec!(130), dec!(90), dec!(105));
6812        assert_eq!(OhlcvBar::average_high(&[b1, b2]), Some(dec!(120)));
6813    }
6814
6815    #[test]
6816    fn test_average_low_none_for_empty_slice() {
6817        assert!(OhlcvBar::average_low(&[]).is_none());
6818    }
6819
6820    #[test]
6821    fn test_average_low_single_bar() {
6822        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(105));
6823        assert_eq!(OhlcvBar::average_low(&[bar]), Some(dec!(80)));
6824    }
6825
6826    #[test]
6827    fn test_average_low_multiple_bars() {
6828        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(80), dec!(105));
6829        let b2 = make_ohlcv_bar(dec!(100), dec!(130), dec!(60), dec!(105));
6830        assert_eq!(OhlcvBar::average_low(&[b1, b2]), Some(dec!(70)));
6831    }
6832
6833    // ── OhlcvBar::min_body / max_body ─────────────────────────────────────────
6834
6835    #[test]
6836    fn test_min_body_none_for_empty_slice() {
6837        assert!(OhlcvBar::min_body(&[]).is_none());
6838    }
6839
6840    #[test]
6841    fn test_min_body_returns_smallest_body() {
6842        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // body=5
6843        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(115)); // body=15
6844        assert_eq!(OhlcvBar::min_body(&[b1, b2]), Some(dec!(5)));
6845    }
6846
6847    #[test]
6848    fn test_max_body_none_for_empty_slice() {
6849        assert!(OhlcvBar::max_body(&[]).is_none());
6850    }
6851
6852    #[test]
6853    fn test_max_body_returns_largest_body() {
6854        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // body=5
6855        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(115)); // body=15
6856        assert_eq!(OhlcvBar::max_body(&[b1, b2]), Some(dec!(15)));
6857    }
6858
6859    // ── OhlcvBar::atr_pct ────────────────────────────────────────────────────
6860
6861    #[test]
6862    fn test_atr_pct_none_for_single_bar() {
6863        assert!(OhlcvBar::atr_pct(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]).is_none());
6864    }
6865
6866    #[test]
6867    fn test_atr_pct_positive_for_normal_bars() {
6868        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6869        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
6870        let pct = OhlcvBar::atr_pct(&[b1, b2]).unwrap();
6871        assert!(pct > 0.0);
6872    }
6873
6874    // ── OhlcvBar::breakout_count ──────────────────────────────────────────────
6875
6876    #[test]
6877    fn test_breakout_count_zero_for_empty_slice() {
6878        assert_eq!(OhlcvBar::breakout_count(&[]), 0);
6879    }
6880
6881    #[test]
6882    fn test_breakout_count_zero_for_single_bar() {
6883        assert_eq!(OhlcvBar::breakout_count(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), 0);
6884    }
6885
6886    #[test]
6887    fn test_breakout_count_detects_close_above_prev_high() {
6888        // b1: high=110; b2: close=115 > 110 → breakout
6889        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6890        let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(105), dec!(115));
6891        assert_eq!(OhlcvBar::breakout_count(&[b1, b2]), 1);
6892    }
6893
6894    #[test]
6895    fn test_breakout_count_zero_when_close_at_prev_high() {
6896        // close == prev high → not a strict breakout
6897        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
6898        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(110));
6899        assert_eq!(OhlcvBar::breakout_count(&[b1, b2]), 0);
6900    }
6901
6902    // ── OhlcvBar::doji_count ──────────────────────────────────────────────────
6903
6904    #[test]
6905    fn test_doji_count_zero_for_empty_slice() {
6906        assert_eq!(OhlcvBar::doji_count(&[], dec!(0.001)), 0);
6907    }
6908
6909    #[test]
6910    fn test_doji_count_detects_doji_bars() {
6911        let doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // body=0
6912        let non_doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)); // body=10
6913        assert_eq!(OhlcvBar::doji_count(&[doji, non_doji], dec!(1)), 1);
6914    }
6915
6916    // ── OhlcvBar::channel_width ───────────────────────────────────────────────
6917
6918    #[test]
6919    fn test_channel_width_none_for_empty_slice() {
6920        assert!(OhlcvBar::channel_width(&[]).is_none());
6921    }
6922
6923    #[test]
6924    fn test_channel_width_correct() {
6925        let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105));
6926        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(80), dec!(100));
6927        // highest_high = 120, lowest_low = 80, width = 40
6928        assert_eq!(OhlcvBar::channel_width(&[b1, b2]), Some(dec!(40)));
6929    }
6930
6931    // ── OhlcvBar::sma ─────────────────────────────────────────────────────────
6932
6933    #[test]
6934    fn test_sma_none_for_zero_period() {
6935        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6936        assert!(OhlcvBar::sma(&[bar], 0).is_none());
6937    }
6938
6939    #[test]
6940    fn test_sma_none_when_fewer_bars_than_period() {
6941        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6942        assert!(OhlcvBar::sma(&[bar], 3).is_none());
6943    }
6944
6945    #[test]
6946    fn test_sma_correct_for_last_n_bars() {
6947        let bars = vec![
6948            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
6949            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
6950            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120)),
6951        ];
6952        // sma(3) = (100 + 110 + 120) / 3 = 110
6953        assert_eq!(OhlcvBar::sma(&bars, 3), Some(dec!(110)));
6954    }
6955
6956    // ── OhlcvBar::mean_wick_ratio ─────────────────────────────────────────────
6957
6958    #[test]
6959    fn test_mean_wick_ratio_none_for_empty_slice() {
6960        assert!(OhlcvBar::mean_wick_ratio(&[]).is_none());
6961    }
6962
6963    #[test]
6964    fn test_mean_wick_ratio_in_range_zero_to_one() {
6965        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
6966        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(100));
6967        let ratio = OhlcvBar::mean_wick_ratio(&[b1, b2]).unwrap();
6968        assert!(ratio >= 0.0 && ratio <= 1.0);
6969    }
6970
6971    // ── OhlcvBar::bullish_volume / bearish_volume ─────────────────────────────
6972
6973    #[test]
6974    fn test_bullish_volume_zero_for_empty_slice() {
6975        assert_eq!(OhlcvBar::bullish_volume(&[]), dec!(0));
6976    }
6977
6978    #[test]
6979    fn test_bullish_volume_sums_bullish_bars() {
6980        let mut bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
6981        bull.volume = dec!(100);
6982        let mut bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
6983        bear.volume = dec!(50);
6984        assert_eq!(OhlcvBar::bullish_volume(&[bull, bear]), dec!(100));
6985    }
6986
6987    #[test]
6988    fn test_bearish_volume_zero_for_empty_slice() {
6989        assert_eq!(OhlcvBar::bearish_volume(&[]), dec!(0));
6990    }
6991
6992    #[test]
6993    fn test_bearish_volume_sums_bearish_bars() {
6994        let mut bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
6995        bull.volume = dec!(100);
6996        let mut bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
6997        bear.volume = dec!(50);
6998        assert_eq!(OhlcvBar::bearish_volume(&[bull, bear]), dec!(50));
6999    }
7000
7001    // ── OhlcvBar::close_above_mid_count ──────────────────────────────────────
7002
7003    #[test]
7004    fn test_close_above_mid_count_zero_for_empty_slice() {
7005        assert_eq!(OhlcvBar::close_above_mid_count(&[]), 0);
7006    }
7007
7008    #[test]
7009    fn test_close_above_mid_count_correct() {
7010        let above_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110)); // mid=100, close=110 > 100
7011        let at_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100)); // mid=100, close=100 not > 100
7012        let below_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(85)); // mid=100, close=85 < 100
7013        assert_eq!(OhlcvBar::close_above_mid_count(&[above_mid, at_mid, below_mid]), 1);
7014    }
7015
7016    // ── OhlcvBar::ema ─────────────────────────────────────────────────────────
7017
7018    #[test]
7019    fn test_ema_none_for_empty_slice() {
7020        assert!(OhlcvBar::ema(&[], 0.5).is_none());
7021    }
7022
7023    #[test]
7024    fn test_ema_single_bar_equals_close() {
7025        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7026        let e = OhlcvBar::ema(&[bar], 0.5).unwrap();
7027        assert!((e - 105.0).abs() < 1e-9);
7028    }
7029
7030    #[test]
7031    fn test_ema_alpha_one_equals_last_close() {
7032        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7033        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
7034        let e = OhlcvBar::ema(&[b1, b2], 1.0).unwrap();
7035        assert!((e - 200.0).abs() < 1e-9);
7036    }
7037
7038    // ── OhlcvBar::highest_open / lowest_open ─────────────────────────────────
7039
7040    #[test]
7041    fn test_highest_open_none_for_empty_slice() {
7042        assert!(OhlcvBar::highest_open(&[]).is_none());
7043    }
7044
7045    #[test]
7046    fn test_highest_open_returns_max() {
7047        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7048        let b2 = make_ohlcv_bar(dec!(130), dec!(140), dec!(120), dec!(135));
7049        assert_eq!(OhlcvBar::highest_open(&[b1, b2]), Some(dec!(130)));
7050    }
7051
7052    #[test]
7053    fn test_lowest_open_none_for_empty_slice() {
7054        assert!(OhlcvBar::lowest_open(&[]).is_none());
7055    }
7056
7057    #[test]
7058    fn test_lowest_open_returns_min() {
7059        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7060        let b2 = make_ohlcv_bar(dec!(130), dec!(140), dec!(120), dec!(135));
7061        assert_eq!(OhlcvBar::lowest_open(&[b1, b2]), Some(dec!(100)));
7062    }
7063
7064    // ── OhlcvBar::rising_close_count ─────────────────────────────────────────
7065
7066    #[test]
7067    fn test_rising_close_count_zero_for_empty_slice() {
7068        assert_eq!(OhlcvBar::rising_close_count(&[]), 0);
7069    }
7070
7071    #[test]
7072    fn test_rising_close_count_zero_for_single_bar() {
7073        assert_eq!(OhlcvBar::rising_close_count(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), 0);
7074    }
7075
7076    #[test]
7077    fn test_rising_close_count_correct() {
7078        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7079        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(110)); // close > prev
7080        let b3 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(105)); // close < prev
7081        let b4 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(115)); // close > prev
7082        assert_eq!(OhlcvBar::rising_close_count(&[b1, b2, b3, b4]), 2);
7083    }
7084
7085    // ── OhlcvBar::mean_body_ratio ─────────────────────────────────────────────
7086
7087    #[test]
7088    fn test_mean_body_ratio_none_for_empty_slice() {
7089        assert!(OhlcvBar::mean_body_ratio(&[]).is_none());
7090    }
7091
7092    #[test]
7093    fn test_mean_body_ratio_in_range_zero_to_one() {
7094        let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
7095        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
7096        let ratio = OhlcvBar::mean_body_ratio(&[b1, b2]).unwrap();
7097        assert!(ratio >= 0.0 && ratio <= 1.0);
7098    }
7099
7100    // ── OhlcvBar::volume_std_dev ──────────────────────────────────────────────
7101
7102    #[test]
7103    fn test_volume_std_dev_none_for_single_bar() {
7104        let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7105        b.volume = dec!(100);
7106        assert!(OhlcvBar::volume_std_dev(&[b]).is_none());
7107    }
7108
7109    #[test]
7110    fn test_volume_std_dev_zero_for_identical_volumes() {
7111        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(50);
7112        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(50);
7113        assert_eq!(OhlcvBar::volume_std_dev(&[b1, b2]), Some(0.0));
7114    }
7115
7116    #[test]
7117    fn test_volume_std_dev_positive_for_varied_volumes() {
7118        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
7119        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
7120        let std = OhlcvBar::volume_std_dev(&[b1, b2]).unwrap();
7121        assert!(std > 0.0);
7122    }
7123
7124    // ── OhlcvBar::max_volume_bar / min_volume_bar ─────────────────────────────
7125
7126    #[test]
7127    fn test_max_volume_bar_none_for_empty_slice() {
7128        assert!(OhlcvBar::max_volume_bar(&[]).is_none());
7129    }
7130
7131    #[test]
7132    fn test_max_volume_bar_returns_highest_volume() {
7133        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
7134        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
7135        let bars = [b1, b2];
7136        let bar = OhlcvBar::max_volume_bar(&bars).unwrap();
7137        assert_eq!(bar.volume, dec!(100));
7138    }
7139
7140    #[test]
7141    fn test_min_volume_bar_returns_lowest_volume() {
7142        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
7143        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
7144        let bars = [b1, b2];
7145        let bar = OhlcvBar::min_volume_bar(&bars).unwrap();
7146        assert_eq!(bar.volume, dec!(10));
7147    }
7148
7149    // ── OhlcvBar::gap_sum ─────────────────────────────────────────────────────
7150
7151    #[test]
7152    fn test_gap_sum_zero_for_empty_slice() {
7153        assert_eq!(OhlcvBar::gap_sum(&[]), dec!(0));
7154    }
7155
7156    #[test]
7157    fn test_gap_sum_zero_for_single_bar() {
7158        assert_eq!(OhlcvBar::gap_sum(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), dec!(0));
7159    }
7160
7161    #[test]
7162    fn test_gap_sum_positive_for_gap_up_sequence() {
7163        // b1 close=100, b2 open=110 → gap = +10
7164        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7165        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
7166        assert_eq!(OhlcvBar::gap_sum(&[b1, b2]), dec!(10));
7167    }
7168
7169    #[test]
7170    fn test_gap_sum_negative_for_gap_down_sequence() {
7171        // b1 close=100, b2 open=90 → gap = -10
7172        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7173        let b2 = make_ohlcv_bar(dec!(90), dec!(95), dec!(80), dec!(85));
7174        assert_eq!(OhlcvBar::gap_sum(&[b1, b2]), dec!(-10));
7175    }
7176
7177    // ── OhlcvBar::three_white_soldiers ────────────────────────────────────────
7178
7179    #[test]
7180    fn test_three_white_soldiers_false_for_fewer_than_3_bars() {
7181        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7182        assert!(!OhlcvBar::three_white_soldiers(&[b]));
7183    }
7184
7185    #[test]
7186    fn test_three_white_soldiers_true_for_classic_pattern() {
7187        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
7188        let b2 = make_ohlcv_bar(dec!(112), dec!(128), dec!(110), dec!(125));
7189        let b3 = make_ohlcv_bar(dec!(125), dec!(142), dec!(123), dec!(140));
7190        assert!(OhlcvBar::three_white_soldiers(&[b1, b2, b3]));
7191    }
7192
7193    #[test]
7194    fn test_three_white_soldiers_false_for_bearish_bar_in_sequence() {
7195        // b2 is bearish (close < open)
7196        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
7197        let b2 = make_ohlcv_bar(dec!(115), dec!(120), dec!(105), dec!(108));
7198        let b3 = make_ohlcv_bar(dec!(108), dec!(130), dec!(106), dec!(128));
7199        assert!(!OhlcvBar::three_white_soldiers(&[b1, b2, b3]));
7200    }
7201
7202    // ── OhlcvBar::three_black_crows ───────────────────────────────────────────
7203
7204    #[test]
7205    fn test_three_black_crows_false_for_fewer_than_3_bars() {
7206        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
7207        assert!(!OhlcvBar::three_black_crows(&[b]));
7208    }
7209
7210    #[test]
7211    fn test_three_black_crows_true_for_classic_pattern() {
7212        let b1 = make_ohlcv_bar(dec!(140), dec!(142), dec!(110), dec!(112));
7213        let b2 = make_ohlcv_bar(dec!(112), dec!(114), dec!(95), dec!(97));
7214        let b3 = make_ohlcv_bar(dec!(97), dec!(99), dec!(80), dec!(82));
7215        assert!(OhlcvBar::three_black_crows(&[b1, b2, b3]));
7216    }
7217
7218    #[test]
7219    fn test_three_black_crows_false_for_bullish_bar_in_sequence() {
7220        // b2 is bullish
7221        let b1 = make_ohlcv_bar(dec!(140), dec!(142), dec!(110), dec!(112));
7222        let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(106), dec!(118));
7223        let b3 = make_ohlcv_bar(dec!(115), dec!(116), dec!(90), dec!(92));
7224        assert!(!OhlcvBar::three_black_crows(&[b1, b2, b3]));
7225    }
7226
7227    // ── OhlcvBar::is_gap_bar ─────────────────────────────────────────────────
7228
7229    #[test]
7230    fn test_is_gap_bar_true_when_open_differs_from_prev_close() {
7231        let bar = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(110));
7232        assert!(OhlcvBar::is_gap_bar(&bar, dec!(100)));
7233    }
7234
7235    #[test]
7236    fn test_is_gap_bar_false_when_open_equals_prev_close() {
7237        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
7238        assert!(!OhlcvBar::is_gap_bar(&bar, dec!(100)));
7239    }
7240
7241    // ── OhlcvBar::gap_bars_count ──────────────────────────────────────────────
7242
7243    #[test]
7244    fn test_gap_bars_count_zero_for_empty_slice() {
7245        assert_eq!(OhlcvBar::gap_bars_count(&[]), 0);
7246    }
7247
7248    #[test]
7249    fn test_gap_bars_count_zero_when_no_gaps() {
7250        // b1 close=100, b2 open=100 → no gap
7251        let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
7252        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
7253        assert_eq!(OhlcvBar::gap_bars_count(&[b1, b2]), 0);
7254    }
7255
7256    #[test]
7257    fn test_gap_bars_count_counts_all_gaps() {
7258        let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
7259        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); // gap: open=105 != 100
7260        let b3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115)); // no gap
7261        let b4 = make_ohlcv_bar(dec!(120), dec!(130), dec!(118), dec!(128)); // gap: open=120 != 115
7262        assert_eq!(OhlcvBar::gap_bars_count(&[b1, b2, b3, b4]), 2);
7263    }
7264
7265    // ── OhlcvBar::inside_bar / outside_bar (instance method) ─────────────────
7266
7267    #[test]
7268    fn test_inside_bar_true_when_range_inside_prior_v2() {
7269        let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
7270        let bar   = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(108));
7271        assert!(bar.inside_bar(&prior));
7272    }
7273
7274    #[test]
7275    fn test_inside_bar_false_when_high_exceeds_prior_v2() {
7276        let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
7277        let bar   = make_ohlcv_bar(dec!(105), dec!(125), dec!(90), dec!(118));
7278        assert!(!bar.inside_bar(&prior));
7279    }
7280
7281    #[test]
7282    fn test_outside_bar_true_when_range_engulfs_prior_v2() {
7283        let prior = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(108));
7284        let bar   = make_ohlcv_bar(dec!(95), dec!(120), dec!(85), dec!(112));
7285        assert!(bar.outside_bar(&prior));
7286    }
7287
7288    #[test]
7289    fn test_outside_bar_false_when_range_is_inside_v2() {
7290        let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
7291        let bar   = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(108));
7292        assert!(!bar.outside_bar(&prior));
7293    }
7294
7295    // ── OhlcvBar::bar_efficiency ──────────────────────────────────────────────
7296
7297    #[test]
7298    fn test_bar_efficiency_none_for_zero_range_bar() {
7299        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
7300        assert!(OhlcvBar::bar_efficiency(&bar).is_none());
7301    }
7302
7303    #[test]
7304    fn test_bar_efficiency_one_for_full_trend_bar() {
7305        // open=100, high=110, low=100, close=110 → body=10, range=10 → efficiency=1.0
7306        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
7307        let eff = OhlcvBar::bar_efficiency(&bar).unwrap();
7308        assert!((eff - 1.0).abs() < 1e-9);
7309    }
7310
7311    #[test]
7312    fn test_bar_efficiency_between_zero_and_one() {
7313        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(108));
7314        let eff = OhlcvBar::bar_efficiency(&bar).unwrap();
7315        assert!(eff >= 0.0 && eff <= 1.0);
7316    }
7317
7318    // ── OhlcvBar::wicks_sum ───────────────────────────────────────────────────
7319
7320    #[test]
7321    fn test_wicks_sum_zero_for_empty_slice() {
7322        assert_eq!(OhlcvBar::wicks_sum(&[]), dec!(0));
7323    }
7324
7325    #[test]
7326    fn test_wicks_sum_correct_for_doji_like_bar() {
7327        // open=close=100, high=110, low=90 → upper=10, lower=10, total=20
7328        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7329        assert_eq!(OhlcvBar::wicks_sum(&[bar]), dec!(20));
7330    }
7331
7332    // ── OhlcvBar::avg_close_to_high ───────────────────────────────────────────
7333
7334    #[test]
7335    fn test_avg_close_to_high_none_for_empty_slice() {
7336        assert!(OhlcvBar::avg_close_to_high(&[]).is_none());
7337    }
7338
7339    #[test]
7340    fn test_avg_close_to_high_correct_for_two_bars() {
7341        // b1: high=110, close=105 → 5; b2: high=120, close=115 → 5; avg=5.0
7342        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(105));
7343        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115));
7344        let avg = OhlcvBar::avg_close_to_high(&[b1, b2]).unwrap();
7345        assert!((avg - 5.0).abs() < 1e-9);
7346    }
7347
7348    // ── OhlcvBar::avg_range ───────────────────────────────────────────────────
7349
7350    #[test]
7351    fn test_avg_range_r65_none_for_empty() {
7352        assert!(OhlcvBar::avg_range(&[]).is_none());
7353    }
7354
7355    #[test]
7356    fn test_avg_range_r65_correct() {
7357        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7358        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115));
7359        let avg = OhlcvBar::avg_range(&[b1, b2]).unwrap();
7360        assert!((avg - 20.0).abs() < 1e-9);
7361    }
7362
7363    // ── OhlcvBar::max_close / min_close ───────────────────────────────────────
7364
7365    #[test]
7366    fn test_max_close_r65_none_empty() {
7367        assert!(OhlcvBar::max_close(&[]).is_none());
7368    }
7369
7370    #[test]
7371    fn test_max_close_r65_highest() {
7372        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
7373        let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(108), dec!(125));
7374        let b3 = make_ohlcv_bar(dec!(115), dec!(120), dec!(112), dec!(118));
7375        assert_eq!(OhlcvBar::max_close(&[b1, b2, b3]), Some(dec!(125)));
7376    }
7377
7378    #[test]
7379    fn test_min_close_r65_lowest() {
7380        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
7381        let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(108), dec!(125));
7382        let b3 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(95));
7383        assert_eq!(OhlcvBar::min_close(&[b1, b2, b3]), Some(dec!(95)));
7384    }
7385
7386    // ── OhlcvBar::trend_strength ──────────────────────────────────────────────
7387
7388    #[test]
7389    fn test_trend_strength_r65_none_single() {
7390        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7391        assert!(OhlcvBar::trend_strength(&[b]).is_none());
7392    }
7393
7394    #[test]
7395    fn test_trend_strength_r65_one_bullish() {
7396        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(105));
7397        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115));
7398        let b3 = make_ohlcv_bar(dec!(115), dec!(130), dec!(113), dec!(128));
7399        let s = OhlcvBar::trend_strength(&[b1, b2, b3]).unwrap();
7400        assert!((s - 1.0).abs() < 1e-9);
7401    }
7402
7403    #[test]
7404    fn test_trend_strength_r65_zero_bearish() {
7405        let b1 = make_ohlcv_bar(dec!(128), dec!(130), dec!(113), dec!(128));
7406        let b2 = make_ohlcv_bar(dec!(115), dec!(120), dec!(103), dec!(110));
7407        let b3 = make_ohlcv_bar(dec!(105), dec!(110), dec!(95), dec!(100));
7408        let s = OhlcvBar::trend_strength(&[b1, b2, b3]).unwrap();
7409        assert!((s - 0.0).abs() < 1e-9);
7410    }
7411
7412    // ── OhlcvBar::net_change ──────────────────────────────────────────────────
7413
7414    #[test]
7415    fn test_net_change_none_for_empty() {
7416        assert!(OhlcvBar::net_change(&[]).is_none());
7417    }
7418
7419    #[test]
7420    fn test_net_change_positive_for_bullish_bar() {
7421        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
7422        assert_eq!(OhlcvBar::net_change(&[b]), Some(dec!(12)));
7423    }
7424
7425    #[test]
7426    fn test_net_change_negative_for_bearish_bar() {
7427        let b = make_ohlcv_bar(dec!(110), dec!(112), dec!(95), dec!(100));
7428        assert_eq!(OhlcvBar::net_change(&[b]), Some(dec!(-10)));
7429    }
7430
7431    // ── OhlcvBar::open_to_close_pct ───────────────────────────────────────────
7432
7433    #[test]
7434    fn test_open_to_close_pct_none_for_empty() {
7435        assert!(OhlcvBar::open_to_close_pct(&[]).is_none());
7436    }
7437
7438    #[test]
7439    fn test_open_to_close_pct_correct() {
7440        // open=100, close=110 → 10%
7441        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
7442        let pct = OhlcvBar::open_to_close_pct(&[b]).unwrap();
7443        assert!((pct - 10.0).abs() < 1e-9);
7444    }
7445
7446    // ── OhlcvBar::high_to_low_pct ─────────────────────────────────────────────
7447
7448    #[test]
7449    fn test_high_to_low_pct_none_for_empty() {
7450        assert!(OhlcvBar::high_to_low_pct(&[]).is_none());
7451    }
7452
7453    #[test]
7454    fn test_high_to_low_pct_correct() {
7455        // high=200, low=100 → 50%
7456        let b = make_ohlcv_bar(dec!(150), dec!(200), dec!(100), dec!(160));
7457        let pct = OhlcvBar::high_to_low_pct(&[b]).unwrap();
7458        assert!((pct - 50.0).abs() < 1e-9);
7459    }
7460
7461    // ── OhlcvBar::consecutive_highs / consecutive_lows ───────────────────────
7462
7463    #[test]
7464    fn test_consecutive_highs_zero_for_single_bar() {
7465        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7466        assert_eq!(OhlcvBar::consecutive_highs(&[b]), 0);
7467    }
7468
7469    #[test]
7470    fn test_consecutive_highs_counts_trailing_highs() {
7471        // bars with rising highs: 110, 120, 130 → 2 consecutive from end
7472        let b1 = make_ohlcv_bar(dec!(95), dec!(110), dec!(90), dec!(105));
7473        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115));
7474        let b3 = make_ohlcv_bar(dec!(115), dec!(130), dec!(113), dec!(125));
7475        assert_eq!(OhlcvBar::consecutive_highs(&[b1, b2, b3]), 2);
7476    }
7477
7478    #[test]
7479    fn test_consecutive_lows_counts_trailing_lows() {
7480        // bars with falling lows: 90, 80, 70 → 2 consecutive from end
7481        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7482        let b2 = make_ohlcv_bar(dec!(95), dec!(108), dec!(80), dec!(100));
7483        let b3 = make_ohlcv_bar(dec!(90), dec!(102), dec!(70), dec!(95));
7484        assert_eq!(OhlcvBar::consecutive_lows(&[b1, b2, b3]), 2);
7485    }
7486
7487    // ── OhlcvBar::volume_change_pct ───────────────────────────────────────────
7488
7489    #[test]
7490    fn test_volume_change_pct_none_for_single_bar() {
7491        let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7492        b.volume = dec!(100);
7493        assert!(OhlcvBar::volume_change_pct(&[b]).is_none());
7494    }
7495
7496    #[test]
7497    fn test_volume_change_pct_correct() {
7498        // prior vol=100, current vol=150 → +50%
7499        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
7500        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(150);
7501        let pct = OhlcvBar::volume_change_pct(&[b1, b2]).unwrap();
7502        assert!((pct - 50.0).abs() < 1e-9);
7503    }
7504
7505    // ── OhlcvBar::close_location_value (instance method) ─────────────────────
7506
7507    #[test]
7508    fn test_clv_r67_plus_one_at_high() {
7509        // symmetric CLV: +1 when close=high
7510        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
7511        let clv = b.close_location_value().unwrap();
7512        assert!((clv - 1.0).abs() < 1e-9);
7513    }
7514
7515    #[test]
7516    fn test_clv_r67_minus_one_at_low() {
7517        // symmetric CLV: -1 when close=low
7518        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
7519        let clv = b.close_location_value().unwrap();
7520        assert!((clv - (-1.0)).abs() < 1e-9);
7521    }
7522
7523    #[test]
7524    fn test_clv_r67_none_for_zero_range() {
7525        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
7526        assert!(b.close_location_value().is_none());
7527    }
7528
7529    // ── OhlcvBar::body_pct (instance method) ──────────────────────────────────
7530
7531    #[test]
7532    fn test_body_pct_r67_none_for_zero_range() {
7533        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
7534        assert!(b.body_pct().is_none());
7535    }
7536
7537    #[test]
7538    fn test_body_pct_r67_100_for_full_body() {
7539        // open=90, close=110, high=110, low=90 → body_pct=100%
7540        let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
7541        assert_eq!(b.body_pct(), Some(dec!(100)));
7542    }
7543
7544    // ── OhlcvBar::bullish_count / bearish_count ───────────────────────────────
7545
7546    #[test]
7547    fn test_bullish_count_r67_correct() {
7548        let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112)); // bullish
7549        let b2 = make_ohlcv_bar(dec!(112), dec!(120), dec!(105), dec!(108)); // bearish
7550        let b3 = make_ohlcv_bar(dec!(108), dec!(125), dec!(106), dec!(120)); // bullish
7551        assert_eq!(OhlcvBar::bullish_count(&[b1, b2, b3]), 2);
7552    }
7553
7554    #[test]
7555    fn test_bearish_count_r67_correct() {
7556        let b1 = make_ohlcv_bar(dec!(115), dec!(118), dec!(100), dec!(105)); // bearish
7557        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(112)); // bullish
7558        assert_eq!(OhlcvBar::bearish_count(&[b1, b2]), 1);
7559    }
7560
7561    // ── OhlcvBar::open_gap_pct ────────────────────────────────────────────────
7562
7563    #[test]
7564    fn test_open_gap_pct_none_for_single_bar() {
7565        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7566        assert!(OhlcvBar::open_gap_pct(&[b]).is_none());
7567    }
7568
7569    #[test]
7570    fn test_open_gap_pct_positive_for_gap_up() {
7571        // prev close=100, current open=105 → 5%
7572        let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
7573        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110));
7574        let pct = OhlcvBar::open_gap_pct(&[b1, b2]).unwrap();
7575        assert!((pct - 5.0).abs() < 1e-9);
7576    }
7577
7578    // ── OhlcvBar::volume_cumulative ───────────────────────────────────────────
7579
7580    #[test]
7581    fn test_volume_cumulative_zero_for_empty() {
7582        assert_eq!(OhlcvBar::volume_cumulative(&[]), dec!(0));
7583    }
7584
7585    #[test]
7586    fn test_volume_cumulative_sums_all_volumes() {
7587        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
7588        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(200);
7589        assert_eq!(OhlcvBar::volume_cumulative(&[b1, b2]), dec!(300));
7590    }
7591
7592    // ── OhlcvBar::price_position ──────────────────────────────────────────────
7593
7594    #[test]
7595    fn test_price_position_none_for_empty() {
7596        assert!(OhlcvBar::price_position(&[]).is_none());
7597    }
7598
7599    #[test]
7600    fn test_price_position_one_when_close_at_highest() {
7601        // bars: high=100 and high=120 (range 80-120=40), last close=120
7602        let b1 = make_ohlcv_bar(dec!(85), dec!(100), dec!(80), dec!(95));
7603        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(120));
7604        let pos = OhlcvBar::price_position(&[b1, b2]).unwrap();
7605        assert!((pos - 1.0).abs() < 1e-9);
7606    }
7607
7608    // ── OhlcvBar::close_above_open_count ──────────────────────────────────────
7609
7610    #[test]
7611    fn test_close_above_open_count_zero_for_empty() {
7612        assert_eq!(OhlcvBar::close_above_open_count(&[]), 0);
7613    }
7614
7615    #[test]
7616    fn test_close_above_open_count_correct() {
7617        // bar1: bullish (close > open), bar2: bearish
7618        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108));
7619        let b2 = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(102));
7620        assert_eq!(OhlcvBar::close_above_open_count(&[b1, b2]), 1);
7621    }
7622
7623    // ── OhlcvBar::volume_price_correlation ────────────────────────────────────
7624
7625    #[test]
7626    fn test_volume_price_correlation_none_for_single_bar() {
7627        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7628        assert!(OhlcvBar::volume_price_correlation(&[b]).is_none());
7629    }
7630
7631    #[test]
7632    fn test_volume_price_correlation_positive_for_comoving() {
7633        // Both volume and close rise together
7634        let mut b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(102)); b1.volume = dec!(100);
7635        let mut b2 = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108)); b2.volume = dec!(200);
7636        let corr = OhlcvBar::volume_price_correlation(&[b1, b2]).unwrap();
7637        assert!(corr > 0.0, "expected positive correlation, got {}", corr);
7638    }
7639
7640    // ── OhlcvBar::body_consistency ────────────────────────────────────────────
7641
7642    #[test]
7643    fn test_body_consistency_none_for_empty() {
7644        assert!(OhlcvBar::body_consistency(&[]).is_none());
7645    }
7646
7647    #[test]
7648    fn test_body_consistency_one_for_all_big_bodies() {
7649        // body = |close - open| = 8, range = high - low = 10 → 8 > 5 ✓
7650        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
7651        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(110), dec!(118));
7652        let r = OhlcvBar::body_consistency(&[b1, b2]).unwrap();
7653        assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
7654    }
7655
7656    // ── OhlcvBar::close_volatility_ratio ──────────────────────────────────────
7657
7658    #[test]
7659    fn test_close_volatility_ratio_none_for_single_bar() {
7660        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7661        assert!(OhlcvBar::close_volatility_ratio(&[b]).is_none());
7662    }
7663
7664    #[test]
7665    fn test_close_volatility_ratio_positive_for_varied_closes() {
7666        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7667        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
7668        let r = OhlcvBar::close_volatility_ratio(&[b1, b2]).unwrap();
7669        assert!(r > 0.0, "expected positive ratio, got {}", r);
7670    }
7671
7672    #[test]
7673    fn test_close_volatility_ratio_zero_for_identical_closes() {
7674        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7675        let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(88), dec!(105));
7676        let r = OhlcvBar::close_volatility_ratio(&[b1, b2]).unwrap();
7677        assert!((r - 0.0).abs() < 1e-9, "expected 0.0 for identical closes, got {}", r);
7678    }
7679
7680    // ── OhlcvBar::is_trending_up / is_trending_down ───────────────────────────
7681
7682    #[test]
7683    fn test_is_trending_up_false_for_empty() {
7684        assert!(!OhlcvBar::is_trending_up(&[], 3));
7685    }
7686
7687    #[test]
7688    fn test_is_trending_up_false_for_n_less_than_2() {
7689        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7690        assert!(!OhlcvBar::is_trending_up(&[b], 1));
7691    }
7692
7693    #[test]
7694    fn test_is_trending_up_true_for_rising_closes() {
7695        let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(102));
7696        let b2 = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(107));
7697        let b3 = make_ohlcv_bar(dec!(107), dec!(115), dec!(105), dec!(112));
7698        assert!(OhlcvBar::is_trending_up(&[b1, b2, b3], 3));
7699    }
7700
7701    #[test]
7702    fn test_is_trending_down_true_for_falling_closes() {
7703        let b1 = make_ohlcv_bar(dec!(112), dec!(115), dec!(105), dec!(110));
7704        let b2 = make_ohlcv_bar(dec!(110), dec!(112), dec!(100), dec!(105));
7705        let b3 = make_ohlcv_bar(dec!(105), dec!(108), dec!(95), dec!(98));
7706        assert!(OhlcvBar::is_trending_down(&[b1, b2, b3], 3));
7707    }
7708
7709    // ── OhlcvBar::volume_acceleration ────────────────────────────────────────
7710
7711    #[test]
7712    fn test_volume_acceleration_none_for_single_bar() {
7713        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7714        assert!(OhlcvBar::volume_acceleration(&[b]).is_none());
7715    }
7716
7717    #[test]
7718    fn test_volume_acceleration_positive_when_volume_rises() {
7719        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
7720        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(150);
7721        let acc = OhlcvBar::volume_acceleration(&[b1, b2]).unwrap();
7722        assert!(acc > 0.0, "volume rose so acceleration should be positive, got {}", acc);
7723    }
7724
7725    // ── OhlcvBar::wick_body_ratio ─────────────────────────────────────────────
7726
7727    #[test]
7728    fn test_wick_body_ratio_none_for_empty() {
7729        assert!(OhlcvBar::wick_body_ratio(&[]).is_none());
7730    }
7731
7732    #[test]
7733    fn test_wick_body_ratio_none_for_doji_bar() {
7734        // open == close → zero body, should be skipped
7735        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7736        assert!(OhlcvBar::wick_body_ratio(&[b]).is_none());
7737    }
7738
7739    #[test]
7740    fn test_wick_body_ratio_positive_for_wicked_bar() {
7741        // open=100, close=105 → body=5; high=115, low=95 → wicks=10+5=15
7742        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
7743        let r = OhlcvBar::wick_body_ratio(&[b]).unwrap();
7744        assert!(r > 0.0, "expected positive wick/body ratio, got {}", r);
7745    }
7746
7747    // ── OhlcvBar::close_momentum_score ────────────────────────────────────────
7748
7749    #[test]
7750    fn test_close_momentum_score_none_for_empty() {
7751        assert!(OhlcvBar::close_momentum_score(&[]).is_none());
7752    }
7753
7754    #[test]
7755    fn test_close_momentum_score_half_for_symmetric() {
7756        // Two bars: closes [90, 110] → mean=100; 90 < 100, 110 > 100 → 1/2
7757        let b1 = make_ohlcv_bar(dec!(88), dec!(95), dec!(85), dec!(90));
7758        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(110));
7759        let score = OhlcvBar::close_momentum_score(&[b1, b2]).unwrap();
7760        assert!((score - 0.5).abs() < 1e-9, "expected 0.5, got {}", score);
7761    }
7762
7763    // ── OhlcvBar::range_expansion_count ──────────────────────────────────────
7764
7765    #[test]
7766    fn test_range_expansion_count_zero_for_single_bar() {
7767        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7768        assert_eq!(OhlcvBar::range_expansion_count(&[b]), 0);
7769    }
7770
7771    #[test]
7772    fn test_range_expansion_count_correct() {
7773        // b1 range=20, b2 range=30 → expansion
7774        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7775        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(90), dec!(110));
7776        assert_eq!(OhlcvBar::range_expansion_count(&[b1, b2]), 1);
7777    }
7778
7779    // ── OhlcvBar::gap_count ────────────────────────────────────────────────────
7780
7781    #[test]
7782    fn test_gap_count_zero_for_single_bar() {
7783        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7784        assert_eq!(OhlcvBar::gap_count(&[b]), 0);
7785    }
7786
7787    #[test]
7788    fn test_gap_count_detects_gap() {
7789        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7790        // b2 opens at 108, prev close=105 → gap
7791        let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(106), dec!(112));
7792        assert_eq!(OhlcvBar::gap_count(&[b1, b2]), 1);
7793    }
7794
7795    #[test]
7796    fn test_gap_count_zero_when_open_equals_close() {
7797        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7798        // b2 opens at exactly prev close=105
7799        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(112));
7800        assert_eq!(OhlcvBar::gap_count(&[b1, b2]), 0);
7801    }
7802
7803    // ── OhlcvBar::avg_wick_size ───────────────────────────────────────────────
7804
7805    #[test]
7806    fn test_avg_wick_size_none_for_empty() {
7807        assert!(OhlcvBar::avg_wick_size(&[]).is_none());
7808    }
7809
7810    #[test]
7811    fn test_avg_wick_size_correct() {
7812        // open=100, close=105, high=115, low=95
7813        // upper wick = 115 - 105 = 10, lower wick = 100 - 95 = 5, total = 15
7814        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
7815        let ws = OhlcvBar::avg_wick_size(&[b]).unwrap();
7816        assert!((ws - 15.0).abs() < 1e-6, "expected 15.0, got {}", ws);
7817    }
7818
7819    // ── OhlcvBar::mean_volume_ratio ────────────────────────────────────────────
7820
7821    #[test]
7822    fn test_mean_volume_ratio_empty_for_empty_slice() {
7823        assert!(OhlcvBar::mean_volume_ratio(&[]).is_empty());
7824    }
7825
7826    #[test]
7827    fn test_mean_volume_ratio_sums_to_n_times_mean() {
7828        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
7829        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(300);
7830        // mean = 200; ratios: 0.5, 1.5
7831        let ratios = OhlcvBar::mean_volume_ratio(&[b1, b2]);
7832        assert_eq!(ratios.len(), 2);
7833        let r0 = ratios[0].unwrap();
7834        let r1 = ratios[1].unwrap();
7835        assert!((r0 - 0.5).abs() < 1e-6, "expected 0.5, got {}", r0);
7836        assert!((r1 - 1.5).abs() < 1e-6, "expected 1.5, got {}", r1);
7837    }
7838
7839    // ── OhlcvBar::price_compression_ratio ────────────────────────────────────
7840
7841    #[test]
7842    fn test_price_compression_ratio_none_for_zero_range() {
7843        // open==high==low==close → range=0
7844        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
7845        assert!(OhlcvBar::price_compression_ratio(&[b]).is_none());
7846    }
7847
7848    #[test]
7849    fn test_price_compression_ratio_in_range() {
7850        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
7851        let r = OhlcvBar::price_compression_ratio(&[b]).unwrap();
7852        assert!(r >= 0.0 && r <= 1.0, "expected value in [0,1], got {}", r);
7853    }
7854
7855    // ── OhlcvBar::open_close_spread ───────────────────────────────────────────
7856
7857    #[test]
7858    fn test_open_close_spread_none_for_empty() {
7859        assert!(OhlcvBar::open_close_spread(&[]).is_none());
7860    }
7861
7862    #[test]
7863    fn test_open_close_spread_zero_for_doji() {
7864        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
7865        let s = OhlcvBar::open_close_spread(&[b]).unwrap();
7866        assert!((s - 0.0).abs() < 1e-9, "doji should have spread=0, got {}", s);
7867    }
7868
7869    #[test]
7870    fn test_open_close_spread_positive_for_directional_bar() {
7871        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
7872        let s = OhlcvBar::open_close_spread(&[b]).unwrap();
7873        assert!(s > 0.0, "directional bar should have positive spread, got {}", s);
7874    }
7875
7876    // ── OhlcvBar::close_above_high_ma ────────────────────────────────────────
7877
7878    #[test]
7879    fn test_close_above_high_ma_zero_for_too_few_bars() {
7880        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7881        assert_eq!(OhlcvBar::close_above_high_ma(&[b], 2), 0);
7882    }
7883
7884    #[test]
7885    fn test_close_above_high_ma_detects_breakout() {
7886        // 2-bar high MA = (110+120)/2=115; close of b2=118 > 115
7887        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7888        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(118));
7889        assert_eq!(OhlcvBar::close_above_high_ma(&[b1, b2], 2), 1);
7890    }
7891
7892    // ── OhlcvBar::max_consecutive_gains / max_consecutive_losses ──────────────
7893
7894    #[test]
7895    fn test_max_consecutive_gains_zero_for_single_bar() {
7896        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7897        assert_eq!(OhlcvBar::max_consecutive_gains(&[b]), 0);
7898    }
7899
7900    #[test]
7901    fn test_max_consecutive_gains_correct() {
7902        // closes: 100, 105, 110, 108, 115 → gains: 1,1,0,1 → max run=2
7903        let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
7904        let b2 = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
7905        let b3 = make_ohlcv_bar(dec!(105), dec!(112), dec!(104), dec!(110));
7906        let b4 = make_ohlcv_bar(dec!(110), dec!(111), dec!(105), dec!(108));
7907        let b5 = make_ohlcv_bar(dec!(108), dec!(116), dec!(107), dec!(115));
7908        assert_eq!(OhlcvBar::max_consecutive_gains(&[b1, b2, b3, b4, b5]), 2);
7909    }
7910
7911    #[test]
7912    fn test_max_consecutive_losses_correct() {
7913        // closes: 110, 105, 100, 108 → losses: 1,1,0 → max run=2
7914        let b1 = make_ohlcv_bar(dec!(112), dec!(115), dec!(108), dec!(110));
7915        let b2 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
7916        let b3 = make_ohlcv_bar(dec!(105), dec!(108), dec!(98), dec!(100));
7917        let b4 = make_ohlcv_bar(dec!(100), dec!(112), dec!(98), dec!(108));
7918        assert_eq!(OhlcvBar::max_consecutive_losses(&[b1, b2, b3, b4]), 2);
7919    }
7920
7921    // ── OhlcvBar::price_path_length ───────────────────────────────────────────
7922
7923    #[test]
7924    fn test_price_path_length_none_for_single_bar() {
7925        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7926        assert!(OhlcvBar::price_path_length(&[b]).is_none());
7927    }
7928
7929    #[test]
7930    fn test_price_path_length_correct() {
7931        // closes: 100, 110, 105 → |10| + |5| = 15
7932        let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
7933        let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(99), dec!(110));
7934        let b3 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
7935        let len = OhlcvBar::price_path_length(&[b1, b2, b3]).unwrap();
7936        assert!((len - 15.0).abs() < 1e-6, "expected 15.0, got {}", len);
7937    }
7938
7939    // ── OhlcvBar::close_reversion_count ──────────────────────────────────────
7940
7941    #[test]
7942    fn test_close_reversion_count_zero_for_single_bar() {
7943        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7944        assert_eq!(OhlcvBar::close_reversion_count(&[b]), 0);
7945    }
7946
7947    #[test]
7948    fn test_close_reversion_count_returns_usize() {
7949        let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
7950        let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(99), dec!(110));
7951        let b3 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
7952        // Just test it runs without panic
7953        let _ = OhlcvBar::close_reversion_count(&[b1, b2, b3]);
7954    }
7955
7956    // ── OhlcvBar::atr_ratio ───────────────────────────────────────────────────
7957
7958    #[test]
7959    fn test_atr_ratio_none_for_single_bar() {
7960        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7961        assert!(OhlcvBar::atr_ratio(&[b]).is_none());
7962    }
7963
7964    #[test]
7965    fn test_atr_ratio_positive_for_valid_bars() {
7966        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7967        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110));
7968        let r = OhlcvBar::atr_ratio(&[b1, b2]).unwrap();
7969        assert!(r > 0.0, "expected positive ATR ratio, got {}", r);
7970    }
7971
7972    // ── OhlcvBar::volume_trend_strength ───────────────────────────────────────
7973
7974    #[test]
7975    fn test_volume_trend_strength_none_for_single_bar() {
7976        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
7977        assert!(OhlcvBar::volume_trend_strength(&[b]).is_none());
7978    }
7979
7980    #[test]
7981    fn test_volume_trend_strength_positive_for_rising_volume() {
7982        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
7983        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(200);
7984        let mut b3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115)); b3.volume = dec!(300);
7985        let s = OhlcvBar::volume_trend_strength(&[b1, b2, b3]).unwrap();
7986        assert!(s > 0.0, "rising volume should give positive strength, got {}", s);
7987    }
7988
7989    // ── OhlcvBar::high_close_spread ───────────────────────────────────────────
7990
7991    #[test]
7992    fn test_high_close_spread_none_for_empty() {
7993        assert!(OhlcvBar::high_close_spread(&[]).is_none());
7994    }
7995
7996    #[test]
7997    fn test_high_close_spread_zero_when_close_equals_high() {
7998        // open=100, high=110, low=90, close=110 → upper wick=0
7999        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
8000        let s = OhlcvBar::high_close_spread(&[b]).unwrap();
8001        assert!((s - 0.0).abs() < 1e-9, "expected 0.0, got {}", s);
8002    }
8003
8004    #[test]
8005    fn test_high_close_spread_positive_for_wicked_bar() {
8006        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
8007        let s = OhlcvBar::high_close_spread(&[b]).unwrap();
8008        assert!(s > 0.0, "expected positive spread, got {}", s);
8009    }
8010
8011    // ── OhlcvBar::open_range ──────────────────────────────────────────────────
8012
8013    #[test]
8014    fn test_open_range_none_for_empty() {
8015        assert!(OhlcvBar::open_range(&[]).is_none());
8016    }
8017
8018    #[test]
8019    fn test_open_range_zero_for_doji() {
8020        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
8021        let r = OhlcvBar::open_range(&[b]).unwrap();
8022        assert!((r - 0.0).abs() < 1e-9, "doji should have open_range=0, got {}", r);
8023    }
8024
8025    #[test]
8026    fn test_open_range_positive_for_directional() {
8027        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
8028        let r = OhlcvBar::open_range(&[b]).unwrap();
8029        assert!(r > 0.0, "directional bar should have positive open_range, got {}", r);
8030    }
8031
8032    // ── OhlcvBar::normalized_close ────────────────────────────────────────────
8033
8034    #[test]
8035    fn test_normalized_close_none_for_single_bar() {
8036        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8037        assert!(OhlcvBar::normalized_close(&[b]).is_none());
8038    }
8039
8040    #[test]
8041    fn test_normalized_close_one_when_last_close_is_max() {
8042        let b1 = make_ohlcv_bar(dec!(98), dec!(105), dec!(96), dec!(100));
8043        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(99), dec!(110));
8044        let nc = OhlcvBar::normalized_close(&[b1, b2]).unwrap();
8045        assert!((nc - 1.0).abs() < 1e-9, "last close = max should give 1.0, got {}", nc);
8046    }
8047
8048    #[test]
8049    fn test_normalized_close_zero_when_last_close_is_min() {
8050        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
8051        let b2 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
8052        // min_close=90, max_close=100, last_close=100 → 1.0
8053        // Actually min=90, max=100, last=100 → normalized=1.0
8054        let nc = OhlcvBar::normalized_close(&[b1, b2]).unwrap();
8055        assert!(nc >= 0.0 && nc <= 1.0, "normalized close should be in [0,1], got {}", nc);
8056    }
8057
8058    // ── OhlcvBar::candle_score ────────────────────────────────────────────────
8059
8060    #[test]
8061    fn test_candle_score_none_for_empty() {
8062        assert!(OhlcvBar::candle_score(&[]).is_none());
8063    }
8064
8065    #[test]
8066    fn test_candle_score_one_for_strong_bull_bar() {
8067        // open=100, close=108, high=110, low=99 → bullish, body=8, range=11, close_above_mid=yes
8068        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(108));
8069        let s = OhlcvBar::candle_score(&[b]).unwrap();
8070        assert_eq!(s, 1.0, "strong bullish bar should score 1.0, got {}", s);
8071    }
8072
8073    #[test]
8074    fn test_candle_score_zero_for_bear_bar() {
8075        // open=108, close=100 → bearish → score 0
8076        let b = make_ohlcv_bar(dec!(108), dec!(110), dec!(99), dec!(100));
8077        let s = OhlcvBar::candle_score(&[b]).unwrap();
8078        assert_eq!(s, 0.0, "bearish bar should score 0.0, got {}", s);
8079    }
8080
8081    // ── OhlcvBar::bar_speed ───────────────────────────────────────────────────
8082
8083    #[test]
8084    fn test_bar_speed_none_for_empty() {
8085        assert!(OhlcvBar::bar_speed(&[]).is_none());
8086    }
8087
8088    // ── OhlcvBar::higher_highs_count / lower_lows_count ──────────────────────
8089
8090    #[test]
8091    fn test_higher_highs_count_zero_for_single_bar() {
8092        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8093        assert_eq!(OhlcvBar::higher_highs_count(&[b]), 0);
8094    }
8095
8096    #[test]
8097    fn test_higher_highs_count_correct() {
8098        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8099        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115)); // high 120 > 110
8100        let b3 = make_ohlcv_bar(dec!(115), dec!(115), dec!(110), dec!(112)); // high 115 < 120
8101        assert_eq!(OhlcvBar::higher_highs_count(&[b1, b2, b3]), 1);
8102    }
8103
8104    #[test]
8105    fn test_lower_lows_count_correct() {
8106        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8107        let b2 = make_ohlcv_bar(dec!(105), dec!(112), dec!(85), dec!(108)); // low 85 < 90
8108        let b3 = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(112)); // low 95 > 85
8109        assert_eq!(OhlcvBar::lower_lows_count(&[b1, b2, b3]), 1);
8110    }
8111
8112    // ── OhlcvBar::close_minus_open_pct ────────────────────────────────────────
8113
8114    #[test]
8115    fn test_close_minus_open_pct_none_for_empty() {
8116        assert!(OhlcvBar::close_minus_open_pct(&[]).is_none());
8117    }
8118
8119    #[test]
8120    fn test_close_minus_open_pct_positive_for_bull_bar() {
8121        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(98), dec!(110));
8122        let p = OhlcvBar::close_minus_open_pct(&[b]).unwrap();
8123        assert!(p > 0.0, "bullish bar should give positive pct, got {}", p);
8124    }
8125
8126    // ── OhlcvBar::volume_per_range ────────────────────────────────────────────
8127
8128    #[test]
8129    fn test_volume_per_range_none_for_empty() {
8130        assert!(OhlcvBar::volume_per_range(&[]).is_none());
8131    }
8132
8133    #[test]
8134    fn test_volume_per_range_positive_for_valid_bar() {
8135        let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b.volume = dec!(100);
8136        let r = OhlcvBar::volume_per_range(&[b]).unwrap();
8137        assert!(r > 0.0, "expected positive volume/range, got {}", r);
8138    }
8139
8140    #[test]
8141    fn test_up_volume_fraction_none_for_empty() {
8142        assert!(OhlcvBar::up_volume_fraction(&[]).is_none());
8143    }
8144
8145    #[test]
8146    fn test_up_volume_fraction_all_up() {
8147        // close > open for both bars
8148        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108)); b1.volume = dec!(50);
8149        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112)); b2.volume = dec!(50);
8150        let f = OhlcvBar::up_volume_fraction(&[b1, b2]).unwrap();
8151        assert!((f - 1.0).abs() < 1e-9, "all up bars → fraction=1.0, got {}", f);
8152    }
8153
8154    #[test]
8155    fn test_tail_upper_fraction_none_for_empty() {
8156        assert!(OhlcvBar::tail_upper_fraction(&[]).is_none());
8157    }
8158
8159    #[test]
8160    fn test_tail_upper_fraction_correct() {
8161        // bar: open=100, high=110, low=90, close=105 → body_top=105, upper_wick=5, range=20 → 0.25
8162        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8163        let f = OhlcvBar::tail_upper_fraction(&[b]).unwrap();
8164        assert!((f - 0.25).abs() < 1e-9, "expected 0.25, got {}", f);
8165    }
8166
8167    #[test]
8168    fn test_tail_lower_fraction_none_for_empty() {
8169        assert!(OhlcvBar::tail_lower_fraction(&[]).is_none());
8170    }
8171
8172    #[test]
8173    fn test_tail_lower_fraction_correct() {
8174        // bar: open=100, high=110, low=90, close=105 → body_bot=100, lower_wick=10, range=20 → 0.5
8175        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8176        let f = OhlcvBar::tail_lower_fraction(&[b]).unwrap();
8177        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
8178    }
8179
8180    #[test]
8181    fn test_range_std_dev_none_for_single_bar() {
8182        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8183        assert!(OhlcvBar::range_std_dev(&[b]).is_none());
8184    }
8185
8186    #[test]
8187    fn test_range_std_dev_zero_for_equal_ranges() {
8188        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8189        let b2 = make_ohlcv_bar(dec!(200), dec!(210), dec!(190), dec!(205));
8190        let sd = OhlcvBar::range_std_dev(&[b1, b2]).unwrap();
8191        assert!(sd.abs() < 1e-9, "equal ranges → std_dev=0, got {}", sd);
8192    }
8193
8194    #[test]
8195    fn test_body_fraction_none_for_empty() {
8196        assert!(OhlcvBar::body_fraction(&[]).is_none());
8197    }
8198
8199    #[test]
8200    fn test_body_fraction_doji_is_zero() {
8201        // open == close → body = 0 → fraction = 0
8202        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
8203        let f = OhlcvBar::body_fraction(&[b]).unwrap();
8204        assert!(f.abs() < 1e-9, "doji → body_fraction=0, got {}", f);
8205    }
8206
8207    #[test]
8208    fn test_bullish_ratio_none_for_empty() {
8209        assert!(OhlcvBar::bullish_ratio(&[]).is_none());
8210    }
8211
8212    #[test]
8213    fn test_bullish_ratio_all_bullish() {
8214        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108));
8215        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112));
8216        let r = OhlcvBar::bullish_ratio(&[b1, b2]).unwrap();
8217        assert!((r - 1.0).abs() < 1e-9, "all bullish → ratio=1.0, got {}", r);
8218    }
8219
8220    #[test]
8221    fn test_peak_trough_close_none_for_empty() {
8222        assert!(OhlcvBar::peak_close(&[]).is_none());
8223        assert!(OhlcvBar::trough_close(&[]).is_none());
8224    }
8225
8226    #[test]
8227    fn test_peak_trough_close_correct() {
8228        let bars = vec![
8229            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
8230            make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115)),
8231            make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(98)),
8232        ];
8233        assert_eq!(OhlcvBar::peak_close(&bars).unwrap(), dec!(115));
8234        assert_eq!(OhlcvBar::trough_close(&bars).unwrap(), dec!(98));
8235    }
8236
8237    // ── round-79 ─────────────────────────────────────────────────────────────
8238
8239    // ── OhlcvBar::close_to_range_position ────────────────────────────────────
8240
8241    #[test]
8242    fn test_close_to_range_position_none_for_empty() {
8243        assert!(OhlcvBar::close_to_range_position(&[]).is_none());
8244    }
8245
8246    #[test]
8247    fn test_close_to_range_position_one_when_close_at_high() {
8248        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(120));
8249        let r = OhlcvBar::close_to_range_position(&[bar]).unwrap();
8250        assert!((r - 1.0).abs() < 1e-9, "close at high → position=1, got {}", r);
8251    }
8252
8253    #[test]
8254    fn test_close_to_range_position_zero_when_close_at_low() {
8255        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(80));
8256        let r = OhlcvBar::close_to_range_position(&[bar]).unwrap();
8257        assert!(r.abs() < 1e-9, "close at low → position=0, got {}", r);
8258    }
8259
8260    // ── OhlcvBar::volume_oscillator ───────────────────────────────────────────
8261
8262    #[test]
8263    fn test_volume_oscillator_none_for_insufficient_bars() {
8264        let bars = vec![make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))];
8265        assert!(OhlcvBar::volume_oscillator(&bars, 1, 3).is_none());
8266    }
8267
8268    #[test]
8269    fn test_volume_oscillator_none_when_short_ge_long() {
8270        let bars: Vec<_> = (0..5)
8271            .map(|_| make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)))
8272            .collect();
8273        assert!(OhlcvBar::volume_oscillator(&bars, 3, 2).is_none());
8274    }
8275
8276    #[test]
8277    fn test_volume_oscillator_zero_for_constant_volume() {
8278        let bars: Vec<_> = (0..5)
8279            .map(|_| make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)))
8280            .collect();
8281        let v = OhlcvBar::volume_oscillator(&bars, 2, 4).unwrap();
8282        assert!(v.abs() < 1e-9, "constant volume → oscillator=0, got {}", v);
8283    }
8284
8285    // ── OhlcvBar::direction_reversal_count ───────────────────────────────────
8286
8287    #[test]
8288    fn test_direction_reversal_count_zero_for_single_bar() {
8289        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8290        assert_eq!(OhlcvBar::direction_reversal_count(&[bar]), 0);
8291    }
8292
8293    #[test]
8294    fn test_direction_reversal_count_zero_for_all_bullish() {
8295        let bars = vec![
8296            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
8297            make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(112)),
8298        ];
8299        assert_eq!(OhlcvBar::direction_reversal_count(&bars), 0);
8300    }
8301
8302    #[test]
8303    fn test_direction_reversal_count_two_for_alternating() {
8304        let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
8305        let bear = make_ohlcv_bar(dec!(108), dec!(112), dec!(95), dec!(102));
8306        let bull2 = make_ohlcv_bar(dec!(102), dec!(115), dec!(98), dec!(110));
8307        let bear2 = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(104));
8308        assert_eq!(OhlcvBar::direction_reversal_count(&[bull, bear, bull2, bear2]), 3);
8309    }
8310
8311    // ── OhlcvBar::upper_wick_dominance_fraction ───────────────────────────────
8312
8313    #[test]
8314    fn test_upper_wick_dominance_fraction_none_for_empty() {
8315        assert!(OhlcvBar::upper_wick_dominance_fraction(&[]).is_none());
8316    }
8317
8318    #[test]
8319    fn test_upper_wick_dominance_fraction_one_when_all_upper() {
8320        // high > close and close > low, upper > lower
8321        let bar = make_ohlcv_bar(dec!(100), dec!(130), dec!(99), dec!(101));
8322        let r = OhlcvBar::upper_wick_dominance_fraction(&[bar]).unwrap();
8323        assert!((r - 1.0).abs() < 1e-9, "all upper dominant → 1.0, got {}", r);
8324    }
8325
8326    // ── OhlcvBar::avg_open_to_high_ratio ─────────────────────────────────────
8327
8328    #[test]
8329    fn test_avg_open_to_high_ratio_none_for_empty() {
8330        assert!(OhlcvBar::avg_open_to_high_ratio(&[]).is_none());
8331    }
8332
8333    #[test]
8334    fn test_avg_open_to_high_ratio_one_when_open_at_low() {
8335        // open == low → (high - open) / range == 1
8336        let bar = make_ohlcv_bar(dec!(80), dec!(120), dec!(80), dec!(100));
8337        let r = OhlcvBar::avg_open_to_high_ratio(&[bar]).unwrap();
8338        assert!((r - 1.0).abs() < 1e-9, "open at low → ratio=1, got {}", r);
8339    }
8340
8341    // ── OhlcvBar::volume_weighted_range ──────────────────────────────────────
8342
8343    #[test]
8344    fn test_volume_weighted_range_none_for_empty() {
8345        assert!(OhlcvBar::volume_weighted_range(&[]).is_none());
8346    }
8347
8348    #[test]
8349    fn test_volume_weighted_range_positive() {
8350        let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
8351        let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(100), dec!(120));
8352        let r = OhlcvBar::volume_weighted_range(&[b1, b2]).unwrap();
8353        assert!(r > 0.0, "should be positive, got {}", r);
8354    }
8355
8356    // ── OhlcvBar::bar_strength_index ─────────────────────────────────────────
8357
8358    #[test]
8359    fn test_bar_strength_index_none_for_empty() {
8360        assert!(OhlcvBar::bar_strength_index(&[]).is_none());
8361    }
8362
8363    #[test]
8364    fn test_bar_strength_index_positive_when_closes_near_high() {
8365        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(120));
8366        let s = OhlcvBar::bar_strength_index(&[bar]).unwrap();
8367        assert!(s > 0.0, "close at high → positive strength, got {}", s);
8368    }
8369
8370    // ── OhlcvBar::shadow_to_body_ratio ────────────────────────────────────────
8371
8372    #[test]
8373    fn test_shadow_to_body_ratio_none_for_empty() {
8374        assert!(OhlcvBar::shadow_to_body_ratio(&[]).is_none());
8375    }
8376
8377    #[test]
8378    fn test_shadow_to_body_ratio_zero_for_marubozu() {
8379        // Marubozu: open==low and close==high → no wicks
8380        let bar = make_ohlcv_bar(dec!(80), dec!(120), dec!(80), dec!(120));
8381        let r = OhlcvBar::shadow_to_body_ratio(&[bar]).unwrap();
8382        assert!(r.abs() < 1e-9, "marubozu → ratio=0, got {}", r);
8383    }
8384
8385    // ── OhlcvBar::first_last_close_pct ───────────────────────────────────────
8386
8387    #[test]
8388    fn test_first_last_close_pct_none_for_empty() {
8389        assert!(OhlcvBar::first_last_close_pct(&[]).is_none());
8390    }
8391
8392    #[test]
8393    fn test_first_last_close_pct_zero_for_same_close() {
8394        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
8395        let r = OhlcvBar::first_last_close_pct(&[bar]).unwrap();
8396        assert!(r.abs() < 1e-9, "same open/close → pct=0, got {}", r);
8397    }
8398
8399    #[test]
8400    fn test_first_last_close_pct_positive_for_rise() {
8401        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
8402        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110));
8403        let r = OhlcvBar::first_last_close_pct(&[b1, b2]).unwrap();
8404        assert!(r > 0.0, "price rose → positive pct, got {}", r);
8405    }
8406
8407    // ── OhlcvBar::open_to_close_volatility ───────────────────────────────────
8408
8409    #[test]
8410    fn test_open_to_close_volatility_none_for_single_bar() {
8411        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8412        assert!(OhlcvBar::open_to_close_volatility(&[bar]).is_none());
8413    }
8414
8415    #[test]
8416    fn test_open_to_close_volatility_zero_for_identical_bars() {
8417        let bars = vec![
8418            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
8419            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
8420            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
8421        ];
8422        let v = OhlcvBar::open_to_close_volatility(&bars).unwrap();
8423        assert!(v.abs() < 1e-9, "identical bars → volatility=0, got {}", v);
8424    }
8425
8426    // ── round-80 tests ────────────────────────────────────────────────────────
8427
8428    #[test]
8429    fn test_close_recovery_ratio_none_for_empty() {
8430        assert!(OhlcvBar::close_recovery_ratio(&[]).is_none());
8431    }
8432
8433    #[test]
8434    fn test_close_recovery_ratio_one_for_close_at_high() {
8435        // close == high → ratio = 1.0
8436        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
8437        let r = OhlcvBar::close_recovery_ratio(&[b]).unwrap();
8438        assert!((r - 1.0).abs() < 1e-9, "close at high → ratio=1, got {}", r);
8439    }
8440
8441    #[test]
8442    fn test_median_range_none_for_empty() {
8443        assert!(OhlcvBar::median_range(&[]).is_none());
8444    }
8445
8446    #[test]
8447    fn test_median_range_correct_odd() {
8448        let bars = vec![
8449            make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),  // range=20
8450            make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(105)),  // range=25
8451            make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105)),  // range=30
8452        ];
8453        assert_eq!(OhlcvBar::median_range(&bars).unwrap(), dec!(25));
8454    }
8455
8456    #[test]
8457    fn test_mean_typical_price_none_for_empty() {
8458        assert!(OhlcvBar::mean_typical_price(&[]).is_none());
8459    }
8460
8461    #[test]
8462    fn test_mean_typical_price_correct() {
8463        // typical = (110 + 90 + 105) / 3 = 101.666...
8464        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8465        let expected = b.typical_price();
8466        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8467        let tp = OhlcvBar::mean_typical_price(&[b2]).unwrap();
8468        assert_eq!(tp, expected);
8469    }
8470
8471    #[test]
8472    fn test_directional_volume_ratio_none_for_empty() {
8473        assert!(OhlcvBar::directional_volume_ratio(&[]).is_none());
8474    }
8475
8476    #[test]
8477    fn test_directional_volume_ratio_one_for_all_bullish() {
8478        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); b1.volume = dec!(50);
8479        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112)); b2.volume = dec!(50);
8480        let r = OhlcvBar::directional_volume_ratio(&[b1, b2]).unwrap();
8481        assert!((r - 1.0).abs() < 1e-9, "all bullish → ratio=1, got {}", r);
8482    }
8483
8484    #[test]
8485    fn test_inside_bar_fraction_none_for_single_bar() {
8486        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8487        assert!(OhlcvBar::inside_bar_fraction(&[b]).is_none());
8488    }
8489
8490    #[test]
8491    fn test_body_momentum_empty_is_zero() {
8492        assert_eq!(OhlcvBar::body_momentum(&[]), Decimal::ZERO);
8493    }
8494
8495    #[test]
8496    fn test_body_momentum_bullish_positive() {
8497        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
8498        let m = OhlcvBar::body_momentum(&[b]);
8499        assert!(m > Decimal::ZERO, "bullish bar → positive body momentum");
8500    }
8501
8502    #[test]
8503    fn test_avg_trade_count_none_for_empty() {
8504        assert!(OhlcvBar::avg_trade_count(&[]).is_none());
8505    }
8506
8507    #[test]
8508    fn test_max_trade_count_none_for_empty() {
8509        assert!(OhlcvBar::max_trade_count(&[]).is_none());
8510    }
8511
8512    #[test]
8513    fn test_max_trade_count_returns_max() {
8514        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.trade_count = 5;
8515        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.trade_count = 10;
8516        assert_eq!(OhlcvBar::max_trade_count(&[b1, b2]).unwrap(), 10);
8517    }
8518
8519    // ── round-81 tests ────────────────────────────────────────────────────────
8520
8521    #[test]
8522    fn test_close_to_high_std_none_for_single_bar() {
8523        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8524        assert!(OhlcvBar::close_to_high_std(&[b]).is_none());
8525    }
8526
8527    #[test]
8528    fn test_close_to_high_std_zero_for_identical_bars() {
8529        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8530        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8531        let sd = OhlcvBar::close_to_high_std(&[b1, b2]).unwrap();
8532        assert!(sd.abs() < 1e-9, "identical bars → std=0, got {}", sd);
8533    }
8534
8535    #[test]
8536    fn test_avg_open_volume_ratio_none_for_empty() {
8537        assert!(OhlcvBar::avg_open_volume_ratio(&[]).is_none());
8538    }
8539
8540    #[test]
8541    fn test_typical_price_std_none_for_single_bar() {
8542        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8543        assert!(OhlcvBar::typical_price_std(&[b]).is_none());
8544    }
8545
8546    #[test]
8547    fn test_typical_price_std_zero_for_identical_bars() {
8548        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8549        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8550        let sd = OhlcvBar::typical_price_std(&[b1, b2]).unwrap();
8551        assert!(sd.abs() < 1e-9, "identical bars → std=0, got {}", sd);
8552    }
8553
8554    #[test]
8555    fn test_vwap_deviation_avg_none_for_empty() {
8556        assert!(OhlcvBar::vwap_deviation_avg(&[]).is_none());
8557    }
8558
8559    #[test]
8560    fn test_avg_high_low_ratio_none_for_empty() {
8561        assert!(OhlcvBar::avg_high_low_ratio(&[]).is_none());
8562    }
8563
8564    #[test]
8565    fn test_avg_high_low_ratio_one_for_doji() {
8566        // high == low (doji) → ratio = 1.0 but low=0 is skipped... use low=100
8567        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
8568        let r = OhlcvBar::avg_high_low_ratio(&[b]).unwrap();
8569        assert!((r - 1.0).abs() < 1e-9, "high==low → ratio=1, got {}", r);
8570    }
8571
8572    #[test]
8573    fn test_gap_fill_fraction_none_for_empty() {
8574        assert!(OhlcvBar::gap_fill_fraction(&[]).is_none());
8575    }
8576
8577    #[test]
8578    fn test_gap_fill_fraction_zero_for_no_gaps() {
8579        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8580        let f = OhlcvBar::gap_fill_fraction(&[b]).unwrap();
8581        assert!(f.abs() < 1e-9, "no gap fills → fraction=0, got {}", f);
8582    }
8583
8584    #[test]
8585    fn test_complete_bar_count_zero_for_incomplete() {
8586        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8587        assert_eq!(OhlcvBar::complete_bar_count(&[b]), 0);
8588    }
8589
8590    #[test]
8591    fn test_min_trade_count_none_for_empty() {
8592        assert!(OhlcvBar::min_trade_count(&[]).is_none());
8593    }
8594
8595    #[test]
8596    fn test_min_trade_count_returns_min() {
8597        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.trade_count = 5;
8598        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.trade_count = 2;
8599        assert_eq!(OhlcvBar::min_trade_count(&[b1, b2]).unwrap(), 2);
8600    }
8601
8602    // ── round-82 tests ────────────────────────────────────────────────────────
8603
8604    #[test]
8605    fn test_avg_bar_range_none_for_empty() {
8606        assert!(OhlcvBar::avg_bar_range(&[]).is_none());
8607    }
8608
8609    #[test]
8610    fn test_avg_bar_range_correct_value() {
8611        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
8612        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105)); // range=20
8613        let r = OhlcvBar::avg_bar_range(&[b1, b2]).unwrap();
8614        assert_eq!(r, dec!(20));
8615    }
8616
8617    #[test]
8618    fn test_max_up_move_none_for_empty() {
8619        assert!(OhlcvBar::max_up_move(&[]).is_none());
8620    }
8621
8622    #[test]
8623    fn test_max_up_move_largest_bullish_body() {
8624        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); // up: 8
8625        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // up: 5
8626        assert_eq!(OhlcvBar::max_up_move(&[b1, b2]).unwrap(), dec!(8));
8627    }
8628
8629    #[test]
8630    fn test_max_down_move_none_for_empty() {
8631        assert!(OhlcvBar::max_down_move(&[]).is_none());
8632    }
8633
8634    #[test]
8635    fn test_max_down_move_largest_bearish_body() {
8636        let b1 = make_ohlcv_bar(dec!(108), dec!(115), dec!(85), dec!(100)); // down: 8
8637        let b2 = make_ohlcv_bar(dec!(103), dec!(110), dec!(90), dec!(100)); // down: 3
8638        assert_eq!(OhlcvBar::max_down_move(&[b1, b2]).unwrap(), dec!(8));
8639    }
8640
8641    #[test]
8642    fn test_avg_close_position_none_for_doji_only() {
8643        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)); // range=0
8644        assert!(OhlcvBar::avg_close_position(&[b]).is_none());
8645    }
8646
8647    #[test]
8648    fn test_avg_close_position_one_for_close_at_high() {
8649        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
8650        let pos = OhlcvBar::avg_close_position(&[b]).unwrap();
8651        assert!((pos - 1.0).abs() < 1e-9, "close at high → position=1, got {}", pos);
8652    }
8653
8654    #[test]
8655    fn test_volume_std_none_for_single_bar() {
8656        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8657        assert!(OhlcvBar::volume_std(&[b]).is_none());
8658    }
8659
8660    #[test]
8661    fn test_volume_std_zero_for_equal_volumes() {
8662        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8663        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8664        let s = OhlcvBar::volume_std(&[b1, b2]).unwrap();
8665        assert!(s.abs() < 1e-9, "equal volumes → std=0, got {}", s);
8666    }
8667
8668    #[test]
8669    fn test_avg_wick_ratio_none_for_doji_only() {
8670        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
8671        assert!(OhlcvBar::avg_wick_ratio(&[b]).is_none());
8672    }
8673
8674    #[test]
8675    fn test_avg_wick_ratio_in_range() {
8676        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(105));
8677        let r = OhlcvBar::avg_wick_ratio(&[b]).unwrap();
8678        assert!(r >= 0.0 && r <= 1.0, "wick ratio should be in [0,1], got {}", r);
8679    }
8680
8681    #[test]
8682    fn test_open_gap_mean_none_for_single_bar() {
8683        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8684        assert!(OhlcvBar::open_gap_mean(&[b]).is_none());
8685    }
8686
8687    #[test]
8688    fn test_open_gap_mean_zero_for_no_gap() {
8689        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8690        let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
8691        b2.open = dec!(105); // open == prev_close → no gap
8692        let g = OhlcvBar::open_gap_mean(&[b1, b2]).unwrap();
8693        assert!(g.abs() < 1e-9, "no gap → mean=0, got {}", g);
8694    }
8695
8696    #[test]
8697    fn test_net_directional_move_none_for_empty() {
8698        assert!(OhlcvBar::net_directional_move(&[]).is_none());
8699    }
8700
8701    #[test]
8702    fn test_net_directional_move_positive_for_rising_close() {
8703        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8704        let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115));
8705        let m = OhlcvBar::net_directional_move(&[b1, b2]).unwrap();
8706        assert!(m > 0.0, "rising bar sequence → positive move, got {}", m);
8707    }
8708
8709    // ── round-83 tests ────────────────────────────────────────────────────────
8710
8711    #[test]
8712    fn test_close_above_median_fraction_none_for_empty() {
8713        assert!(OhlcvBar::close_above_median_fraction(&[]).is_none());
8714    }
8715
8716    #[test]
8717    fn test_close_above_median_fraction_half_for_symmetric() {
8718        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
8719        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8720        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
8721        let b4 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
8722        let f = OhlcvBar::close_above_median_fraction(&[b1, b2, b3, b4]).unwrap();
8723        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
8724    }
8725
8726    #[test]
8727    fn test_avg_range_to_open_none_for_empty() {
8728        assert!(OhlcvBar::avg_range_to_open(&[]).is_none());
8729    }
8730
8731    #[test]
8732    fn test_close_sum_zero_for_empty() {
8733        assert_eq!(OhlcvBar::close_sum(&[]), dec!(0));
8734    }
8735
8736    #[test]
8737    fn test_close_sum_sums_all_closes() {
8738        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8739        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(107));
8740        assert_eq!(OhlcvBar::close_sum(&[b1, b2]), dec!(212));
8741    }
8742
8743    #[test]
8744    fn test_above_avg_volume_count_zero_for_empty() {
8745        assert_eq!(OhlcvBar::above_avg_volume_count(&[]), 0);
8746    }
8747
8748    #[test]
8749    fn test_median_close_none_for_empty() {
8750        assert!(OhlcvBar::median_close(&[]).is_none());
8751    }
8752
8753    #[test]
8754    fn test_median_close_correct_for_sorted() {
8755        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
8756        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8757        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
8758        let m = OhlcvBar::median_close(&[b1, b2, b3]).unwrap();
8759        assert_eq!(m, dec!(105));
8760    }
8761
8762    #[test]
8763    fn test_flat_bar_fraction_none_for_empty() {
8764        assert!(OhlcvBar::flat_bar_fraction(&[]).is_none());
8765    }
8766
8767    #[test]
8768    fn test_avg_body_to_range_none_for_empty() {
8769        assert!(OhlcvBar::avg_body_to_range(&[]).is_none());
8770    }
8771
8772    #[test]
8773    fn test_avg_body_to_range_in_range() {
8774        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8775        let r = OhlcvBar::avg_body_to_range(&[b]).unwrap();
8776        assert!(r >= 0.0 && r <= 1.0, "body-to-range in [0,1], got {}", r);
8777    }
8778
8779    #[test]
8780    fn test_max_open_gap_none_for_single_bar() {
8781        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8782        assert!(OhlcvBar::max_open_gap(&[b]).is_none());
8783    }
8784
8785    #[test]
8786    fn test_volume_trend_slope_none_for_single_bar() {
8787        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8788        assert!(OhlcvBar::volume_trend_slope(&[b]).is_none());
8789    }
8790
8791    #[test]
8792    fn test_up_close_fraction_none_for_empty() {
8793        assert!(OhlcvBar::up_close_fraction(&[]).is_none());
8794    }
8795
8796    #[test]
8797    fn test_avg_upper_shadow_ratio_none_for_doji_only() {
8798        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
8799        assert!(OhlcvBar::avg_upper_shadow_ratio(&[b]).is_none());
8800    }
8801
8802    // ── round-84 tests ────────────────────────────────────────────────────────
8803
8804    #[test]
8805    fn test_avg_lower_shadow_ratio_none_for_empty() {
8806        assert!(OhlcvBar::avg_lower_shadow_ratio(&[]).is_none());
8807    }
8808
8809    #[test]
8810    fn test_avg_lower_shadow_ratio_in_range() {
8811        let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(105));
8812        let r = OhlcvBar::avg_lower_shadow_ratio(&[b]).unwrap();
8813        assert!(r >= 0.0 && r <= 1.0, "lower shadow ratio in [0,1], got {}", r);
8814    }
8815
8816    #[test]
8817    fn test_close_to_open_range_ratio_none_for_doji_only() {
8818        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
8819        assert!(OhlcvBar::close_to_open_range_ratio(&[b]).is_none());
8820    }
8821
8822    #[test]
8823    fn test_max_high_none_for_empty() {
8824        assert!(OhlcvBar::max_high(&[]).is_none());
8825    }
8826
8827    #[test]
8828    fn test_max_high_returns_maximum() {
8829        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8830        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
8831        assert_eq!(OhlcvBar::max_high(&[b1, b2]).unwrap(), dec!(120));
8832    }
8833
8834    #[test]
8835    fn test_min_low_none_for_empty() {
8836        assert!(OhlcvBar::min_low(&[]).is_none());
8837    }
8838
8839    #[test]
8840    fn test_min_low_returns_minimum() {
8841        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(85), dec!(105));
8842        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8843        assert_eq!(OhlcvBar::min_low(&[b1, b2]).unwrap(), dec!(85));
8844    }
8845
8846    #[test]
8847    fn test_avg_bar_efficiency_none_for_doji_only() {
8848        let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
8849        assert!(OhlcvBar::avg_bar_efficiency(&[b]).is_none());
8850    }
8851
8852    #[test]
8853    fn test_avg_bar_efficiency_one_for_full_body_bar() {
8854        let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
8855        let e = OhlcvBar::avg_bar_efficiency(&[b]).unwrap();
8856        assert!((e - 1.0).abs() < 1e-9, "full body → efficiency=1, got {}", e);
8857    }
8858
8859    #[test]
8860    fn test_open_range_fraction_none_for_empty() {
8861        assert!(OhlcvBar::open_range_fraction(&[]).is_none());
8862    }
8863
8864    #[test]
8865    fn test_open_range_fraction_in_range() {
8866        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8867        let f = OhlcvBar::open_range_fraction(&[b]).unwrap();
8868        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
8869    }
8870
8871    #[test]
8872    fn test_close_skewness_none_for_two_bars() {
8873        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8874        let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
8875        assert!(OhlcvBar::close_skewness(&[b1, b2]).is_none());
8876    }
8877
8878    #[test]
8879    fn test_close_skewness_returns_value_for_three_bars() {
8880        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
8881        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8882        let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
8883        let s = OhlcvBar::close_skewness(&[b1, b2, b3]);
8884        assert!(s.is_some(), "skewness should be computed for 3 bars");
8885    }
8886
8887    #[test]
8888    fn test_volume_above_median_fraction_none_for_empty() {
8889        assert!(OhlcvBar::volume_above_median_fraction(&[]).is_none());
8890    }
8891
8892    #[test]
8893    fn test_volume_above_median_fraction_in_range() {
8894        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8895        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8896        let f = OhlcvBar::volume_above_median_fraction(&[b1, b2]).unwrap();
8897        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
8898    }
8899
8900    #[test]
8901    fn test_typical_price_sum_zero_for_empty() {
8902        assert_eq!(OhlcvBar::typical_price_sum(&[]), dec!(0));
8903    }
8904
8905    #[test]
8906    fn test_typical_price_sum_correct_value() {
8907        let b = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
8908        // typical = (120+80+100)/3 = 300/3 = 100
8909        assert_eq!(OhlcvBar::typical_price_sum(&[b]), dec!(100));
8910    }
8911
8912    #[test]
8913    fn test_max_body_size_none_for_empty() {
8914        assert!(OhlcvBar::max_body_size(&[]).is_none());
8915    }
8916
8917    #[test]
8918    fn test_max_body_size_correct_value() {
8919        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
8920        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(103));
8921        assert_eq!(OhlcvBar::max_body_size(&[b1, b2]).unwrap(), dec!(8));
8922    }
8923
8924    #[test]
8925    fn test_min_body_size_none_for_empty() {
8926        assert!(OhlcvBar::min_body_size(&[]).is_none());
8927    }
8928
8929    #[test]
8930    fn test_avg_lower_wick_to_range_none_for_empty() {
8931        assert!(OhlcvBar::avg_lower_wick_to_range(&[]).is_none());
8932    }
8933
8934    #[test]
8935    fn test_avg_lower_wick_to_range_zero_for_open_at_low() {
8936        // open = low, so lower wick = 0
8937        let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(105));
8938        let r = OhlcvBar::avg_lower_wick_to_range(&[b]).unwrap();
8939        assert!(r.abs() < 1e-9, "open=low → lower wick=0, got {}", r);
8940    }
8941
8942    // ── round-85 extra tests ──────────────────────────────────────────────────
8943
8944    #[test]
8945    fn test_total_range_zero_for_empty() {
8946        assert_eq!(OhlcvBar::total_range(&[]), dec!(0));
8947    }
8948
8949    #[test]
8950    fn test_total_range_sum_of_ranges() {
8951        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
8952        let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110)); // range=20
8953        assert_eq!(OhlcvBar::total_range(&[b1, b2]), dec!(40));
8954    }
8955
8956    #[test]
8957    fn test_close_at_high_fraction_none_for_empty() {
8958        assert!(OhlcvBar::close_at_high_fraction(&[]).is_none());
8959    }
8960
8961    #[test]
8962    fn test_close_at_high_fraction_one_when_all_close_at_high() {
8963        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
8964        let f = OhlcvBar::close_at_high_fraction(&[b]).unwrap();
8965        assert!((f - 1.0).abs() < 1e-9, "close=high → fraction=1, got {}", f);
8966    }
8967
8968    #[test]
8969    fn test_close_at_low_fraction_none_for_empty() {
8970        assert!(OhlcvBar::close_at_low_fraction(&[]).is_none());
8971    }
8972
8973    #[test]
8974    fn test_close_at_low_fraction_one_when_all_close_at_low() {
8975        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
8976        let f = OhlcvBar::close_at_low_fraction(&[b]).unwrap();
8977        assert!((f - 1.0).abs() < 1e-9, "close=low → fraction=1, got {}", f);
8978    }
8979
8980    #[test]
8981    fn test_avg_high_above_open_ratio_none_for_empty() {
8982        assert!(OhlcvBar::avg_high_above_open_ratio(&[]).is_none());
8983    }
8984
8985    #[test]
8986    fn test_avg_high_above_open_ratio_in_range() {
8987        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8988        let r = OhlcvBar::avg_high_above_open_ratio(&[b]).unwrap();
8989        assert!(r >= 0.0 && r <= 1.0, "ratio in [0,1], got {}", r);
8990    }
8991
8992    #[test]
8993    fn test_continuation_bar_count_zero_for_single() {
8994        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
8995        assert_eq!(OhlcvBar::continuation_bar_count(&[b]), 0);
8996    }
8997
8998    #[test]
8999    fn test_down_close_volume_zero_for_all_up_close() {
9000        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // close > open
9001        assert_eq!(OhlcvBar::down_close_volume(&[b]), dec!(0));
9002    }
9003
9004    #[test]
9005    fn test_up_close_volume_zero_for_all_down_close() {
9006        let b = make_ohlcv_bar(dec!(105), dec!(110), dec!(90), dec!(100)); // close < open
9007        assert_eq!(OhlcvBar::up_close_volume(&[b]), dec!(0));
9008    }
9009
9010    // ── round-86 tests ────────────────────────────────────────────────────────
9011
9012    #[test]
9013    fn test_mean_open_none_for_empty() {
9014        assert!(OhlcvBar::mean_open(&[]).is_none());
9015    }
9016
9017    #[test]
9018    fn test_mean_open_correct() {
9019        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9020        let b2 = make_ohlcv_bar(dec!(200), dec!(210), dec!(190), dec!(205));
9021        assert_eq!(OhlcvBar::mean_open(&[b1, b2]).unwrap(), dec!(150));
9022    }
9023
9024    #[test]
9025    fn test_new_high_count_zero_for_single() {
9026        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9027        assert_eq!(OhlcvBar::new_high_count(&[b]), 0);
9028    }
9029
9030    #[test]
9031    fn test_new_high_count_correct() {
9032        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9033        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(115));
9034        let b3 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(110));
9035        assert_eq!(OhlcvBar::new_high_count(&[b1, b2, b3]), 1);
9036    }
9037
9038    #[test]
9039    fn test_new_low_count_zero_for_single() {
9040        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9041        assert_eq!(OhlcvBar::new_low_count(&[b]), 0);
9042    }
9043
9044    #[test]
9045    fn test_close_std_none_for_single() {
9046        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9047        assert!(OhlcvBar::close_std(&[b]).is_none());
9048    }
9049
9050    #[test]
9051    fn test_close_std_zero_for_constant_close() {
9052        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9053        let b2 = make_ohlcv_bar(dec!(101), dec!(111), dec!(91), dec!(105));
9054        let s = OhlcvBar::close_std(&[b1, b2]).unwrap();
9055        assert!(s.abs() < 1e-9, "constant close → std=0, got {}", s);
9056    }
9057
9058    #[test]
9059    fn test_zero_volume_fraction_none_for_empty() {
9060        assert!(OhlcvBar::zero_volume_fraction(&[]).is_none());
9061    }
9062
9063    #[test]
9064    fn test_zero_volume_fraction_zero_when_no_zero_volume() {
9065        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9066        let f = OhlcvBar::zero_volume_fraction(&[b]).unwrap();
9067        assert!(f.abs() < 1e-9, "bar has volume → zero_vol_fraction=0, got {}", f);
9068    }
9069
9070    // ── round-87 tests ────────────────────────────────────────────────────────
9071
9072    #[test]
9073    fn test_avg_open_to_close_none_for_empty() {
9074        assert!(OhlcvBar::avg_open_to_close(&[]).is_none());
9075    }
9076
9077    #[test]
9078    fn test_avg_open_to_close_positive_when_all_bullish() {
9079        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // close > open
9080        let r = OhlcvBar::avg_open_to_close(&[b]).unwrap();
9081        assert!(r > dec!(0), "bullish bar → avg_open_to_close > 0, got {}", r);
9082    }
9083
9084    #[test]
9085    fn test_avg_open_to_close_zero_for_doji() {
9086        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // close == open
9087        let r = OhlcvBar::avg_open_to_close(&[b]).unwrap();
9088        assert_eq!(r, dec!(0));
9089    }
9090
9091    #[test]
9092    fn test_max_bar_volume_none_for_empty() {
9093        assert!(OhlcvBar::max_bar_volume(&[]).is_none());
9094    }
9095
9096    #[test]
9097    fn test_max_bar_volume_selects_largest() {
9098        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9099        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9100        // make_ohlcv_bar sets volume=1 by default; override manually
9101        // use default and confirm max equals volume
9102        let vol = OhlcvBar::max_bar_volume(&[b1, b2]).unwrap();
9103        assert!(vol > dec!(0));
9104    }
9105
9106    #[test]
9107    fn test_min_bar_volume_none_for_empty() {
9108        assert!(OhlcvBar::min_bar_volume(&[]).is_none());
9109    }
9110
9111    #[test]
9112    fn test_body_to_range_std_none_for_single() {
9113        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9114        assert!(OhlcvBar::body_to_range_std(&[b]).is_none());
9115    }
9116
9117    #[test]
9118    fn test_body_to_range_std_nonneg_for_varied_bars() {
9119        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9120        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
9121        let s = OhlcvBar::body_to_range_std(&[b1, b2]).unwrap();
9122        assert!(s >= 0.0, "std dev should be non-negative, got {}", s);
9123    }
9124
9125    #[test]
9126    fn test_avg_wick_symmetry_none_for_empty() {
9127        assert!(OhlcvBar::avg_wick_symmetry(&[]).is_none());
9128    }
9129
9130    #[test]
9131    fn test_avg_wick_symmetry_in_range() {
9132        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
9133        // upper wick = 10, lower wick = 10 → perfectly symmetric → ratio = 1
9134        let s = OhlcvBar::avg_wick_symmetry(&[b]).unwrap();
9135        assert!(s >= 0.0 && s <= 1.0, "symmetry in [0,1], got {}", s);
9136    }
9137
9138    // ── round-88 tests ────────────────────────────────────────────────────────
9139
9140    #[test]
9141    fn test_avg_range_pct_of_open_none_for_empty() {
9142        assert!(OhlcvBar::avg_range_pct_of_open(&[]).is_none());
9143    }
9144
9145    #[test]
9146    fn test_avg_range_pct_of_open_correct() {
9147        // range = 20 (110 - 90), open = 100 → 20%
9148        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9149        let r = OhlcvBar::avg_range_pct_of_open(&[b]).unwrap();
9150        assert!((r - 0.2).abs() < 1e-9, "range/open = 0.2, got {}", r);
9151    }
9152
9153    #[test]
9154    fn test_high_volume_fraction_none_for_empty() {
9155        assert!(OhlcvBar::high_volume_fraction(&[]).is_none());
9156    }
9157
9158    #[test]
9159    fn test_close_cluster_count_zero_for_single_bar() {
9160        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9161        assert_eq!(OhlcvBar::close_cluster_count(&[b]), 0);
9162    }
9163
9164    #[test]
9165    fn test_mean_vwap_none_for_bars_without_vwap() {
9166        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9167        assert!(OhlcvBar::mean_vwap(&[b]).is_none());
9168    }
9169
9170    #[test]
9171    fn test_complete_fraction_none_for_empty() {
9172        assert!(OhlcvBar::complete_fraction(&[]).is_none());
9173    }
9174
9175    #[test]
9176    fn test_complete_fraction_zero_when_none_complete() {
9177        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9178        // make_ohlcv_bar sets is_complete=false by default
9179        let f = OhlcvBar::complete_fraction(&[b]).unwrap();
9180        assert!(f.abs() < 1e-9, "no complete bars → fraction=0, got {}", f);
9181    }
9182
9183    #[test]
9184    fn test_total_body_movement_zero_for_empty() {
9185        assert_eq!(OhlcvBar::total_body_movement(&[]), rust_decimal::Decimal::ZERO);
9186    }
9187
9188    #[test]
9189    fn test_total_body_movement_correct() {
9190        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // body = 5
9191        let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(100)); // body = 10
9192        assert_eq!(OhlcvBar::total_body_movement(&[b1, b2]), dec!(15));
9193    }
9194
9195    #[test]
9196    fn test_open_std_none_for_single() {
9197        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9198        assert!(OhlcvBar::open_std(&[b]).is_none());
9199    }
9200
9201    #[test]
9202    fn test_open_std_zero_for_constant_open() {
9203        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9204        let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
9205        let s = OhlcvBar::open_std(&[b1, b2]).unwrap();
9206        assert!(s.abs() < 1e-9, "constant open → std=0, got {}", s);
9207    }
9208
9209    #[test]
9210    fn test_mean_high_low_ratio_none_for_empty() {
9211        assert!(OhlcvBar::mean_high_low_ratio(&[]).is_none());
9212    }
9213
9214    #[test]
9215    fn test_mean_high_low_ratio_above_one() {
9216        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
9217        let r = OhlcvBar::mean_high_low_ratio(&[b]).unwrap();
9218        assert!(r > 1.0, "high > low → ratio > 1, got {}", r);
9219    }
9220
9221    // ── round-89 tests ────────────────────────────────────────────────────────
9222
9223    #[test]
9224    fn test_max_consecutive_up_bars_zero_for_empty() {
9225        assert_eq!(OhlcvBar::max_consecutive_up_bars(&[]), 0);
9226    }
9227
9228    #[test]
9229    fn test_max_consecutive_up_bars_zero_for_all_down() {
9230        let b = make_ohlcv_bar(dec!(105), dec!(110), dec!(90), dec!(100)); // close < open
9231        assert_eq!(OhlcvBar::max_consecutive_up_bars(&[b]), 0);
9232    }
9233
9234    #[test]
9235    fn test_max_consecutive_up_bars_correct_run() {
9236        let up = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // up
9237        let dn = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(100)); // down
9238        let up2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); // up
9239        let up3 = make_ohlcv_bar(dec!(108), dec!(120), dec!(100), dec!(115)); // up
9240        assert_eq!(OhlcvBar::max_consecutive_up_bars(&[up, dn, up2, up3]), 2);
9241    }
9242
9243    #[test]
9244    fn test_avg_upper_shadow_fraction_none_for_empty() {
9245        assert!(OhlcvBar::avg_upper_shadow_fraction(&[]).is_none());
9246    }
9247
9248    #[test]
9249    fn test_avg_upper_shadow_fraction_in_range() {
9250        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // close==open, upper=10, range=20
9251        let f = OhlcvBar::avg_upper_shadow_fraction(&[b]).unwrap();
9252        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
9253    }
9254
9255    #[test]
9256    fn test_up_down_bar_ratio_none_for_no_down_bars() {
9257        // All up-close bars
9258        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9259        assert!(OhlcvBar::up_down_bar_ratio(&[b]).is_none());
9260    }
9261
9262    #[test]
9263    fn test_up_down_bar_ratio_one_for_balanced() {
9264        let up_bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9265        let dn_bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(90), dec!(100));
9266        let r = OhlcvBar::up_down_bar_ratio(&[up_bar, dn_bar]).unwrap();
9267        assert!((r - 1.0).abs() < 1e-9, "1 up / 1 down → 1.0, got {}", r);
9268    }
9269
9270    // ── round-90 tests ────────────────────────────────────────────────────────
9271
9272    #[test]
9273    fn test_close_range_fraction_none_for_empty() {
9274        assert!(OhlcvBar::close_range_fraction(&[]).is_none());
9275    }
9276
9277    #[test]
9278    fn test_close_range_fraction_one_for_close_at_high() {
9279        // close == high → (close-low)/(high-low) = 1.0 > 0.5 → fraction = 1.0
9280        let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
9281        let f = OhlcvBar::close_range_fraction(&[b]).unwrap();
9282        assert!((f - 1.0).abs() < 1e-9, "close=high → 1.0, got {}", f);
9283    }
9284
9285    #[test]
9286    fn test_close_range_fraction_zero_for_close_at_low() {
9287        // close == low → (close-low)/(high-low) = 0.0 < 0.5 → fraction = 0.0
9288        let b = make_ohlcv_bar(dec!(110), dec!(120), dec!(90), dec!(90));
9289        let f = OhlcvBar::close_range_fraction(&[b]).unwrap();
9290        assert!((f - 0.0).abs() < 1e-9, "close=low → 0.0, got {}", f);
9291    }
9292
9293    #[test]
9294    fn test_tail_symmetry_none_for_empty() {
9295        assert!(OhlcvBar::tail_symmetry(&[]).is_none());
9296    }
9297
9298    #[test]
9299    fn test_tail_symmetry_one_for_symmetric_bar() {
9300        // open and close at midpoint → equal upper/lower shadows
9301        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // open=close=100, high=110, low=90
9302        let s = OhlcvBar::tail_symmetry(&[b]).unwrap();
9303        assert!((s - 1.0).abs() < 1e-9, "symmetric bar → 1.0, got {}", s);
9304    }
9305
9306    #[test]
9307    fn test_bar_trend_strength_none_for_single_bar() {
9308        let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9309        assert!(OhlcvBar::bar_trend_strength(&[b]).is_none());
9310    }
9311
9312    #[test]
9313    fn test_bar_trend_strength_one_for_monotone_up() {
9314        let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
9315        let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
9316        let b3 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
9317        // all closes increasing: 100 → 105 → 110
9318        let s = OhlcvBar::bar_trend_strength(&[b1, b2, b3]).unwrap();
9319        assert!((s - 1.0).abs() < 1e-9, "monotone up → 1.0, got {}", s);
9320    }
9321}