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