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 std::fmt::Display for Timeframe {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Timeframe::Seconds(s) => write!(f, "{s}s"),
68            Timeframe::Minutes(m) => write!(f, "{m}m"),
69            Timeframe::Hours(h) => write!(f, "{h}h"),
70        }
71    }
72}
73
74/// Direction of an OHLCV bar body.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum BarDirection {
77    /// Close is strictly above open.
78    Bullish,
79    /// Close is strictly below open.
80    Bearish,
81    /// Close equals open (flat body).
82    Neutral,
83}
84
85/// A completed or partial OHLCV bar.
86#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
87pub struct OhlcvBar {
88    /// Instrument symbol (e.g. `"BTC-USD"`).
89    pub symbol: String,
90    /// Timeframe of this bar.
91    pub timeframe: Timeframe,
92    /// UTC millisecond timestamp of the bar's open boundary.
93    pub bar_start_ms: u64,
94    /// Opening price (first tick's price in the bar window).
95    pub open: Decimal,
96    /// Highest price seen in the bar window.
97    pub high: Decimal,
98    /// Lowest price seen in the bar window.
99    pub low: Decimal,
100    /// Closing price (most recent tick's price in the bar window).
101    pub close: Decimal,
102    /// Total traded volume in this bar.
103    pub volume: Decimal,
104    /// Number of ticks contributing to this bar.
105    pub trade_count: u64,
106    /// `true` once the bar's time window has been closed by a tick in a later window.
107    pub is_complete: bool,
108    /// `true` if this bar was synthesized to fill a gap — no real ticks were received
109    /// during its window. Gap-fill bars have `trade_count == 0` and all OHLC fields set
110    /// to the last known close price. Callers may use this flag to filter synthetic bars
111    /// out of indicator calculations or storage.
112    pub is_gap_fill: bool,
113    /// Volume-weighted average price for this bar. `None` for gap-fill bars.
114    pub vwap: Option<Decimal>,
115}
116
117impl OhlcvBar {
118    /// Price range of the bar: `high - low`.
119    pub fn range(&self) -> Decimal {
120        self.high - self.low
121    }
122
123    /// Candle body size: `(close - open).abs()`.
124    ///
125    /// Direction-independent; use `close > open` to determine bullish/bearish.
126    pub fn body(&self) -> Decimal {
127        (self.close - self.open).abs()
128    }
129
130    /// Returns `true` if this is a bullish bar (`close > open`).
131    pub fn is_bullish(&self) -> bool {
132        self.close > self.open
133    }
134
135    /// Returns `true` if this is a bearish bar (`close < open`).
136    pub fn is_bearish(&self) -> bool {
137        self.close < self.open
138    }
139
140    /// Returns `true` if the bar has a non-zero upper wick (`high > max(open, close)`).
141    pub fn has_upper_wick(&self) -> bool {
142        self.wick_upper() > Decimal::ZERO
143    }
144
145    /// Returns `true` if the bar has a non-zero lower wick (`min(open, close) > low`).
146    pub fn has_lower_wick(&self) -> bool {
147        self.wick_lower() > Decimal::ZERO
148    }
149
150    /// Directional classification of the bar body.
151    ///
152    /// Returns [`BarDirection::Bullish`] when `close > open`, [`BarDirection::Bearish`]
153    /// when `close < open`, and [`BarDirection::Neutral`] when they are equal.
154    pub fn body_direction(&self) -> BarDirection {
155        use std::cmp::Ordering;
156        match self.close.cmp(&self.open) {
157            Ordering::Greater => BarDirection::Bullish,
158            Ordering::Less => BarDirection::Bearish,
159            Ordering::Equal => BarDirection::Neutral,
160        }
161    }
162
163    /// Returns `true` if the bar body is a doji (indecision candle).
164    ///
165    /// A doji has `|close - open| <= epsilon`. Use a small positive `epsilon`
166    /// such as `dec!(0.01)` to account for rounding in price data.
167    pub fn is_doji(&self, epsilon: Decimal) -> bool {
168        self.body() <= epsilon
169    }
170
171    /// Upper wick (shadow) length: `high - max(open, close)`.
172    ///
173    /// The upper wick is the portion of the candle above the body.
174    pub fn wick_upper(&self) -> Decimal {
175        self.high - self.open.max(self.close)
176    }
177
178    /// Lower wick (shadow) length: `min(open, close) - low`.
179    ///
180    /// The lower wick is the portion of the candle below the body.
181    pub fn wick_lower(&self) -> Decimal {
182        self.open.min(self.close) - self.low
183    }
184
185    /// Signed price change: `close - open`.
186    ///
187    /// Positive for bullish bars, negative for bearish bars, zero for doji.
188    /// Unlike [`body`](Self::body), this preserves direction.
189    pub fn price_change(&self) -> Decimal {
190        self.close - self.open
191    }
192
193    /// Typical price: `(high + low + close) / 3`.
194    ///
195    /// Commonly used as the basis for VWAP and commodity channel index (CCI)
196    /// calculations.
197    pub fn typical_price(&self) -> Decimal {
198        (self.high + self.low + self.close) / Decimal::from(3)
199    }
200
201    /// Close Location Value (CLV): where the close sits within the bar's range.
202    ///
203    /// Formula: `(close - low - (high - close)) / range`.
204    ///
205    /// Returns `None` if the range is zero (e.g. a single-price bar). Values
206    /// are in `[-1.0, 1.0]`: `+1.0` means the close is at the high, `-1.0` at
207    /// the low, and `0.0` means the close is exactly mid-range.
208    pub fn close_location_value(&self) -> Option<f64> {
209        use rust_decimal::prelude::ToPrimitive;
210        let range = self.range();
211        if range.is_zero() {
212            return None;
213        }
214        ((self.close - self.low - (self.high - self.close)) / range).to_f64()
215    }
216
217    /// Median price: `(high + low) / 2`.
218    ///
219    /// The midpoint of the bar's price range, independent of open and close.
220    pub fn median_price(&self) -> Decimal {
221        (self.high + self.low) / Decimal::from(2)
222    }
223
224    /// Weighted close price: `(high + low + close × 2) / 4`.
225    ///
226    /// Gives extra weight to the closing price over the high and low extremes.
227    /// Commonly used as the basis for certain momentum and volatility indicators.
228    pub fn weighted_close(&self) -> Decimal {
229        (self.high + self.low + self.close + self.close) / Decimal::from(4)
230    }
231
232    /// Percentage price change: `(close − open) / open × 100`.
233    ///
234    /// Returns `None` if `open` is zero. Positive values indicate a bullish bar;
235    /// negative values indicate a bearish bar.
236    pub fn price_change_pct(&self) -> Option<f64> {
237        use rust_decimal::prelude::ToPrimitive;
238        if self.open.is_zero() {
239            return None;
240        }
241        let pct = (self.close - self.open) / self.open * Decimal::from(100);
242        pct.to_f64()
243    }
244
245    /// Body ratio: `body / range`.
246    ///
247    /// The fraction of the total price range that is body (rather than wicks).
248    /// Ranges from `0.0` (pure wicks / doji) to `1.0` (no wicks at all).
249    /// Returns `None` if the bar's range is zero (all prices identical).
250    pub fn body_ratio(&self) -> Option<f64> {
251        use rust_decimal::prelude::ToPrimitive;
252        let range = self.range();
253        if range.is_zero() {
254            return None;
255        }
256        (self.body() / range).to_f64()
257    }
258
259    /// True range: `max(high − low, |high − prev_close|, |low − prev_close|)`.
260    ///
261    /// The standard ATR (Average True Range) input. Accounts for overnight gaps by
262    /// including the distance from the previous close to today's high and low.
263    pub fn true_range(&self, prev_close: Decimal) -> Decimal {
264        let hl = self.high - self.low;
265        let hpc = (self.high - prev_close).abs();
266        let lpc = (self.low - prev_close).abs();
267        hl.max(hpc).max(lpc)
268    }
269
270    /// Returns `true` if this bar is an inside bar relative to `prev`.
271    ///
272    /// An inside bar has `high < prev.high` and `low > prev.low` — its full
273    /// range is contained within the prior bar's range. Used in price action
274    /// trading as a consolidation signal.
275    pub fn inside_bar(&self, prev: &OhlcvBar) -> bool {
276        self.high < prev.high && self.low > prev.low
277    }
278
279    /// Returns `true` if this bar is an outside bar relative to `prev`.
280    ///
281    /// An outside bar has `high > prev.high` and `low < prev.low` — it fully
282    /// engulfs the prior bar's range. Also called a key reversal day.
283    pub fn outside_bar(&self, prev: &OhlcvBar) -> bool {
284        self.high > prev.high && self.low < prev.low
285    }
286
287    /// Returns the ratio of total wick length to bar range: `(upper_wick + lower_wick) / range`.
288    ///
289    /// A value near 1 indicates a bar that is mostly wicks with little body.
290    /// Returns `None` when the bar has zero range (high == low).
291    pub fn wick_ratio(&self) -> Option<f64> {
292        use rust_decimal::prelude::ToPrimitive;
293        let range = self.range();
294        if range.is_zero() {
295            return None;
296        }
297        ((self.wick_upper() + self.wick_lower()) / range).to_f64()
298    }
299
300    /// Returns `true` if this bar has a classic hammer shape.
301    ///
302    /// A hammer has:
303    /// - A small body (≤ 30% of range)
304    /// - A long lower wick (≥ 60% of range)
305    /// - A tiny upper wick (≤ 10% of range)
306    ///
307    /// Returns `false` if the bar's range is zero.
308    pub fn is_hammer(&self) -> bool {
309        let range = self.range();
310        if range.is_zero() {
311            return false;
312        }
313        let body = self.body();
314        let wick_lo = self.wick_lower();
315        let wick_hi = self.wick_upper();
316        let three = Decimal::from(3);
317        let six = Decimal::from(6);
318        let ten = Decimal::from(10);
319        // body ≤ 30%: body*10 ≤ range*3
320        // lower wick ≥ 60%: wick_lo*10 ≥ range*6
321        // upper wick ≤ 10%: wick_hi*10 ≤ range
322        body * ten <= range * three
323            && wick_lo * ten >= range * six
324            && wick_hi * ten <= range
325    }
326
327    /// Returns `true` if this bar has a classic shooting-star shape.
328    ///
329    /// A shooting star has:
330    /// - A small body (≤ 30% of range)
331    /// - A long upper wick (≥ 60% of range)
332    /// - A tiny lower wick (≤ 10% of range)
333    ///
334    /// This is the inverse of a hammer — it signals a potential reversal at
335    /// the top of an uptrend. Returns `false` if the bar's range is zero.
336    pub fn is_shooting_star(&self) -> bool {
337        let range = self.range();
338        if range.is_zero() {
339            return false;
340        }
341        let body = self.body();
342        let wick_lo = self.wick_lower();
343        let wick_hi = self.wick_upper();
344        let three = Decimal::from(3);
345        let six = Decimal::from(6);
346        let ten = Decimal::from(10);
347        // body ≤ 30%: body*10 ≤ range*3
348        // upper wick ≥ 60%: wick_hi*10 ≥ range*6
349        // lower wick ≤ 10%: wick_lo*10 ≤ range
350        body * ten <= range * three
351            && wick_hi * ten >= range * six
352            && wick_lo * ten <= range
353    }
354
355    /// Gap from the previous bar: `self.open − prev.close`.
356    ///
357    /// Positive values indicate a gap-up; negative values indicate a gap-down.
358    /// Zero means the bar opened exactly at the previous close (no gap).
359    pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
360        self.open - prev.close
361    }
362
363    /// Returns `true` if this bar opened above the previous bar's close.
364    pub fn is_gap_up(&self, prev: &OhlcvBar) -> bool {
365        self.open > prev.close
366    }
367
368    /// Returns `true` if this bar opened below the previous bar's close.
369    pub fn is_gap_down(&self, prev: &OhlcvBar) -> bool {
370        self.open < prev.close
371    }
372
373    /// Body midpoint: `(open + close) / 2`.
374    ///
375    /// The arithmetic center of the candle body, regardless of direction.
376    /// Useful as a proxy for the "fair value" of the period.
377    pub fn bar_midpoint(&self) -> Decimal {
378        (self.open + self.close) / Decimal::from(2)
379    }
380
381    /// Body as a fraction of total range: `body / range`.
382    ///
383    /// Returns `None` when `range` is zero (all OHLC prices identical).
384    pub fn body_to_range_ratio(&self) -> Option<Decimal> {
385        let r = self.range();
386        if r.is_zero() {
387            return None;
388        }
389        Some(self.body() / r)
390    }
391
392    /// Returns `true` if the upper wick is longer than the candle body.
393    ///
394    /// Indicates a bearish rejection at the high (supply above current price).
395    pub fn is_long_upper_wick(&self) -> bool {
396        self.wick_upper() > self.body()
397    }
398
399    /// Absolute price change over the bar: `|close − open|`.
400    pub fn price_change_abs(&self) -> Decimal {
401        (self.close - self.open).abs()
402    }
403
404    /// Upper shadow length — alias for [`wick_upper`](Self::wick_upper).
405    ///
406    /// Returns `high − max(open, close)`.
407    pub fn upper_shadow(&self) -> Decimal {
408        self.wick_upper()
409    }
410
411    /// Lower shadow length — alias for [`wick_lower`](Self::wick_lower).
412    ///
413    /// Returns `min(open, close) − low`.
414    pub fn lower_shadow(&self) -> Decimal {
415        self.wick_lower()
416    }
417
418    /// Returns `true` if this bar has a spinning-top pattern.
419    ///
420    /// A spinning top has a small body (≤ `body_pct` of range) with significant
421    /// wicks on both sides (each wick strictly greater than the body). Signals
422    /// market indecision — neither buyers nor sellers controlled the period.
423    ///
424    /// `body_pct` is a fraction in `[0.0, 1.0]`, e.g. `dec!(0.3)` for 30%.
425    /// Returns `false` if the bar's range is zero.
426    pub fn is_spinning_top(&self, body_pct: Decimal) -> bool {
427        let range = self.range();
428        if range.is_zero() {
429            return false;
430        }
431        let body = self.body();
432        let max_body = range * body_pct;
433        body <= max_body && self.wick_upper() > body && self.wick_lower() > body
434    }
435
436    /// HLC3: `(high + low + close) / 3` — alias for [`typical_price`](Self::typical_price).
437    pub fn hlc3(&self) -> Decimal {
438        self.typical_price()
439    }
440
441    /// OHLC4: `(open + high + low + close) / 4`.
442    ///
443    /// Gives equal weight to all four price points. Sometimes used as a smoother
444    /// proxy than typical price because it incorporates the open.
445    pub fn ohlc4(&self) -> Decimal {
446        (self.open + self.high + self.low + self.close) / Decimal::from(4)
447    }
448
449    /// Returns `true` if this bar is a marubozu — no upper or lower wicks.
450    ///
451    /// A marubozu has `open == low` and `close == high` (bullish) or
452    /// `open == high` and `close == low` (bearish). It signals strong
453    /// one-directional momentum with no intrabar rejection.
454    /// A zero-range bar (all prices equal) is considered a marubozu.
455    pub fn is_marubozu(&self) -> bool {
456        self.wick_upper().is_zero() && self.wick_lower().is_zero()
457    }
458
459    /// Returns `true` if this bar's body engulfs `prev`'s body.
460    ///
461    /// Engulfing requires: `self.open < prev.open.min(prev.close)` and
462    /// `self.close > prev.open.max(prev.close)` (or vice versa for bearish).
463    /// Specifically, `self.body_low < prev.body_low` and
464    /// `self.body_high > prev.body_high`.
465    ///
466    /// Does NOT require opposite directions — use in combination with
467    /// [`is_bullish`](Self::is_bullish) / [`is_bearish`](Self::is_bearish) if
468    /// classic engulfing patterns are needed.
469    pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
470        let self_lo = self.open.min(self.close);
471        let self_hi = self.open.max(self.close);
472        let prev_lo = prev.open.min(prev.close);
473        let prev_hi = prev.open.max(prev.close);
474        self_lo < prev_lo && self_hi > prev_hi
475    }
476
477    /// Returns `true` if this bar is a harami: its body is entirely contained
478    /// within the previous bar's body.
479    ///
480    /// A harami is the opposite of an engulfing pattern. Neither bar needs to
481    /// be bullish or bearish — only the body ranges are compared.
482    pub fn is_harami(&self, prev: &OhlcvBar) -> bool {
483        let self_lo = self.open.min(self.close);
484        let self_hi = self.open.max(self.close);
485        let prev_lo = prev.open.min(prev.close);
486        let prev_hi = prev.open.max(prev.close);
487        self_lo > prev_lo && self_hi < prev_hi
488    }
489
490    /// The longer of the upper and lower wicks.
491    ///
492    /// Returns the maximum of `wick_upper()` and `wick_lower()`. Useful for
493    /// identifying long-tailed candles regardless of direction.
494    pub fn tail_length(&self) -> Decimal {
495        self.wick_upper().max(self.wick_lower())
496    }
497
498    /// Returns `true` if this bar is an inside bar: both `high` and `low` are
499    /// strictly within the previous bar's range.
500    ///
501    /// Unlike [`is_harami`](Self::is_harami), which compares body ranges,
502    /// this method compares the full high-low range including wicks.
503    pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
504        self.high < prev.high && self.low > prev.low
505    }
506
507    /// Returns `true` if this bar opened above the previous bar's high (gap up).
508    pub fn gap_up(&self, prev: &OhlcvBar) -> bool {
509        self.open > prev.high
510    }
511
512    /// Returns `true` if this bar opened below the previous bar's low (gap down).
513    pub fn gap_down(&self, prev: &OhlcvBar) -> bool {
514        self.open < prev.low
515    }
516
517    /// Absolute size of the candle body: `|close - open|`.
518    pub fn body_size(&self) -> Decimal {
519        (self.close - self.open).abs()
520    }
521
522    /// Volume change vs the previous bar: `self.volume - prev.volume`.
523    pub fn volume_delta(&self, prev: &OhlcvBar) -> Decimal {
524        self.volume - prev.volume
525    }
526
527    /// Returns `true` if this bar's range is less than 50% of the previous bar's range.
528    ///
529    /// Indicates price consolidation / compression.
530    pub fn is_consolidating(&self, prev: &OhlcvBar) -> bool {
531        let prev_range = prev.high - prev.low;
532        if prev_range.is_zero() {
533            return false;
534        }
535        let this_range = self.high - self.low;
536        this_range < prev_range / Decimal::TWO
537    }
538
539    /// Mean volume across a slice of bars.
540    ///
541    /// Returns `None` if the slice is empty.
542    pub fn mean_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
543        if bars.is_empty() {
544            return None;
545        }
546        let sum: Decimal = bars.iter().map(|b| b.volume).sum();
547        Some(sum / Decimal::from(bars.len() as u64))
548    }
549
550    /// Absolute deviation of close price from VWAP as a fraction of VWAP: `|close - vwap| / vwap`.
551    ///
552    /// Returns `None` if `vwap` is not set or is zero.
553    pub fn vwap_deviation(&self) -> Option<f64> {
554        use rust_decimal::prelude::ToPrimitive;
555        let vwap = self.vwap?;
556        if vwap.is_zero() {
557            return None;
558        }
559        ((self.close - vwap).abs() / vwap).to_f64()
560    }
561
562    /// Volume as a ratio of `avg_volume`.
563    ///
564    /// Returns `None` if `avg_volume` is zero.
565    pub fn relative_volume(&self, avg_volume: Decimal) -> Option<f64> {
566        use rust_decimal::prelude::ToPrimitive;
567        if avg_volume.is_zero() {
568            return None;
569        }
570        (self.volume / avg_volume).to_f64()
571    }
572
573    /// Returns `true` if this bar opens in the direction of the prior bar's move
574    /// but closes against it (an intraday reversal signal).
575    ///
576    /// Specifically: prev was bullish (close > open), this bar opens near/above prev close,
577    /// and closes below prev open — or vice versa for a bearish reversal.
578    pub fn intraday_reversal(&self, prev: &OhlcvBar) -> bool {
579        let prev_bullish = prev.close > prev.open;
580        let this_bearish = self.close < self.open;
581        let prev_bearish = prev.close < prev.open;
582        let this_bullish = self.close > self.open;
583        (prev_bullish && this_bearish && self.open >= prev.close)
584            || (prev_bearish && this_bullish && self.open <= prev.close)
585    }
586
587    /// High-low range as a percentage of the open price: `(high - low) / open * 100`.
588    ///
589    /// Returns `None` if open is zero.
590    pub fn range_pct(&self) -> Option<f64> {
591        use rust_decimal::prelude::ToPrimitive;
592        if self.open.is_zero() {
593            return None;
594        }
595        let range = (self.high - self.low) / self.open;
596        range.to_f64().map(|v| v * 100.0)
597    }
598
599    /// Returns `true` if this bar is an outside bar (engulfs `prev`'s range).
600    ///
601    /// An outside bar has a higher high AND lower low than the previous bar.
602    pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
603        self.high > prev.high && self.low < prev.low
604    }
605
606    /// Midpoint of the high-low range: `(high + low) / 2`.
607    pub fn high_low_midpoint(&self) -> Decimal {
608        (self.high + self.low) / Decimal::TWO
609    }
610
611    /// Ratio of close to high: `close / high` as `f64`.
612    ///
613    /// Returns `None` if `high` is zero. A value near 1.0 means the bar closed
614    /// near its high (bullish strength); near 0.0 means it closed far below.
615    pub fn high_close_ratio(&self) -> Option<f64> {
616        use rust_decimal::prelude::ToPrimitive;
617        if self.high.is_zero() {
618            return None;
619        }
620        (self.close / self.high).to_f64()
621    }
622
623    /// Lower shadow as a fraction of the full bar range: `lower_shadow / range`.
624    ///
625    /// Returns `None` if the bar's range is zero.
626    pub fn lower_shadow_pct(&self) -> Option<f64> {
627        use rust_decimal::prelude::ToPrimitive;
628        let range = self.high - self.low;
629        if range.is_zero() {
630            return None;
631        }
632        (self.lower_shadow() / range).to_f64()
633    }
634
635    /// Ratio of close to open: `close / open` as `f64`.
636    ///
637    /// Returns `None` if `open` is zero. Values above 1.0 indicate a bullish bar.
638    pub fn open_close_ratio(&self) -> Option<f64> {
639        use rust_decimal::prelude::ToPrimitive;
640        if self.open.is_zero() {
641            return None;
642        }
643        (self.close / self.open).to_f64()
644    }
645
646    /// Returns `true` if this bar's range (`high - low`) exceeds `threshold`.
647    pub fn is_wide_range_bar(&self, threshold: Decimal) -> bool {
648        (self.high - self.low) > threshold
649    }
650
651    /// Position of close within the bar's high-low range: `(close - low) / (high - low)`.
652    ///
653    /// Returns `None` if the bar's range is zero. Result is in `[0.0, 1.0]`:
654    /// - `0.0` → closed at the low (bearish)
655    /// - `1.0` → closed at the high (bullish)
656    pub fn close_to_low_ratio(&self) -> Option<f64> {
657        use rust_decimal::prelude::ToPrimitive;
658        let range = self.high - self.low;
659        if range.is_zero() {
660            return None;
661        }
662        ((self.close - self.low) / range).to_f64()
663    }
664
665    /// Average volume per trade: `volume / trade_count`.
666    ///
667    /// Returns `None` if `trade_count` is zero.
668    pub fn volume_per_trade(&self) -> Option<Decimal> {
669        if self.trade_count == 0 {
670            return None;
671        }
672        Some(self.volume / Decimal::from(self.trade_count as u64))
673    }
674
675    /// Returns `true` if this bar's high-low range overlaps with `other`'s range.
676    ///
677    /// Two ranges overlap when neither is entirely above or below the other.
678    pub fn price_range_overlap(&self, other: &OhlcvBar) -> bool {
679        self.high >= other.low && other.high >= self.low
680    }
681
682    /// Bar height as a fraction of the open price: `(high - low) / open`.
683    ///
684    /// Returns `None` if `open` is zero. Useful for comparing volatility across
685    /// instruments trading at different price levels.
686    pub fn bar_height_pct(&self) -> Option<f64> {
687        use rust_decimal::prelude::ToPrimitive;
688        if self.open.is_zero() {
689            return None;
690        }
691        ((self.high - self.low) / self.open).to_f64()
692    }
693
694    /// Classifies this bar as `"bullish"`, `"bearish"`, or `"doji"`.
695    ///
696    /// A doji is a bar whose body is zero (open equals close). Otherwise the
697    /// direction is determined by whether close is above or below open.
698    pub fn bar_type(&self) -> &'static str {
699        if self.close == self.open {
700            "doji"
701        } else if self.close > self.open {
702            "bullish"
703        } else {
704            "bearish"
705        }
706    }
707
708    /// Body as a percentage of the total high-low range.
709    ///
710    /// Returns `None` when the range is zero (all four prices equal).
711    /// A 100% body means no wicks (marubozu); near 0% means a doji.
712    pub fn body_pct(&self) -> Option<Decimal> {
713        let range = self.range();
714        if range.is_zero() {
715            return None;
716        }
717        Some(self.body() / range * Decimal::ONE_HUNDRED)
718    }
719
720    /// Returns `true` if this bar is a bullish hammer: a long lower wick,
721    /// small body near the top of the range, and little or no upper wick.
722    ///
723    /// Specifically: the lower wick is at least twice the body, and the upper
724    /// wick is no more than the body.
725    pub fn is_bullish_hammer(&self) -> bool {
726        let body = self.body();
727        if body.is_zero() {
728            return false;
729        }
730        let lower = self.wick_lower();
731        let upper = self.wick_upper();
732        lower >= body * Decimal::TWO && upper <= body
733    }
734
735    /// Upper wick as a percentage of the total range (0–100).
736    ///
737    /// Returns `None` when the range is zero.
738    pub fn upper_wick_pct(&self) -> Option<Decimal> {
739        let range = self.range();
740        if range.is_zero() {
741            return None;
742        }
743        Some(self.wick_upper() / range * Decimal::ONE_HUNDRED)
744    }
745
746    /// Lower wick as a percentage of the total range (0–100).
747    ///
748    /// Returns `None` when the range is zero.
749    pub fn lower_wick_pct(&self) -> Option<Decimal> {
750        let range = self.range();
751        if range.is_zero() {
752            return None;
753        }
754        Some(self.wick_lower() / range * Decimal::ONE_HUNDRED)
755    }
756
757    /// Returns `true` if this bar is a bearish engulfing candle relative to `prev`.
758    ///
759    /// A bearish engulfing has: current bar bearish, body entirely engulfs prev body.
760    pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
761        self.is_bearish() && self.is_engulfing(prev)
762    }
763
764    /// Returns `true` if this bar is a bullish engulfing candle relative to `prev`.
765    ///
766    /// A bullish engulfing has: current bar bullish, body entirely engulfs prev body.
767    pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
768        self.is_bullish() && self.is_engulfing(prev)
769    }
770
771    /// Gap between this bar's open and the previous bar's close: `self.open - prev.close`.
772    ///
773    /// A positive value indicates an upward gap; negative indicates a downward gap.
774    pub fn close_gap(&self, prev: &OhlcvBar) -> Decimal {
775        self.open - prev.close
776    }
777
778    /// Returns `true` if the close price is strictly above the bar's midpoint `(high + low) / 2`.
779    pub fn close_above_midpoint(&self) -> bool {
780        self.close > self.high_low_midpoint()
781    }
782
783    /// Price momentum: `self.close - prev.close`.
784    ///
785    /// Positive → price increased; negative → decreased.
786    pub fn close_momentum(&self, prev: &OhlcvBar) -> Decimal {
787        self.close - prev.close
788    }
789
790    /// Full high-low range of the bar: `high - low`.
791    pub fn bar_range(&self) -> Decimal {
792        self.high - self.low
793    }
794
795    /// Duration of this bar's timeframe in milliseconds.
796    pub fn bar_duration_ms(&self) -> u64 {
797        self.timeframe.duration_ms()
798    }
799
800    /// Returns `true` if this bar resembles a gravestone doji.
801    ///
802    /// A gravestone doji has open ≈ close ≈ low (body within `epsilon` of
803    /// zero and close within `epsilon` of the low), with a long upper wick.
804    pub fn is_gravestone_doji(&self, epsilon: Decimal) -> bool {
805        self.body() <= epsilon && (self.close - self.low).abs() <= epsilon
806    }
807
808    /// Returns `true` if this bar resembles a dragonfly doji.
809    ///
810    /// A dragonfly doji has open ≈ close ≈ high (body within `epsilon` of
811    /// zero and close within `epsilon` of the high), with a long lower wick.
812    pub fn is_dragonfly_doji(&self, epsilon: Decimal) -> bool {
813        self.body() <= epsilon && (self.high - self.close).abs() <= epsilon
814    }
815
816    /// Returns `true` if this bar is completely flat (open == close == high == low).
817    pub fn is_flat(&self) -> bool {
818        self.open == self.close && self.high == self.low && self.open == self.high
819    }
820
821    /// True range: `max(high - low, |high - prev_close|, |low - prev_close|)`.
822    ///
823    /// This is the ATR building block. Without a previous close, returns `high - low`.
824    pub fn true_range_with_prev(&self, prev_close: Decimal) -> Decimal {
825        let hl = self.high - self.low;
826        let hc = (self.high - prev_close).abs();
827        let lc = (self.low - prev_close).abs();
828        hl.max(hc).max(lc)
829    }
830
831    /// Returns the ratio of close to high, or `None` if high is zero.
832    pub fn close_to_high_ratio(&self) -> Option<f64> {
833        use rust_decimal::prelude::ToPrimitive;
834        if self.high.is_zero() { return None; }
835        (self.close / self.high).to_f64()
836    }
837
838    /// Returns the ratio of close to open, or `None` if open is zero.
839    pub fn close_open_ratio(&self) -> Option<f64> {
840        use rust_decimal::prelude::ToPrimitive;
841        if self.open.is_zero() { return None; }
842        (self.close / self.open).to_f64()
843    }
844
845    /// Interpolates a price within the bar's high-low range.
846    ///
847    /// `pct = 0.0` returns `low`; `pct = 1.0` returns `high`.
848    /// Values outside `[0.0, 1.0]` are clamped to that interval.
849    pub fn price_at_pct(&self, pct: f64) -> Decimal {
850        use rust_decimal::prelude::FromPrimitive;
851        let pct_clamped = pct.clamp(0.0, 1.0);
852        let factor = Decimal::from_f64(pct_clamped).unwrap_or(Decimal::ZERO);
853        self.low + self.range() * factor
854    }
855}
856
857impl std::fmt::Display for OhlcvBar {
858    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
859        write!(
860            f,
861            "{} {} [{}/{}/{}/{}  v={}]",
862            self.symbol, self.timeframe, self.open, self.high, self.low, self.close, self.volume
863        )
864    }
865}
866
867/// Aggregates ticks into OHLCV bars.
868pub struct OhlcvAggregator {
869    symbol: String,
870    timeframe: Timeframe,
871    current_bar: Option<OhlcvBar>,
872    /// The most recently completed bar emitted by `feed` or `flush`.
873    last_bar: Option<OhlcvBar>,
874    /// When true, `feed` returns synthetic zero-volume bars for any bar windows
875    /// that were skipped between the previous tick and the current one.
876    /// The synthetic bars use the last known close price for all OHLC fields.
877    emit_empty_bars: bool,
878    /// Total number of completed bars emitted by this aggregator.
879    bars_emitted: u64,
880    /// Running sum of `price × quantity` for VWAP computation in the current bar.
881    price_volume_sum: Decimal,
882    /// Cumulative volume across all completed bars (does not include the current partial bar).
883    total_volume: Decimal,
884    /// Maximum single-bar volume seen across all completed bars.
885    peak_volume: Option<Decimal>,
886    /// Minimum single-bar volume seen across all completed bars.
887    min_volume: Option<Decimal>,
888}
889
890impl OhlcvAggregator {
891    /// Create a new aggregator for `symbol` at `timeframe`.
892    ///
893    /// Returns an error if `timeframe.duration_ms()` is zero, which would make
894    /// bar boundary alignment undefined.
895    pub fn new(symbol: impl Into<String>, timeframe: Timeframe) -> Result<Self, StreamError> {
896        let tf_dur = timeframe.duration_ms();
897        if tf_dur == 0 {
898            return Err(StreamError::ConfigError {
899                reason: "OhlcvAggregator timeframe duration must be > 0".into(),
900            });
901        }
902        Ok(Self {
903            symbol: symbol.into(),
904            timeframe,
905            current_bar: None,
906            last_bar: None,
907            emit_empty_bars: false,
908            bars_emitted: 0,
909            price_volume_sum: Decimal::ZERO,
910            total_volume: Decimal::ZERO,
911            peak_volume: None,
912            min_volume: None,
913        })
914    }
915
916    /// Enable emission of synthetic zero-volume bars for skipped bar windows.
917    pub fn with_emit_empty_bars(mut self, enabled: bool) -> Self {
918        self.emit_empty_bars = enabled;
919        self
920    }
921
922    /// Feed a tick. Returns completed bars (including any empty gap bars when
923    /// `emit_empty_bars` is true). At most one real completed bar plus zero or
924    /// more empty bars can be returned per call.
925    ///
926    /// Bar boundaries are aligned using the exchange-side timestamp
927    /// (`exchange_ts_ms`) when available, falling back to the local system
928    /// clock (`received_at_ms`). Using the exchange timestamp avoids
929    /// misalignment caused by variable network latency.
930    #[must_use = "completed bars are returned; ignoring them loses bar data"]
931    #[inline]
932    pub fn feed(&mut self, tick: &NormalizedTick) -> Result<Vec<OhlcvBar>, StreamError> {
933        if tick.symbol != self.symbol {
934            return Err(StreamError::AggregationError {
935                reason: format!(
936                    "tick symbol '{}' does not match aggregator '{}'",
937                    tick.symbol, self.symbol
938                ),
939            });
940        }
941
942        // Prefer the authoritative exchange timestamp; fall back to local clock.
943        let tick_ts = tick.exchange_ts_ms.unwrap_or(tick.received_at_ms);
944        let bar_start = self.timeframe.bar_start_ms(tick_ts);
945        let mut emitted: Vec<OhlcvBar> = Vec::new();
946
947        // Check whether the incoming tick belongs to a new bar window.
948        let bar_window_changed = self
949            .current_bar
950            .as_ref()
951            .map(|b| b.bar_start_ms != bar_start)
952            .unwrap_or(false);
953
954        if bar_window_changed {
955            // Take ownership — avoids cloning the current bar.
956            let mut completed = self.current_bar.take().unwrap_or_else(|| unreachable!());
957            completed.is_complete = true;
958            let prev_close = completed.close;
959            let prev_start = completed.bar_start_ms;
960            emitted.push(completed);
961
962            // Optionally fill any empty bar windows between prev_start and bar_start.
963            if self.emit_empty_bars {
964                let dur = self.timeframe.duration_ms();
965                let mut gap_start = prev_start + dur;
966                while gap_start < bar_start {
967                    emitted.push(OhlcvBar {
968                        symbol: self.symbol.clone(),
969                        timeframe: self.timeframe,
970                        bar_start_ms: gap_start,
971                        open: prev_close,
972                        high: prev_close,
973                        low: prev_close,
974                        close: prev_close,
975                        volume: Decimal::ZERO,
976                        trade_count: 0,
977                        is_complete: true,
978                        is_gap_fill: true,
979                        vwap: None,
980                    });
981                    gap_start += dur;
982                }
983            }
984        }
985
986        // Update price_volume_sum before the match to avoid borrow conflicts.
987        let tick_value = tick.price * tick.quantity;
988        if self.current_bar.is_some() {
989            self.price_volume_sum += tick_value;
990        } else {
991            self.price_volume_sum = tick_value;
992        }
993
994        match &mut self.current_bar {
995            Some(bar) => {
996                if tick.price > bar.high {
997                    bar.high = tick.price;
998                }
999                if tick.price < bar.low {
1000                    bar.low = tick.price;
1001                }
1002                bar.close = tick.price;
1003                bar.volume += tick.quantity;
1004                bar.trade_count += 1;
1005                bar.vwap = if bar.volume.is_zero() {
1006                    None
1007                } else {
1008                    Some(self.price_volume_sum / bar.volume)
1009                };
1010            }
1011            None => {
1012                self.current_bar = Some(OhlcvBar {
1013                    symbol: self.symbol.clone(),
1014                    timeframe: self.timeframe,
1015                    bar_start_ms: bar_start,
1016                    open: tick.price,
1017                    high: tick.price,
1018                    low: tick.price,
1019                    close: tick.price,
1020                    volume: tick.quantity,
1021                    trade_count: 1,
1022                    is_complete: false,
1023                    is_gap_fill: false,
1024                    vwap: Some(tick.price), // single-tick VWAP = price
1025                });
1026            }
1027        }
1028        self.bars_emitted += emitted.len() as u64;
1029        for b in &emitted {
1030            self.total_volume += b.volume;
1031            self.peak_volume = Some(match self.peak_volume {
1032                Some(prev) => prev.max(b.volume),
1033                None => b.volume,
1034            });
1035            self.min_volume = Some(match self.min_volume {
1036                Some(prev) => prev.min(b.volume),
1037                None => b.volume,
1038            });
1039        }
1040        if let Some(b) = emitted.last() {
1041            self.last_bar = Some(b.clone());
1042        }
1043        Ok(emitted)
1044    }
1045
1046    /// Current partial bar (if any).
1047    pub fn current_bar(&self) -> Option<&OhlcvBar> {
1048        self.current_bar.as_ref()
1049    }
1050
1051    /// Flush the current partial bar as complete.
1052    #[must_use = "the flushed bar is returned; ignoring it loses the partial bar"]
1053    pub fn flush(&mut self) -> Option<OhlcvBar> {
1054        let mut bar = self.current_bar.take()?;
1055        bar.is_complete = true;
1056        self.bars_emitted += 1;
1057        self.total_volume += bar.volume;
1058        self.peak_volume = Some(match self.peak_volume {
1059            Some(prev) => prev.max(bar.volume),
1060            None => bar.volume,
1061        });
1062        self.min_volume = Some(match self.min_volume {
1063            Some(prev) => prev.min(bar.volume),
1064            None => bar.volume,
1065        });
1066        self.last_bar = Some(bar.clone());
1067        Some(bar)
1068    }
1069
1070    /// The most recently completed bar emitted by [`feed`](Self::feed) or
1071    /// [`flush`](Self::flush). Returns `None` if no bar has been completed yet.
1072    ///
1073    /// Unlike [`current_bar`](Self::current_bar), this bar is always complete.
1074    pub fn last_bar(&self) -> Option<&OhlcvBar> {
1075        self.last_bar.as_ref()
1076    }
1077
1078    /// Total number of completed bars emitted by this aggregator (via `feed` or `flush`).
1079    pub fn bar_count(&self) -> u64 {
1080        self.bars_emitted
1081    }
1082
1083    /// Discard the in-progress bar and reset the bar counter to zero.
1084    ///
1085    /// Useful for backtesting rewind or when restarting aggregation from a
1086    /// new anchor point. Does not affect the aggregator's symbol or timeframe.
1087    pub fn reset(&mut self) {
1088        self.current_bar = None;
1089        self.last_bar = None;
1090        self.bars_emitted = 0;
1091        self.price_volume_sum = Decimal::ZERO;
1092        self.total_volume = Decimal::ZERO;
1093        self.peak_volume = None;
1094        self.min_volume = None;
1095    }
1096
1097    /// Cumulative traded volume across all completed bars emitted by this aggregator.
1098    ///
1099    /// Does not include the current partial bar's volume. Reset to zero by
1100    /// [`reset`](Self::reset).
1101    pub fn total_volume(&self) -> Decimal {
1102        self.total_volume
1103    }
1104
1105    /// Maximum single-bar volume seen across all completed bars.
1106    ///
1107    /// Returns `None` if no bars have been completed yet. Reset to `None` by
1108    /// [`reset`](Self::reset).
1109    pub fn peak_volume(&self) -> Option<Decimal> {
1110        self.peak_volume
1111    }
1112
1113    /// Minimum single-bar volume seen across all completed bars.
1114    ///
1115    /// Returns `None` if no bars have been completed yet. Reset to `None` by
1116    /// [`reset`](Self::reset).
1117    pub fn min_volume(&self) -> Option<Decimal> {
1118        self.min_volume
1119    }
1120
1121    /// Volume range across completed bars: `(min_volume, peak_volume)`.
1122    ///
1123    /// Returns `None` if no bars have been completed yet. Useful for
1124    /// normalizing volume signals to the observed range.
1125    pub fn volume_range(&self) -> Option<(Decimal, Decimal)> {
1126        Some((self.min_volume?, self.peak_volume?))
1127    }
1128
1129    /// Average volume per completed bar: `total_volume / bars_emitted`.
1130    ///
1131    /// Returns `None` if no bars have been completed yet (avoids division by zero).
1132    pub fn average_volume(&self) -> Option<Decimal> {
1133        if self.bars_emitted == 0 {
1134            return None;
1135        }
1136        Some(self.total_volume / Decimal::from(self.bars_emitted))
1137    }
1138
1139    /// The symbol this aggregator tracks.
1140    pub fn symbol(&self) -> &str {
1141        &self.symbol
1142    }
1143
1144    /// The timeframe used for bar alignment.
1145    pub fn timeframe(&self) -> Timeframe {
1146        self.timeframe
1147    }
1148
1149    /// Fraction of the current bar's time window that has elapsed, in `[0.0, 1.0]`.
1150    ///
1151    /// Returns `None` if no bar is in progress (no ticks seen since last
1152    /// flush/reset). `now_ms` should be ≥ the current bar's `bar_start_ms`;
1153    /// values before the start clamp to `0.0`.
1154    pub fn window_progress(&self, now_ms: u64) -> Option<f64> {
1155        let bar = self.current_bar.as_ref()?;
1156        let elapsed = now_ms.saturating_sub(bar.bar_start_ms);
1157        let duration = self.timeframe.duration_ms();
1158        let progress = elapsed as f64 / duration as f64;
1159        Some(progress.clamp(0.0, 1.0))
1160    }
1161
1162    /// Returns `true` if a bar is currently in progress (at least one tick has
1163    /// been fed since the last flush or reset).
1164    pub fn is_active(&self) -> bool {
1165        self.current_bar.is_some()
1166    }
1167
1168    /// Volume-weighted average price of the current in-progress bar.
1169    ///
1170    /// Returns `None` if no bar is currently being built or the bar has zero
1171    /// volume (should not happen with real ticks).
1172    pub fn vwap_current(&self) -> Option<Decimal> {
1173        let bar = self.current_bar.as_ref()?;
1174        if bar.volume.is_zero() {
1175            return None;
1176        }
1177        Some(self.price_volume_sum / bar.volume)
1178    }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184    use crate::tick::{Exchange, NormalizedTick, TradeSide};
1185    use rust_decimal_macros::dec;
1186
1187    fn make_tick(symbol: &str, price: Decimal, qty: Decimal, ts_ms: u64) -> NormalizedTick {
1188        NormalizedTick {
1189            exchange: Exchange::Binance,
1190            symbol: symbol.to_string(),
1191            price,
1192            quantity: qty,
1193            side: Some(TradeSide::Buy),
1194            trade_id: None,
1195            exchange_ts_ms: None,
1196            received_at_ms: ts_ms,
1197        }
1198    }
1199
1200    fn make_tick_with_exchange_ts(
1201        symbol: &str,
1202        price: Decimal,
1203        qty: Decimal,
1204        exchange_ts_ms: u64,
1205        received_at_ms: u64,
1206    ) -> NormalizedTick {
1207        NormalizedTick {
1208            exchange: Exchange::Binance,
1209            symbol: symbol.to_string(),
1210            price,
1211            quantity: qty,
1212            side: Some(TradeSide::Buy),
1213            trade_id: None,
1214            exchange_ts_ms: Some(exchange_ts_ms),
1215            received_at_ms,
1216        }
1217    }
1218
1219    fn agg(symbol: &str, tf: Timeframe) -> OhlcvAggregator {
1220        OhlcvAggregator::new(symbol, tf).unwrap()
1221    }
1222
1223    #[test]
1224    fn test_timeframe_seconds_duration_ms() {
1225        assert_eq!(Timeframe::Seconds(30).duration_ms(), 30_000);
1226    }
1227
1228    #[test]
1229    fn test_timeframe_minutes_duration_ms() {
1230        assert_eq!(Timeframe::Minutes(5).duration_ms(), 300_000);
1231    }
1232
1233    #[test]
1234    fn test_timeframe_hours_duration_ms() {
1235        assert_eq!(Timeframe::Hours(1).duration_ms(), 3_600_000);
1236    }
1237
1238    #[test]
1239    fn test_timeframe_bar_start_ms_aligns() {
1240        let tf = Timeframe::Minutes(1);
1241        let ts = 61_500; // 1min 1.5sec
1242        assert_eq!(tf.bar_start_ms(ts), 60_000);
1243    }
1244
1245    #[test]
1246    fn test_timeframe_display() {
1247        assert_eq!(Timeframe::Seconds(30).to_string(), "30s");
1248        assert_eq!(Timeframe::Minutes(5).to_string(), "5m");
1249        assert_eq!(Timeframe::Hours(4).to_string(), "4h");
1250    }
1251
1252    #[test]
1253    fn test_ohlcv_aggregator_first_tick_sets_ohlcv() {
1254        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1255        let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 60_000);
1256        let result = agg.feed(&tick).unwrap();
1257        assert!(result.is_empty()); // no completed bar yet
1258        let bar = agg.current_bar().unwrap();
1259        assert_eq!(bar.open, dec!(50000));
1260        assert_eq!(bar.high, dec!(50000));
1261        assert_eq!(bar.low, dec!(50000));
1262        assert_eq!(bar.close, dec!(50000));
1263        assert_eq!(bar.volume, dec!(1));
1264        assert_eq!(bar.trade_count, 1);
1265    }
1266
1267    #[test]
1268    fn test_ohlcv_aggregator_high_low_tracking() {
1269        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1270        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1271            .unwrap();
1272        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1273            .unwrap();
1274        agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
1275            .unwrap();
1276        let bar = agg.current_bar().unwrap();
1277        assert_eq!(bar.high, dec!(51000));
1278        assert_eq!(bar.low, dec!(49500));
1279        assert_eq!(bar.close, dec!(49500));
1280        assert_eq!(bar.trade_count, 3);
1281    }
1282
1283    #[test]
1284    fn test_ohlcv_aggregator_bar_completes_on_new_window() {
1285        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1286        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1287            .unwrap();
1288        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2), 60_500))
1289            .unwrap();
1290        // Tick in next minute window closes previous bar
1291        let mut bars = agg
1292            .feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
1293            .unwrap();
1294        assert_eq!(bars.len(), 1);
1295        let bar = bars.remove(0);
1296        assert!(bar.is_complete);
1297        assert_eq!(bar.open, dec!(50000));
1298        assert_eq!(bar.close, dec!(50100));
1299        assert_eq!(bar.volume, dec!(3));
1300        assert_eq!(bar.bar_start_ms, 60_000);
1301    }
1302
1303    #[test]
1304    fn test_ohlcv_aggregator_new_bar_started_after_completion() {
1305        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1306        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1307            .unwrap();
1308        agg.feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
1309            .unwrap();
1310        let bar = agg.current_bar().unwrap();
1311        assert_eq!(bar.open, dec!(50200));
1312        assert_eq!(bar.bar_start_ms, 120_000);
1313    }
1314
1315    #[test]
1316    fn test_ohlcv_aggregator_flush_marks_complete() {
1317        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1318        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1319            .unwrap();
1320        let flushed = agg.flush().unwrap();
1321        assert!(flushed.is_complete);
1322        assert!(agg.current_bar().is_none());
1323    }
1324
1325    #[test]
1326    fn test_ohlcv_aggregator_flush_empty_returns_none() {
1327        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1328        assert!(agg.flush().is_none());
1329    }
1330
1331    #[test]
1332    fn test_ohlcv_aggregator_wrong_symbol_returns_error() {
1333        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1334        let tick = make_tick("ETH-USD", dec!(3000), dec!(1), 60_000);
1335        let result = agg.feed(&tick);
1336        assert!(matches!(result, Err(StreamError::AggregationError { .. })));
1337    }
1338
1339    #[test]
1340    fn test_ohlcv_aggregator_volume_accumulates() {
1341        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1342        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1.5), 60_000))
1343            .unwrap();
1344        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2.5), 60_100))
1345            .unwrap();
1346        let bar = agg.current_bar().unwrap();
1347        assert_eq!(bar.volume, dec!(4));
1348    }
1349
1350    #[test]
1351    fn test_ohlcv_bar_symbol_and_timeframe() {
1352        let mut agg = agg("BTC-USD", Timeframe::Minutes(5));
1353        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 300_000))
1354            .unwrap();
1355        let bar = agg.current_bar().unwrap();
1356        assert_eq!(bar.symbol, "BTC-USD");
1357        assert_eq!(bar.timeframe, Timeframe::Minutes(5));
1358    }
1359
1360    #[test]
1361    fn test_ohlcv_aggregator_symbol_accessor() {
1362        let agg = agg("ETH-USD", Timeframe::Hours(1));
1363        assert_eq!(agg.symbol(), "ETH-USD");
1364        assert_eq!(agg.timeframe(), Timeframe::Hours(1));
1365    }
1366
1367    #[test]
1368    fn test_bar_aligned_by_exchange_ts_not_received_ts() {
1369        // exchange_ts_ms puts tick in minute 1 (60_000..120_000)
1370        // received_at_ms puts tick in minute 2 (120_000..180_000) due to latency
1371        // Bar should use the exchange timestamp.
1372        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1373        let tick = make_tick_with_exchange_ts("BTC-USD", dec!(50000), dec!(1), 60_500, 120_100);
1374        agg.feed(&tick).unwrap();
1375        let bar = agg.current_bar().unwrap();
1376        assert_eq!(bar.bar_start_ms, 60_000, "bar should use exchange_ts_ms");
1377    }
1378
1379    #[test]
1380    fn test_bar_falls_back_to_received_ts_when_no_exchange_ts() {
1381        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1382        let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 75_000);
1383        agg.feed(&tick).unwrap();
1384        let bar = agg.current_bar().unwrap();
1385        assert_eq!(bar.bar_start_ms, 60_000);
1386    }
1387
1388    // --- emit_empty_bars tests ---
1389
1390    #[test]
1391    fn test_emit_empty_bars_no_gap_no_empties() {
1392        // Consecutive bars — no gap — should not produce empty bars.
1393        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1394            .unwrap()
1395            .with_emit_empty_bars(true);
1396        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1397            .unwrap();
1398        let bars = agg
1399            .feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
1400            .unwrap();
1401        // Only the completed bar for the first minute; no empties.
1402        assert_eq!(bars.len(), 1);
1403        assert_eq!(bars[0].bar_start_ms, 60_000);
1404        assert_eq!(bars[0].volume, dec!(1));
1405    }
1406
1407    #[test]
1408    fn test_emit_empty_bars_two_skipped_windows() {
1409        // Gap of 3 minutes: complete bar at 60s, then two empty bars at 120s and 180s,
1410        // then the 240s tick starts a new bar.
1411        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1412            .unwrap()
1413            .with_emit_empty_bars(true);
1414        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1415            .unwrap();
1416        let bars = agg
1417            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1418            .unwrap();
1419        // 1 real completed bar + 2 empty gap bars (120_000, 180_000)
1420        assert_eq!(bars.len(), 3);
1421        assert_eq!(bars[0].bar_start_ms, 60_000);
1422        assert!(!bars[0].volume.is_zero()); // real bar
1423        assert_eq!(bars[1].bar_start_ms, 120_000);
1424        assert!(bars[1].volume.is_zero()); // empty
1425        assert_eq!(bars[1].trade_count, 0);
1426        assert_eq!(bars[1].open, dec!(50000)); // last close carried forward
1427        assert_eq!(bars[2].bar_start_ms, 180_000);
1428        assert!(bars[2].volume.is_zero()); // empty
1429    }
1430
1431    #[test]
1432    fn test_emit_empty_bars_disabled_no_empties_on_gap() {
1433        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1434            .unwrap()
1435            .with_emit_empty_bars(false);
1436        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1437            .unwrap();
1438        let bars = agg
1439            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1440            .unwrap();
1441        assert_eq!(bars.len(), 1); // only real completed bar, no empties
1442    }
1443
1444    #[test]
1445    fn test_emit_empty_bars_is_complete_true() {
1446        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1447            .unwrap()
1448            .with_emit_empty_bars(true);
1449        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1450            .unwrap();
1451        let bars = agg
1452            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1453            .unwrap();
1454        for bar in &bars {
1455            assert!(bar.is_complete, "all emitted bars must be marked complete");
1456        }
1457    }
1458
1459    #[test]
1460    fn test_ohlcv_bar_display() {
1461        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1462        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1463            .unwrap();
1464        let bar = agg.current_bar().unwrap();
1465        let s = bar.to_string();
1466        assert!(s.contains("BTC-USD"));
1467        assert!(s.contains("1m"));
1468        assert!(s.contains("50000"));
1469    }
1470
1471    #[test]
1472    fn test_bar_count_increments_on_feed() {
1473        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1474        assert_eq!(agg.bar_count(), 0);
1475        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1476            .unwrap();
1477        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
1478            .unwrap();
1479        assert_eq!(agg.bar_count(), 1);
1480    }
1481
1482    #[test]
1483    fn test_bar_count_increments_on_flush() {
1484        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1485        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1486            .unwrap();
1487        agg.flush().unwrap();
1488        assert_eq!(agg.bar_count(), 1);
1489    }
1490
1491    #[test]
1492    fn test_ohlcv_bar_range() {
1493        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1494        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1495            .unwrap();
1496        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1497            .unwrap();
1498        agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
1499            .unwrap();
1500        let bar = agg.current_bar().unwrap();
1501        assert_eq!(bar.range(), dec!(1500)); // 51000 - 49500
1502    }
1503
1504    #[test]
1505    fn test_ohlcv_bar_body_bullish() {
1506        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1507        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1508            .unwrap();
1509        agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_100))
1510            .unwrap();
1511        let bar = agg.current_bar().unwrap();
1512        // open=50000, close=50500 → body = 500
1513        assert_eq!(bar.body(), dec!(500));
1514    }
1515
1516    #[test]
1517    fn test_ohlcv_bar_body_bearish() {
1518        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1519        agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_000))
1520            .unwrap();
1521        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
1522            .unwrap();
1523        let bar = agg.current_bar().unwrap();
1524        // open=50500, close=50000 → body = 500 (abs)
1525        assert_eq!(bar.body(), dec!(500));
1526    }
1527
1528    #[test]
1529    fn test_aggregator_reset_clears_bar_and_count() {
1530        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1531        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1532            .unwrap();
1533        agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
1534            .unwrap();
1535        assert_eq!(agg.bar_count(), 1);
1536        assert!(agg.current_bar().is_some());
1537        agg.reset();
1538        assert_eq!(agg.bar_count(), 0);
1539        assert!(agg.current_bar().is_none());
1540    }
1541
1542    #[test]
1543    fn test_ohlcv_bar_is_bullish_when_close_gt_open() {
1544        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1545        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1546            .unwrap();
1547        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1548            .unwrap();
1549        let bar = agg.current_bar().unwrap();
1550        assert!(bar.is_bullish());
1551        assert!(!bar.is_bearish());
1552    }
1553
1554    #[test]
1555    fn test_ohlcv_bar_is_bearish_when_close_lt_open() {
1556        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1557        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_000))
1558            .unwrap();
1559        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
1560            .unwrap();
1561        let bar = agg.current_bar().unwrap();
1562        assert!(bar.is_bearish());
1563        assert!(!bar.is_bullish());
1564    }
1565
1566    #[test]
1567    fn test_ohlcv_bar_neither_bullish_nor_bearish_on_equal_open_close() {
1568        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1569        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1570            .unwrap();
1571        // Single tick: open == close
1572        let bar = agg.current_bar().unwrap();
1573        assert!(!bar.is_bullish());
1574        assert!(!bar.is_bearish());
1575    }
1576
1577    #[test]
1578    fn test_ohlcv_bar_vwap_single_tick_equals_price() {
1579        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1580        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(2), 60_000))
1581            .unwrap();
1582        let bar = agg.current_bar().unwrap();
1583        assert_eq!(bar.vwap, Some(dec!(50000)));
1584    }
1585
1586    #[test]
1587    fn test_ohlcv_bar_vwap_two_equal_price_ticks() {
1588        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1589        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1590            .unwrap();
1591        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(3), 60_100))
1592            .unwrap();
1593        let bar = agg.current_bar().unwrap();
1594        // vwap = (50000*1 + 50000*3) / (1+3) = 50000
1595        assert_eq!(bar.vwap, Some(dec!(50000)));
1596    }
1597
1598    #[test]
1599    fn test_ohlcv_bar_vwap_two_different_price_ticks() {
1600        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1601        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1602            .unwrap();
1603        agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
1604            .unwrap();
1605        let bar = agg.current_bar().unwrap();
1606        // vwap = (50000*1 + 51000*1) / (1+1) = 50500
1607        assert_eq!(bar.vwap, Some(dec!(50500)));
1608    }
1609
1610    #[test]
1611    fn test_ohlcv_bar_vwap_gap_fill_is_none() {
1612        let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
1613            .unwrap()
1614            .with_emit_empty_bars(true);
1615        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1616            .unwrap();
1617        let bars = agg
1618            .feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
1619            .unwrap();
1620        // bars[0] = real, bars[1] and bars[2] = gap-fills
1621        assert!(bars[0].vwap.is_some());
1622        assert!(bars[1].vwap.is_none());
1623        assert!(bars[2].vwap.is_none());
1624    }
1625
1626    #[test]
1627    fn test_aggregator_reset_allows_fresh_start() {
1628        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1629        agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
1630            .unwrap();
1631        agg.reset();
1632        agg.feed(&make_tick("BTC-USD", dec!(99999), dec!(2), 60_000))
1633            .unwrap();
1634        let bar = agg.current_bar().unwrap();
1635        assert_eq!(bar.open, dec!(99999));
1636    }
1637
1638    // ── Timeframe::from_duration_ms ───────────────────────────────────────────
1639
1640    #[test]
1641    fn test_from_duration_ms_hours() {
1642        assert_eq!(Timeframe::from_duration_ms(3_600_000), Some(Timeframe::Hours(1)));
1643        assert_eq!(Timeframe::from_duration_ms(7_200_000), Some(Timeframe::Hours(2)));
1644    }
1645
1646    #[test]
1647    fn test_from_duration_ms_minutes() {
1648        assert_eq!(Timeframe::from_duration_ms(300_000), Some(Timeframe::Minutes(5)));
1649        assert_eq!(Timeframe::from_duration_ms(60_000), Some(Timeframe::Minutes(1)));
1650    }
1651
1652    #[test]
1653    fn test_from_duration_ms_seconds() {
1654        assert_eq!(Timeframe::from_duration_ms(15_000), Some(Timeframe::Seconds(15)));
1655        assert_eq!(Timeframe::from_duration_ms(1_000), Some(Timeframe::Seconds(1)));
1656    }
1657
1658    #[test]
1659    fn test_from_duration_ms_zero_returns_none() {
1660        assert_eq!(Timeframe::from_duration_ms(0), None);
1661    }
1662
1663    #[test]
1664    fn test_from_duration_ms_non_whole_second_returns_none() {
1665        assert_eq!(Timeframe::from_duration_ms(1_500), None);
1666    }
1667
1668    #[test]
1669    fn test_from_duration_ms_roundtrip() {
1670        for tf in [Timeframe::Seconds(30), Timeframe::Minutes(5), Timeframe::Hours(4)] {
1671            assert_eq!(Timeframe::from_duration_ms(tf.duration_ms()), Some(tf));
1672        }
1673    }
1674
1675    // ── OhlcvBar::is_doji / wick_upper / wick_lower ──────────────────────────
1676
1677    #[test]
1678    fn test_is_doji_exact_zero_body() {
1679        let bar = OhlcvBar {
1680            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1681            bar_start_ms: 0, open: dec!(100), high: dec!(105),
1682            low: dec!(95), close: dec!(100),
1683            volume: dec!(1), trade_count: 1, is_complete: true,
1684            is_gap_fill: false, vwap: None,
1685        };
1686        assert!(bar.is_doji(Decimal::ZERO));
1687    }
1688
1689    #[test]
1690    fn test_is_doji_small_epsilon() {
1691        let bar = OhlcvBar {
1692            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1693            bar_start_ms: 0, open: dec!(100), high: dec!(105),
1694            low: dec!(95), close: dec!(100.005),
1695            volume: dec!(1), trade_count: 1, is_complete: true,
1696            is_gap_fill: false, vwap: None,
1697        };
1698        assert!(bar.is_doji(dec!(0.01)));
1699        assert!(!bar.is_doji(Decimal::ZERO));
1700    }
1701
1702    #[test]
1703    fn test_wick_upper_bullish() {
1704        // open=100, close=104, high=107 → upper wick = 107 - 104 = 3
1705        let bar = OhlcvBar {
1706            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1707            bar_start_ms: 0, open: dec!(100), high: dec!(107),
1708            low: dec!(98), close: dec!(104),
1709            volume: dec!(1), trade_count: 1, is_complete: true,
1710            is_gap_fill: false, vwap: None,
1711        };
1712        assert_eq!(bar.wick_upper(), dec!(3));
1713    }
1714
1715    #[test]
1716    fn test_wick_lower_bearish() {
1717        // open=104, close=100, low=97 → lower wick = 100 - 97 = 3
1718        let bar = OhlcvBar {
1719            symbol: "X".into(), timeframe: Timeframe::Minutes(1),
1720            bar_start_ms: 0, open: dec!(104), high: dec!(107),
1721            low: dec!(97), close: dec!(100),
1722            volume: dec!(1), trade_count: 1, is_complete: true,
1723            is_gap_fill: false, vwap: None,
1724        };
1725        assert_eq!(bar.wick_lower(), dec!(3));
1726    }
1727
1728    // ── OhlcvAggregator::window_progress ─────────────────────────────────────
1729
1730    #[test]
1731    fn test_window_progress_none_when_no_bar() {
1732        let agg = agg("BTC-USD", Timeframe::Minutes(1));
1733        assert!(agg.window_progress(60_000).is_none());
1734    }
1735
1736    #[test]
1737    fn test_window_progress_at_start_is_zero() {
1738        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1739        // Tick at bar start.
1740        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1741        assert_eq!(agg.window_progress(60_000), Some(0.0));
1742    }
1743
1744    #[test]
1745    fn test_window_progress_midpoint() {
1746        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1747        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1748        // 30 s into a 60 s bar → 0.5
1749        let progress = agg.window_progress(90_000).unwrap();
1750        assert!((progress - 0.5).abs() < 1e-9);
1751    }
1752
1753    #[test]
1754    fn test_window_progress_clamps_at_one() {
1755        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1756        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1757        // 90 s past the bar start (longer than the bar) → clamped to 1.0
1758        assert_eq!(agg.window_progress(150_000), Some(1.0));
1759    }
1760
1761    // ── OhlcvBar::price_change ────────────────────────────────────────────────
1762
1763    #[test]
1764    fn test_price_change_bullish_is_positive() {
1765        let bar = make_bar(dec!(100), dec!(110), dec!(98), dec!(105));
1766        assert_eq!(bar.price_change(), dec!(5));
1767    }
1768
1769    #[test]
1770    fn test_price_change_bearish_is_negative() {
1771        let bar = make_bar(dec!(105), dec!(110), dec!(98), dec!(100));
1772        assert_eq!(bar.price_change(), dec!(-5));
1773    }
1774
1775    #[test]
1776    fn test_price_change_doji_is_zero() {
1777        let bar = make_bar(dec!(100), dec!(102), dec!(98), dec!(100));
1778        assert_eq!(bar.price_change(), dec!(0));
1779    }
1780
1781    // ── OhlcvAggregator::total_volume ─────────────────────────────────────────
1782
1783    #[test]
1784    fn test_total_volume_zero_before_completion() {
1785        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1786        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
1787        // Bar not yet complete; total_volume should be zero
1788        assert_eq!(agg.total_volume(), dec!(0));
1789    }
1790
1791    #[test]
1792    fn test_total_volume_accumulates_across_bars() {
1793        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1794        // Bar 1: volume = 2
1795        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
1796        // Trigger completion of bar 1
1797        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
1798        // Bar 1 completed with volume 2. Bar 2 in progress with volume 3 (not counted).
1799        assert_eq!(agg.total_volume(), dec!(2));
1800        // Trigger completion of bar 2
1801        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
1802        assert_eq!(agg.total_volume(), dec!(5)); // 2 + 3
1803    }
1804
1805    #[test]
1806    fn test_total_volume_reset_clears() {
1807        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1808        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
1809        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
1810        agg.reset();
1811        assert_eq!(agg.total_volume(), dec!(0));
1812    }
1813
1814    // ── OhlcvBar::typical_price / median_price ────────────────────────────────
1815
1816    fn make_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
1817        OhlcvBar {
1818            symbol: "X".into(),
1819            timeframe: Timeframe::Minutes(1),
1820            bar_start_ms: 0,
1821            open,
1822            high,
1823            low,
1824            close,
1825            volume: dec!(1),
1826            trade_count: 1,
1827            is_complete: true,
1828            is_gap_fill: false,
1829            vwap: None,
1830        }
1831    }
1832
1833    #[test]
1834    fn test_typical_price() {
1835        // high=12, low=8, close=10 → (12+8+10)/3 = 10
1836        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
1837        assert_eq!(bar.typical_price(), dec!(10));
1838    }
1839
1840    #[test]
1841    fn test_median_price() {
1842        // high=12, low=8 → (12+8)/2 = 10
1843        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
1844        assert_eq!(bar.median_price(), dec!(10));
1845    }
1846
1847    #[test]
1848    fn test_typical_price_differs_from_median() {
1849        // high=10, low=6, close=10 → typical=(10+6+10)/3 = 26/3, median=(10+6)/2 = 8
1850        let bar = make_bar(dec!(8), dec!(10), dec!(6), dec!(10));
1851        assert_eq!(bar.median_price(), dec!(8));
1852        assert!(bar.typical_price() > bar.median_price());
1853    }
1854
1855    #[test]
1856    fn test_close_location_value_at_high() {
1857        // close == high → CLV = (high - low - 0) / range = 1.0
1858        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(110));
1859        let clv = bar.close_location_value().unwrap();
1860        assert!((clv - 1.0).abs() < 1e-9, "expected 1.0 got {clv}");
1861    }
1862
1863    #[test]
1864    fn test_close_location_value_at_low() {
1865        // close == low → CLV = (low - low - (high - low)) / range = -range/range = -1.0
1866        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(90));
1867        let clv = bar.close_location_value().unwrap();
1868        assert!((clv + 1.0).abs() < 1e-9, "expected -1.0 got {clv}");
1869    }
1870
1871    #[test]
1872    fn test_close_location_value_midpoint_is_zero() {
1873        // close == (high + low) / 2 → CLV = 0.0
1874        let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(100));
1875        let clv = bar.close_location_value().unwrap();
1876        assert!(clv.abs() < 1e-9, "expected 0.0 got {clv}");
1877    }
1878
1879    #[test]
1880    fn test_close_location_value_zero_range_returns_none() {
1881        let bar = make_bar(dec!(100), dec!(100), dec!(100), dec!(100));
1882        assert!(bar.close_location_value().is_none());
1883    }
1884
1885    #[test]
1886    fn test_body_direction_bullish() {
1887        let bar = make_bar(dec!(90), dec!(110), dec!(85), dec!(105));
1888        assert_eq!(bar.body_direction(), BarDirection::Bullish);
1889    }
1890
1891    #[test]
1892    fn test_body_direction_bearish() {
1893        let bar = make_bar(dec!(105), dec!(110), dec!(85), dec!(90));
1894        assert_eq!(bar.body_direction(), BarDirection::Bearish);
1895    }
1896
1897    #[test]
1898    fn test_body_direction_neutral() {
1899        let bar = make_bar(dec!(100), dec!(110), dec!(85), dec!(100));
1900        assert_eq!(bar.body_direction(), BarDirection::Neutral);
1901    }
1902
1903    // ── OhlcvAggregator::last_bar ─────────────────────────────────────────────
1904
1905    #[test]
1906    fn test_last_bar_none_before_completion() {
1907        let agg = agg("BTC-USD", Timeframe::Minutes(1));
1908        assert!(agg.last_bar().is_none());
1909    }
1910
1911    #[test]
1912    fn test_last_bar_set_after_bar_completion() {
1913        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1914        // First bar in window [60000, 120000)
1915        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1916        // Second tick in next window completes the first bar
1917        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
1918        let last = agg.last_bar().unwrap();
1919        assert!(last.is_complete);
1920        assert_eq!(last.close, dec!(100));
1921    }
1922
1923    #[test]
1924    fn test_last_bar_set_after_flush() {
1925        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1926        agg.feed(&make_tick("BTC-USD", dec!(50), dec!(1), 60_000)).unwrap();
1927        let flushed = agg.flush().unwrap();
1928        assert_eq!(agg.last_bar().unwrap().close, flushed.close);
1929    }
1930
1931    #[test]
1932    fn test_last_bar_cleared_on_reset() {
1933        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
1934        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
1935        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
1936        assert!(agg.last_bar().is_some());
1937        agg.reset();
1938        assert!(agg.last_bar().is_none());
1939    }
1940
1941    // ── OhlcvBar::weighted_close / price_change_pct / wick_ratio ─────────────
1942
1943    #[test]
1944    fn test_weighted_close_basic() {
1945        // (high + low + close*2) / 4 = (12 + 8 + 10*2) / 4 = 40/4 = 10
1946        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
1947        assert_eq!(bar.weighted_close(), dec!(10));
1948    }
1949
1950    #[test]
1951    fn test_weighted_close_weights_close_more_than_typical() {
1952        // high=100, low=0, close=80 → typical=(100+0+80)/3≈60, weighted=(100+0+80+80)/4=65
1953        let bar = make_bar(dec!(50), dec!(100), dec!(0), dec!(80));
1954        assert_eq!(bar.weighted_close(), dec!(65));
1955    }
1956
1957    #[test]
1958    fn test_price_change_pct_bullish() {
1959        // open=100, close=110 → +10%
1960        let bar = make_bar(dec!(100), dec!(115), dec!(95), dec!(110));
1961        let pct = bar.price_change_pct().unwrap();
1962        assert!((pct - 10.0).abs() < 1e-9, "expected 10.0 got {pct}");
1963    }
1964
1965    #[test]
1966    fn test_price_change_pct_bearish() {
1967        // open=200, close=180 → -10%
1968        let bar = make_bar(dec!(200), dec!(210), dec!(175), dec!(180));
1969        let pct = bar.price_change_pct().unwrap();
1970        assert!((pct - (-10.0)).abs() < 1e-9, "expected -10.0 got {pct}");
1971    }
1972
1973    #[test]
1974    fn test_price_change_pct_zero_open_returns_none() {
1975        let bar = make_bar(dec!(0), dec!(5), dec!(0), dec!(3));
1976        assert!(bar.price_change_pct().is_none());
1977    }
1978
1979    #[test]
1980    fn test_wick_ratio_all_wicks() {
1981        // open=close=5, high=10, low=0 → body=0, wicks=5+5=10, range=10 → ratio=1.0
1982        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
1983        let r = bar.wick_ratio().unwrap();
1984        assert!((r - 1.0).abs() < 1e-9, "expected 1.0 got {r}");
1985    }
1986
1987    #[test]
1988    fn test_wick_ratio_no_wicks() {
1989        // open=low=0, close=high=10 → body=10, wicks=0, range=10 → ratio=0.0
1990        let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
1991        let r = bar.wick_ratio().unwrap();
1992        assert!((r - 0.0).abs() < 1e-9, "expected 0.0 got {r}");
1993    }
1994
1995    #[test]
1996    fn test_wick_ratio_zero_range_returns_none() {
1997        // all prices identical → range=0
1998        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
1999        assert!(bar.wick_ratio().is_none());
2000    }
2001
2002    // ── OhlcvBar::body_ratio ──────────────────────────────────────────────────
2003
2004    #[test]
2005    fn test_body_ratio_no_wicks_is_one() {
2006        // open=low=0, close=high=10 → body=10, range=10 → ratio=1.0
2007        let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
2008        let r = bar.body_ratio().unwrap();
2009        assert!((r - 1.0).abs() < 1e-9);
2010    }
2011
2012    #[test]
2013    fn test_body_ratio_all_wicks_is_zero() {
2014        // doji: open=close=5, high=10, low=0 → body=0, range=10 → ratio=0.0
2015        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
2016        let r = bar.body_ratio().unwrap();
2017        assert!((r - 0.0).abs() < 1e-9);
2018    }
2019
2020    #[test]
2021    fn test_body_ratio_zero_range_returns_none() {
2022        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
2023        assert!(bar.body_ratio().is_none());
2024    }
2025
2026    #[test]
2027    fn test_body_ratio_plus_wick_ratio_equals_one() {
2028        // body + wicks = range → ratios sum to 1
2029        let bar = make_bar(dec!(4), dec!(10), dec!(0), dec!(8));
2030        let body = bar.body_ratio().unwrap();
2031        let wick = bar.wick_ratio().unwrap();
2032        assert!((body + wick - 1.0).abs() < 1e-9);
2033    }
2034
2035    // ── OhlcvAggregator::average_volume ──────────────────────────────────────
2036
2037    #[test]
2038    fn test_average_volume_none_before_bars() {
2039        let agg = agg("BTC-USD", Timeframe::Minutes(1));
2040        assert!(agg.average_volume().is_none());
2041    }
2042
2043    #[test]
2044    fn test_average_volume_one_bar() {
2045        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2046        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
2047        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2048        // bar 1 complete with volume 4; bar 2 in progress, not counted
2049        assert_eq!(agg.average_volume(), Some(dec!(4)));
2050    }
2051
2052    #[test]
2053    fn test_average_volume_two_bars() {
2054        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2055        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
2056        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(6), 120_000)).unwrap();
2057        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
2058        // bar 1 vol=4, bar 2 vol=6 → avg=5
2059        assert_eq!(agg.average_volume(), Some(dec!(5)));
2060    }
2061
2062    // ── OhlcvBar::true_range / inside_bar / outside_bar ──────────────────────
2063
2064    #[test]
2065    fn test_true_range_no_gap() {
2066        // high=12, low=8, prev_close=10 → HL=4, H-prev=2, L-prev=2 → TR=4
2067        let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
2068        assert_eq!(bar.true_range(dec!(10)), dec!(4));
2069    }
2070
2071    #[test]
2072    fn test_true_range_gap_up() {
2073        // high=15, low=12, prev_close=10 → HL=3, H-prev=5, L-prev=2 → TR=5
2074        let bar = make_bar(dec!(12), dec!(15), dec!(12), dec!(13));
2075        assert_eq!(bar.true_range(dec!(10)), dec!(5));
2076    }
2077
2078    #[test]
2079    fn test_true_range_gap_down() {
2080        // high=8, low=5, prev_close=12 → HL=3, H-prev=4, L-prev=7 → TR=7
2081        let bar = make_bar(dec!(7), dec!(8), dec!(5), dec!(6));
2082        assert_eq!(bar.true_range(dec!(12)), dec!(7));
2083    }
2084
2085    #[test]
2086    fn test_inside_bar_true_when_contained() {
2087        let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
2088        let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
2089        assert!(curr.inside_bar(&prev));
2090    }
2091
2092    #[test]
2093    fn test_inside_bar_false_when_not_contained() {
2094        let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
2095        let curr = make_bar(dec!(10), dec!(16), dec!(6), dec!(11));
2096        assert!(!curr.inside_bar(&prev));
2097    }
2098
2099    #[test]
2100    fn test_outside_bar_true_when_engulfing() {
2101        let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
2102        let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
2103        assert!(curr.outside_bar(&prev));
2104    }
2105
2106    #[test]
2107    fn test_outside_bar_false_when_not_engulfing() {
2108        let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
2109        let curr = make_bar(dec!(10), dec!(11), dec!(9), dec!(10));
2110        assert!(!curr.outside_bar(&prev));
2111    }
2112
2113    // ── OhlcvBar::is_hammer ───────────────────────────────────────────────────
2114
2115    #[test]
2116    fn test_is_hammer_classic() {
2117        // open=9, high=10, low=0, close=9 → body=0, wick_lo=9, wick_hi=1, range=10
2118        // body=0 ≤ 30%, wick_lo=9 ≥ 60%, wick_hi=1 ≤ 10% → hammer
2119        let bar = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
2120        assert!(bar.is_hammer());
2121    }
2122
2123    #[test]
2124    fn test_is_hammer_false_large_upper_wick() {
2125        // open=5, high=10, low=0, close=5 → body=0, wick_hi=5 (50%) → not hammer
2126        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
2127        assert!(!bar.is_hammer());
2128    }
2129
2130    #[test]
2131    fn test_is_hammer_false_zero_range() {
2132        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
2133        assert!(!bar.is_hammer());
2134    }
2135
2136    // ── OhlcvAggregator::peak_volume ─────────────────────────────────────────
2137
2138    #[test]
2139    fn test_peak_volume_none_before_completion() {
2140        let agg = agg("BTC-USD", Timeframe::Minutes(1));
2141        assert!(agg.peak_volume().is_none());
2142    }
2143
2144    #[test]
2145    fn test_peak_volume_tracks_maximum() {
2146        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2147        // Bar 1: vol=3
2148        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
2149        // Trigger bar 1 completion; bar 2 vol=10 in progress
2150        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
2151        assert_eq!(agg.peak_volume(), Some(dec!(3)));
2152        // Trigger bar 2 completion
2153        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
2154        assert_eq!(agg.peak_volume(), Some(dec!(10)));
2155    }
2156
2157    #[test]
2158    fn test_peak_volume_reset_clears() {
2159        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2160        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
2161        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2162        agg.reset();
2163        assert!(agg.peak_volume().is_none());
2164    }
2165
2166    #[test]
2167    fn test_peak_volume_via_flush() {
2168        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2169        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(7), 60_000)).unwrap();
2170        agg.flush();
2171        assert_eq!(agg.peak_volume(), Some(dec!(7)));
2172    }
2173
2174    // ── OhlcvBar::is_shooting_star ────────────────────────────────────────────
2175
2176    #[test]
2177    fn test_is_shooting_star_classic() {
2178        // open=1, high=10, low=0, close=1 → body=0, wick_hi=9, wick_lo=1, range=10
2179        // body≤30%, wick_hi=9≥60%, wick_lo=1≤10% → shooting star
2180        let bar = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
2181        assert!(bar.is_shooting_star());
2182    }
2183
2184    #[test]
2185    fn test_is_shooting_star_false_large_lower_wick() {
2186        // open=5, high=10, low=0, close=5 → lower wick = 5 (50%) → not shooting star
2187        let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
2188        assert!(!bar.is_shooting_star());
2189    }
2190
2191    #[test]
2192    fn test_is_shooting_star_false_zero_range() {
2193        let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
2194        assert!(!bar.is_shooting_star());
2195    }
2196
2197    #[test]
2198    fn test_hammer_and_shooting_star_are_mutually_exclusive_for_typical_bars() {
2199        // Classic hammer: long lower wick
2200        let hammer = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
2201        // Classic shooting star: long upper wick
2202        let star = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
2203        assert!(hammer.is_hammer() && !hammer.is_shooting_star());
2204        assert!(star.is_shooting_star() && !star.is_hammer());
2205    }
2206
2207    // ── OhlcvAggregator::min_volume ───────────────────────────────────────────
2208
2209    #[test]
2210    fn test_min_volume_none_before_completion() {
2211        let agg = agg("BTC-USD", Timeframe::Minutes(1));
2212        assert!(agg.min_volume().is_none());
2213    }
2214
2215    #[test]
2216    fn test_min_volume_tracks_minimum() {
2217        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2218        // Bar 1: vol=10
2219        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(10), 60_000)).unwrap();
2220        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2221        assert_eq!(agg.min_volume(), Some(dec!(10)));
2222        // Bar 2: vol=1 — should update minimum
2223        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
2224        assert_eq!(agg.min_volume(), Some(dec!(1)));
2225    }
2226
2227    #[test]
2228    fn test_min_volume_reset_clears() {
2229        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2230        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
2231        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
2232        agg.reset();
2233        assert!(agg.min_volume().is_none());
2234    }
2235
2236    // ── OhlcvBar::is_gap_up / is_gap_down ────────────────────────────────────
2237
2238    #[test]
2239    fn test_is_gap_up_true() {
2240        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2241        let curr = make_bar(dec!(9), dec!(12), dec!(8), dec!(11)); // open=9 > prev.close=8
2242        assert!(curr.is_gap_up(&prev));
2243    }
2244
2245    #[test]
2246    fn test_is_gap_up_false_when_equal() {
2247        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2248        let curr = make_bar(dec!(8), dec!(12), dec!(7), dec!(11)); // open=8 == prev.close=8
2249        assert!(!curr.is_gap_up(&prev));
2250    }
2251
2252    #[test]
2253    fn test_is_gap_down_true() {
2254        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2255        let curr = make_bar(dec!(7), dec!(8), dec!(6), dec!(7)); // open=7 < prev.close=8
2256        assert!(curr.is_gap_down(&prev));
2257    }
2258
2259    #[test]
2260    fn test_is_gap_down_false_when_equal() {
2261        let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
2262        let curr = make_bar(dec!(8), dec!(9), dec!(7), dec!(8)); // open=8 == prev.close=8
2263        assert!(!curr.is_gap_down(&prev));
2264    }
2265
2266    // ── OhlcvAggregator::volume_range ─────────────────────────────────────────
2267
2268    #[test]
2269    fn test_volume_range_none_before_completion() {
2270        let agg = agg("BTC-USD", Timeframe::Minutes(1));
2271        assert!(agg.volume_range().is_none());
2272    }
2273
2274    #[test]
2275    fn test_volume_range_after_two_bars() {
2276        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2277        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
2278        agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
2279        agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
2280        // bar1=3, bar2=10 → min=3, peak=10
2281        assert_eq!(agg.volume_range(), Some((dec!(3), dec!(10))));
2282    }
2283
2284    // ── OhlcvBar::body_to_range_ratio ─────────────────────────────────────────
2285
2286    fn make_ohlcv_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
2287        OhlcvBar {
2288            symbol: "X".into(),
2289            timeframe: Timeframe::Minutes(1),
2290            open,
2291            high,
2292            low,
2293            close,
2294            volume: dec!(1),
2295            bar_start_ms: 0,
2296            trade_count: 1,
2297            is_complete: false,
2298            is_gap_fill: false,
2299            vwap: None,
2300        }
2301    }
2302
2303    #[test]
2304    fn test_body_to_range_ratio_bullish_full_body() {
2305        // open=100, close=110, high=110, low=100 → body=10, range=10 → ratio=1.0
2306        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2307        assert_eq!(bar.body_to_range_ratio(), Some(dec!(1)));
2308    }
2309
2310    #[test]
2311    fn test_body_to_range_ratio_doji_like() {
2312        // open=close → body=0, range>0 → ratio=0
2313        let bar = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
2314        assert_eq!(bar.body_to_range_ratio(), Some(dec!(0)));
2315    }
2316
2317    #[test]
2318    fn test_body_to_range_ratio_none_when_range_zero() {
2319        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
2320        assert!(bar.body_to_range_ratio().is_none());
2321    }
2322
2323    // ── OhlcvAggregator::is_active ────────────────────────────────────────────
2324
2325    #[test]
2326    fn test_is_active_false_before_any_ticks() {
2327        let agg = agg("BTC-USD", Timeframe::Minutes(1));
2328        assert!(!agg.is_active());
2329    }
2330
2331    #[test]
2332    fn test_is_active_true_after_first_tick() {
2333        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2334        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
2335        assert!(agg.is_active());
2336    }
2337
2338    #[test]
2339    fn test_is_active_false_after_flush() {
2340        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2341        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
2342        agg.flush();
2343        assert!(!agg.is_active());
2344    }
2345
2346    // ── OhlcvBar::is_long_upper_wick ──────────────────────────────────────────
2347
2348    #[test]
2349    fn test_is_long_upper_wick_true_when_upper_wick_dominates() {
2350        // open=100, close=101, high=110, low=100 → body=1, upper_wick=9
2351        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(101));
2352        assert!(bar.is_long_upper_wick());
2353    }
2354
2355    #[test]
2356    fn test_is_long_upper_wick_false_for_full_body() {
2357        // open=100, close=110, high=110, low=100 → body=10, upper_wick=0
2358        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2359        assert!(!bar.is_long_upper_wick());
2360    }
2361
2362    #[test]
2363    fn test_is_long_upper_wick_false_when_equal() {
2364        // open=100, close=105, high=110, low=100 → body=5, upper_wick=5
2365        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(105));
2366        assert!(!bar.is_long_upper_wick());
2367    }
2368
2369    // ── OhlcvBar::price_change_abs ────────────────────────────────────────────
2370
2371    #[test]
2372    fn test_price_change_abs_bullish_bar() {
2373        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
2374        assert_eq!(bar.price_change_abs(), dec!(8));
2375    }
2376
2377    #[test]
2378    fn test_price_change_abs_bearish_bar() {
2379        let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(102));
2380        assert_eq!(bar.price_change_abs(), dec!(8));
2381    }
2382
2383    #[test]
2384    fn test_price_change_abs_doji_zero() {
2385        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2386        assert_eq!(bar.price_change_abs(), dec!(0));
2387    }
2388
2389    // ── OhlcvAggregator::vwap_current ────────────────────────────────────────
2390
2391    #[test]
2392    fn test_vwap_current_none_before_any_ticks() {
2393        let agg = agg("BTC-USD", Timeframe::Minutes(1));
2394        assert!(agg.vwap_current().is_none());
2395    }
2396
2397    #[test]
2398    fn test_vwap_current_equals_price_for_single_tick() {
2399        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2400        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(5), 1_000)).unwrap();
2401        // vwap = price*qty / qty = 200
2402        assert_eq!(agg.vwap_current(), Some(dec!(200)));
2403    }
2404
2405    #[test]
2406    fn test_vwap_current_weighted_average() {
2407        let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
2408        agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
2409        agg.feed(&make_tick("BTC-USD", dec!(200), dec!(3), 2_000)).unwrap();
2410        // vwap = (100*1 + 200*3) / (1+3) = 700/4 = 175
2411        assert_eq!(agg.vwap_current(), Some(dec!(175)));
2412    }
2413
2414    // --- upper_shadow / lower_shadow / is_spinning_top / hlc3 ---
2415
2416    fn bar(o: i64, h: i64, l: i64, c: i64) -> OhlcvBar {
2417        OhlcvBar {
2418            symbol: "X".into(),
2419            timeframe: Timeframe::Minutes(1),
2420            open: Decimal::from(o),
2421            high: Decimal::from(h),
2422            low: Decimal::from(l),
2423            close: Decimal::from(c),
2424            volume: Decimal::ZERO,
2425            bar_start_ms: 0,
2426            trade_count: 0,
2427            is_complete: false,
2428            is_gap_fill: false,
2429            vwap: None,
2430        }
2431    }
2432
2433    #[test]
2434    fn test_upper_shadow_equals_wick_upper() {
2435        let b = bar(100, 120, 90, 110);
2436        assert_eq!(b.upper_shadow(), b.wick_upper());
2437        assert_eq!(b.upper_shadow(), Decimal::from(10)); // 120 - max(100,110)
2438    }
2439
2440    #[test]
2441    fn test_lower_shadow_equals_wick_lower() {
2442        let b = bar(100, 120, 90, 110);
2443        assert_eq!(b.lower_shadow(), b.wick_lower());
2444        assert_eq!(b.lower_shadow(), Decimal::from(10)); // min(100,110) - 90
2445    }
2446
2447    #[test]
2448    fn test_is_spinning_top_true_when_small_body_large_wicks() {
2449        // body = |110-100| = 10, range = 130-80 = 50
2450        // body_pct = 0.3 → max_body = 15; body(10) <= 15
2451        // wick_upper = 130 - 110 = 20 > 10 ✓
2452        // wick_lower = 100 - 80 = 20 > 10 ✓
2453        let b = bar(100, 130, 80, 110);
2454        assert!(b.is_spinning_top(dec!(0.3)));
2455    }
2456
2457    #[test]
2458    fn test_is_spinning_top_false_when_body_too_large() {
2459        // body = 40, range = 50; body_pct=0.3 → max_body=15; 40 > 15
2460        let b = bar(80, 130, 80, 120);
2461        assert!(!b.is_spinning_top(dec!(0.3)));
2462    }
2463
2464    #[test]
2465    fn test_is_spinning_top_false_when_zero_range() {
2466        let b = bar(100, 100, 100, 100);
2467        assert!(!b.is_spinning_top(dec!(0.3)));
2468    }
2469
2470    #[test]
2471    fn test_hlc3_equals_typical_price() {
2472        let b = bar(100, 120, 80, 110);
2473        assert_eq!(b.hlc3(), b.typical_price());
2474        // (120 + 80 + 110) / 3 = 310/3
2475        assert_eq!(b.hlc3(), (Decimal::from(120) + Decimal::from(80) + Decimal::from(110)) / Decimal::from(3));
2476    }
2477
2478    // ── OhlcvBar::is_bearish ──────────────────────────────────────────────────
2479
2480    #[test]
2481    fn test_is_bearish_true_when_close_below_open() {
2482        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
2483        assert!(bar.is_bearish());
2484    }
2485
2486    #[test]
2487    fn test_is_bearish_false_when_close_above_open() {
2488        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
2489        assert!(!bar.is_bearish());
2490    }
2491
2492    #[test]
2493    fn test_is_bearish_false_when_doji() {
2494        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2495        assert!(!bar.is_bearish());
2496    }
2497
2498    // ── OhlcvBar::wick_ratio ──────────────────────────────────────────────────
2499
2500    #[test]
2501    fn test_wick_ratio_zero_for_full_body_no_wicks() {
2502        // open=100, close=110, high=110, low=100 → no wicks → ratio=0
2503        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2504        let ratio = bar.wick_ratio().unwrap();
2505        assert!(ratio.abs() < 1e-10);
2506    }
2507
2508    #[test]
2509    fn test_wick_ratio_one_for_pure_wick_doji() {
2510        // open=close=105, high=110, low=100 → body=0, upper=5, lower=5, range=10 → ratio=1
2511        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
2512        let ratio = bar.wick_ratio().unwrap();
2513        assert!((ratio - 1.0).abs() < 1e-10);
2514    }
2515
2516    #[test]
2517    fn test_wick_ratio_none_for_zero_range_bar() {
2518        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
2519        assert!(bar.wick_ratio().is_none());
2520    }
2521
2522    // ── OhlcvBar::is_bullish ──────────────────────────────────────────────────
2523
2524    #[test]
2525    fn test_is_bullish_true_when_close_above_open() {
2526        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
2527        assert!(bar.is_bullish());
2528    }
2529
2530    #[test]
2531    fn test_is_bullish_false_when_close_below_open() {
2532        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
2533        assert!(!bar.is_bullish());
2534    }
2535
2536    #[test]
2537    fn test_is_bullish_false_when_doji() {
2538        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2539        assert!(!bar.is_bullish());
2540    }
2541
2542    // ── OhlcvBar::bar_duration_ms ─────────────────────────────────────────────
2543
2544    #[test]
2545    fn test_bar_duration_ms_one_minute() {
2546        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2547        assert_eq!(bar.bar_duration_ms(), 60_000);
2548    }
2549
2550    #[test]
2551    fn test_bar_duration_ms_consistent_with_timeframe() {
2552        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2553        bar.timeframe = Timeframe::Hours(1);
2554        assert_eq!(bar.bar_duration_ms(), 3_600_000);
2555    }
2556
2557    #[test]
2558    fn test_bar_duration_ms_seconds_timeframe() {
2559        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2560        bar.timeframe = Timeframe::Seconds(30);
2561        assert_eq!(bar.bar_duration_ms(), 30_000);
2562    }
2563
2564    // --- ohlc4 / is_marubozu / is_engulfing ---
2565
2566    #[test]
2567    fn test_ohlc4_equals_average_of_all_four_prices() {
2568        let b = bar(100, 120, 80, 110);
2569        // (100 + 120 + 80 + 110) / 4 = 410 / 4 = 102.5
2570        let expected = (Decimal::from(100) + Decimal::from(120) + Decimal::from(80) + Decimal::from(110))
2571            / Decimal::from(4);
2572        assert_eq!(b.ohlc4(), expected);
2573    }
2574
2575    #[test]
2576    fn test_is_marubozu_true_when_no_wicks() {
2577        // Bullish marubozu: open=low=100, close=high=110
2578        let b = bar(100, 110, 100, 110);
2579        assert!(b.is_marubozu());
2580    }
2581
2582    #[test]
2583    fn test_is_marubozu_false_when_has_upper_wick() {
2584        let b = bar(100, 115, 100, 110);
2585        assert!(!b.is_marubozu());
2586    }
2587
2588    #[test]
2589    fn test_is_marubozu_false_when_has_lower_wick() {
2590        let b = bar(100, 110, 95, 110);
2591        assert!(!b.is_marubozu());
2592    }
2593
2594    // --- is_harami / tail_length ---
2595
2596    #[test]
2597    fn test_is_harami_true_when_body_inside_prev_body() {
2598        let prev = bar(98, 115, 90, 108); // prev body: 98-108
2599        let curr = bar(100, 110, 95, 105); // curr body: 100-105 — inside 98-108
2600        assert!(curr.is_harami(&prev));
2601    }
2602
2603    #[test]
2604    fn test_is_harami_false_when_body_engulfs_prev() {
2605        let prev = bar(100, 110, 95, 105); // prev body: 100-105
2606        let curr = bar(98, 115, 90, 108);  // curr body: 98-108 — engulfs prev
2607        assert!(!curr.is_harami(&prev));
2608    }
2609
2610    #[test]
2611    fn test_is_harami_false_when_bodies_equal() {
2612        let prev = bar(100, 110, 90, 105);
2613        let curr = bar(100, 110, 90, 105); // equal bodies
2614        assert!(!curr.is_harami(&prev));
2615    }
2616
2617    #[test]
2618    fn test_tail_length_upper_wick_longer() {
2619        // open=100, high=120, low=95, close=105 → upper_wick=15, lower_wick=5
2620        let b = bar(100, 120, 95, 105);
2621        assert_eq!(b.tail_length(), Decimal::from(15));
2622    }
2623
2624    #[test]
2625    fn test_tail_length_lower_wick_longer() {
2626        // open=105, high=110, low=80, close=100 → upper_wick=5, lower_wick=20
2627        let b = bar(105, 110, 80, 100);
2628        assert_eq!(b.tail_length(), Decimal::from(20));
2629    }
2630
2631    #[test]
2632    fn test_tail_length_zero_for_marubozu() {
2633        // open=low=100, close=high=110 → both wicks zero
2634        let b = bar(100, 110, 100, 110);
2635        assert!(b.tail_length().is_zero());
2636    }
2637
2638    // --- is_inside_bar / bar_type ---
2639
2640    #[test]
2641    fn test_is_inside_bar_true_when_range_within_prev() {
2642        let prev = bar(90, 120, 80, 110); // prev range: 80-120
2643        let curr = bar(95, 115, 85, 100); // curr range: 85-115 — inside 80-120
2644        assert!(curr.is_inside_bar(&prev));
2645    }
2646
2647    #[test]
2648    fn test_is_inside_bar_false_when_high_exceeds_prev_high() {
2649        let prev = bar(90, 110, 80, 100); // prev high = 110
2650        let curr = bar(95, 112, 85, 100); // curr high = 112 > 110
2651        assert!(!curr.is_inside_bar(&prev));
2652    }
2653
2654    #[test]
2655    fn test_is_inside_bar_false_when_equal_range() {
2656        let prev = bar(90, 110, 80, 100);
2657        let curr = bar(90, 110, 80, 100); // same high/low — not strictly inside
2658        assert!(!curr.is_inside_bar(&prev));
2659    }
2660
2661    #[test]
2662    fn test_bar_type_bullish() {
2663        let b = bar(100, 110, 90, 105); // close > open
2664        assert_eq!(b.bar_type(), "bullish");
2665    }
2666
2667    #[test]
2668    fn test_bar_type_bearish() {
2669        let b = bar(105, 110, 90, 100); // close < open
2670        assert_eq!(b.bar_type(), "bearish");
2671    }
2672
2673    #[test]
2674    fn test_bar_type_doji() {
2675        let b = bar(100, 110, 90, 100); // close == open
2676        assert_eq!(b.bar_type(), "doji");
2677    }
2678
2679    // --- body_pct / is_bullish_hammer ---
2680
2681    #[test]
2682    fn test_body_pct_none_for_zero_range() {
2683        let b = bar(100, 100, 100, 100);
2684        assert!(b.body_pct().is_none());
2685    }
2686
2687    #[test]
2688    fn test_body_pct_100_for_marubozu() {
2689        // open=low=100, close=high=110 → body=10, range=10, pct=100
2690        let b = bar(100, 110, 100, 110);
2691        assert_eq!(b.body_pct().unwrap(), Decimal::ONE_HUNDRED);
2692    }
2693
2694    #[test]
2695    fn test_body_pct_50_for_half_body() {
2696        // open=100, close=105, high=110, low=100 → body=5, range=10, pct=50
2697        let b = bar(100, 110, 100, 105);
2698        assert_eq!(b.body_pct().unwrap(), Decimal::from(50));
2699    }
2700
2701    #[test]
2702    fn test_is_bullish_hammer_true_for_classic_hammer() {
2703        // long lower wick, small body near top, tiny upper wick
2704        // open=108, high=110, low=100, close=109 → body=1, lower=8, upper=1
2705        let b = bar(108, 110, 100, 109);
2706        assert!(b.is_bullish_hammer());
2707    }
2708
2709    #[test]
2710    fn test_is_bullish_hammer_false_when_lower_wick_not_long_enough() {
2711        // open=100, high=110, low=98, close=108 → body=8, lower=2 < 2*8=16
2712        let b = bar(100, 110, 98, 108);
2713        assert!(!b.is_bullish_hammer());
2714    }
2715
2716    #[test]
2717    fn test_is_bullish_hammer_false_for_doji() {
2718        let b = bar(100, 110, 90, 100); // open == close, body = 0
2719        assert!(!b.is_bullish_hammer());
2720    }
2721
2722    // --- OhlcvBar::is_marubozu ---
2723    #[test]
2724    fn test_is_marubozu_true_when_full_body() {
2725        // open=100, high=100, low=100, close=110 → body=10, range=10 → 100%
2726        let b = bar(100, 110, 100, 110);
2727        assert!(b.is_marubozu());
2728    }
2729
2730    #[test]
2731    fn test_is_marubozu_false_when_large_wicks() {
2732        // open=100, high=120, low=80, close=110 → body=10, range=40 → 25%
2733        let b = bar(100, 120, 80, 110);
2734        assert!(!b.is_marubozu());
2735    }
2736
2737    #[test]
2738    fn test_is_marubozu_true_for_zero_range_flat_bar() {
2739        // flat bar has no wicks → qualifies as marubozu under "no wicks" definition
2740        let b = bar(100, 100, 100, 100);
2741        assert!(b.is_marubozu());
2742    }
2743
2744    // --- OhlcvBar::upper_wick_pct ---
2745    #[test]
2746    fn test_upper_wick_pct_zero_when_no_upper_wick() {
2747        // close is the high
2748        let b = bar(100, 110, 90, 110);
2749        let pct = b.upper_wick_pct().unwrap();
2750        assert!(pct.is_zero(), "expected 0, got {pct}");
2751    }
2752
2753    #[test]
2754    fn test_upper_wick_pct_50_when_half_range() {
2755        // open=100, high=120, low=100, close=110 → upper_wick=10, range=20 → 50%
2756        let b = bar(100, 120, 100, 110);
2757        let pct = b.upper_wick_pct().unwrap();
2758        assert_eq!(pct, dec!(50));
2759    }
2760
2761    #[test]
2762    fn test_upper_wick_pct_none_for_zero_range() {
2763        let b = bar(100, 100, 100, 100);
2764        assert!(b.upper_wick_pct().is_none());
2765    }
2766
2767    // --- OhlcvBar::lower_wick_pct ---
2768    #[test]
2769    fn test_lower_wick_pct_zero_when_no_lower_wick() {
2770        // open is the low
2771        let b = bar(100, 110, 100, 105);
2772        let pct = b.lower_wick_pct().unwrap();
2773        assert!(pct.is_zero(), "expected 0, got {pct}");
2774    }
2775
2776    #[test]
2777    fn test_lower_wick_pct_50_when_half_range() {
2778        // open=110, high=120, low=100, close=115 → lower_wick=10, range=20 → 50%
2779        let b = bar(110, 120, 100, 115);
2780        let pct = b.lower_wick_pct().unwrap();
2781        assert_eq!(pct, dec!(50));
2782    }
2783
2784    #[test]
2785    fn test_lower_wick_pct_none_for_zero_range() {
2786        let b = bar(100, 100, 100, 100);
2787        assert!(b.lower_wick_pct().is_none());
2788    }
2789
2790    // --- OhlcvBar::is_bearish_engulfing ---
2791    #[test]
2792    fn test_is_bearish_engulfing_true_for_bearish_engulf() {
2793        let prev = bar(100, 115, 95, 110); // bullish, body 100-110
2794        let curr = bar(112, 115, 88, 90);  // bearish, body 112-90, engulfs 100-110
2795        assert!(curr.is_bearish_engulfing(&prev));
2796    }
2797
2798    #[test]
2799    fn test_is_bearish_engulfing_false_for_bullish_engulf() {
2800        let prev = bar(110, 115, 95, 100); // bearish, body 110-100
2801        let curr = bar(98, 120, 95, 115);  // bullish, body 98-115 engulfs but not bearish
2802        assert!(!curr.is_bearish_engulfing(&prev));
2803    }
2804
2805    #[test]
2806    fn test_is_engulfing_true_when_body_contains_prev_body() {
2807        let prev = bar(100, 110, 95, 105); // prev body: 100-105
2808        let curr = bar(98, 115, 95, 108);  // curr body: 98-108 engulfs 100-105
2809        assert!(curr.is_engulfing(&prev));
2810    }
2811
2812    #[test]
2813    fn test_is_engulfing_false_when_only_partial_overlap() {
2814        let prev = bar(100, 115, 90, 112); // prev body: 100-112
2815        let curr = bar(101, 115, 90, 113); // curr body: 101-113 — lo=101 > 100, not engulfing
2816        assert!(!curr.is_engulfing(&prev));
2817    }
2818
2819    #[test]
2820    fn test_is_engulfing_false_for_equal_bodies() {
2821        let prev = bar(100, 110, 90, 108);
2822        let curr = bar(100, 110, 90, 108); // exactly equal
2823        assert!(!curr.is_engulfing(&prev));
2824    }
2825
2826    // ── OhlcvBar::has_upper_wick / has_lower_wick ─────────────────────────────
2827
2828    #[test]
2829    fn test_has_upper_wick_true_when_high_above_max_oc() {
2830        // open=100, close=110, high=115 → upper wick = 5
2831        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(100), dec!(110));
2832        assert!(bar.has_upper_wick());
2833    }
2834
2835    #[test]
2836    fn test_has_upper_wick_false_for_full_body() {
2837        // open=100, close=110, high=110 → no upper wick
2838        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2839        assert!(!bar.has_upper_wick());
2840    }
2841
2842    #[test]
2843    fn test_has_lower_wick_true_when_low_below_min_oc() {
2844        // open=105, close=110, low=100 → lower wick = 5
2845        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(110));
2846        assert!(bar.has_lower_wick());
2847    }
2848
2849    #[test]
2850    fn test_has_lower_wick_false_for_full_body() {
2851        // open=100, close=110, low=100 → no lower wick
2852        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
2853        assert!(!bar.has_lower_wick());
2854    }
2855
2856    // ── OhlcvBar::is_gravestone_doji ──────────────────────────────────────────
2857
2858    #[test]
2859    fn test_is_gravestone_doji_true() {
2860        // open=close=low=100, high=110 → body=0, close≈low → gravestone
2861        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(100));
2862        assert!(bar.is_gravestone_doji(dec!(0)));
2863    }
2864
2865    #[test]
2866    fn test_is_gravestone_doji_false_when_close_above_low() {
2867        // open=100, close=105, low=99, high=110 → body=5 → not a doji
2868        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
2869        assert!(!bar.is_gravestone_doji(dec!(1)));
2870    }
2871
2872    // ── OhlcvBar::is_dragonfly_doji ───────────────────────────────────────────
2873
2874    #[test]
2875    fn test_is_dragonfly_doji_true() {
2876        // open=close=high=110, low=100 → body=0, close≈high → dragonfly
2877        let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(110));
2878        assert!(bar.is_dragonfly_doji(dec!(0)));
2879    }
2880
2881    #[test]
2882    fn test_is_dragonfly_doji_false_when_close_below_high() {
2883        // close=105, high=110 → close not near high
2884        let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
2885        assert!(!bar.is_dragonfly_doji(dec!(1)));
2886    }
2887
2888    // ── OhlcvBar::is_flat / close_to_high_ratio / close_open_ratio ──────────
2889
2890    #[test]
2891    fn test_is_flat_true() {
2892        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
2893        assert!(bar.is_flat());
2894    }
2895
2896    #[test]
2897    fn test_is_flat_false_when_range_exists() {
2898        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2899        assert!(!bar.is_flat());
2900    }
2901
2902    #[test]
2903    fn test_close_to_high_ratio_normal() {
2904        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
2905        // close=110, high=110 → ratio=1.0
2906        let r = bar.close_to_high_ratio().unwrap();
2907        assert!((r - 1.0).abs() < 1e-9);
2908    }
2909
2910    #[test]
2911    fn test_close_to_high_ratio_none_when_high_zero() {
2912        let bar = make_ohlcv_bar(dec!(0), dec!(0), dec!(0), dec!(0));
2913        assert!(bar.close_to_high_ratio().is_none());
2914    }
2915
2916    #[test]
2917    fn test_close_open_ratio_normal() {
2918        // close=110, open=100 → ratio=1.1
2919        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
2920        let r = bar.close_open_ratio().unwrap();
2921        assert!((r - 1.1).abs() < 1e-9);
2922    }
2923
2924    #[test]
2925    fn test_close_open_ratio_none_when_open_zero() {
2926        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
2927        assert!(bar.close_open_ratio().is_none());
2928    }
2929
2930    // ── OhlcvBar::true_range_with_prev ────────────────────────────────────────
2931
2932    #[test]
2933    fn test_true_range_simple_hl_dominates() {
2934        // high=110, low=90, prev_close=100 → hl=20, hc=10, lc=10 → TR=20
2935        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2936        assert_eq!(bar.true_range_with_prev(dec!(100)), dec!(20));
2937    }
2938
2939    #[test]
2940    fn test_true_range_gap_up_dominates() {
2941        // prev_close=80, high=100, low=90 → hl=10, hc=20, lc=10 → TR=20
2942        let bar = make_ohlcv_bar(dec!(91), dec!(100), dec!(90), dec!(95));
2943        assert_eq!(bar.true_range_with_prev(dec!(80)), dec!(20));
2944    }
2945
2946    #[test]
2947    fn test_true_range_gap_down_dominates() {
2948        // prev_close=120, high=100, low=95 → hl=5, hc=20, lc=25 → TR=25
2949        let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97));
2950        assert_eq!(bar.true_range_with_prev(dec!(120)), dec!(25));
2951    }
2952
2953    // ── OhlcvBar::is_outside_bar / high_low_midpoint ─────────────────────────
2954
2955    #[test]
2956    fn test_is_outside_bar_true() {
2957        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2958        let bar  = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
2959        assert!(bar.is_outside_bar(&prev));
2960    }
2961
2962    #[test]
2963    fn test_is_outside_bar_false_when_inside() {
2964        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2965        let bar  = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
2966        assert!(!bar.is_outside_bar(&prev));
2967    }
2968
2969    #[test]
2970    fn test_high_low_midpoint_correct() {
2971        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
2972        // (110 + 90) / 2 = 100
2973        assert_eq!(bar.high_low_midpoint(), dec!(100));
2974    }
2975
2976    #[test]
2977    fn test_high_low_midpoint_uneven() {
2978        let bar = make_ohlcv_bar(dec!(100), dec!(111), dec!(90), dec!(100));
2979        // (111 + 90) / 2 = 100.5
2980        assert_eq!(bar.high_low_midpoint(), dec!(100.5));
2981    }
2982
2983    // ── OhlcvBar::gap_up / gap_down ──────────────────────────────────────────
2984
2985    #[test]
2986    fn test_gap_up_true() {
2987        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
2988        let bar  = make_ohlcv_bar(dec!(102), dec!(110), dec!(101), dec!(108));
2989        assert!(bar.gap_up(&prev));
2990    }
2991
2992    #[test]
2993    fn test_gap_up_false_when_no_gap() {
2994        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
2995        let bar  = make_ohlcv_bar(dec!(99), dec!(105), dec!(98), dec!(104));
2996        assert!(!bar.gap_up(&prev));
2997    }
2998
2999    #[test]
3000    fn test_gap_down_true() {
3001        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
3002        let bar  = make_ohlcv_bar(dec!(88), dec!(89), dec!(85), dec!(86));
3003        assert!(bar.gap_down(&prev));
3004    }
3005
3006    #[test]
3007    fn test_gap_down_false_when_no_gap() {
3008        let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
3009        let bar  = make_ohlcv_bar(dec!(91), dec!(95), dec!(89), dec!(93));
3010        assert!(!bar.gap_down(&prev));
3011    }
3012
3013    // ── OhlcvBar::range_pct ──────────────────────────────────────────────────
3014
3015    #[test]
3016    fn test_range_pct_correct() {
3017        // open=100, high=110, low=90 → range=20, 20/100 * 100 = 20%
3018        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3019        let pct = bar.range_pct().unwrap();
3020        assert!((pct - 20.0).abs() < 1e-9);
3021    }
3022
3023    #[test]
3024    fn test_range_pct_none_when_open_zero() {
3025        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
3026        assert!(bar.range_pct().is_none());
3027    }
3028
3029    #[test]
3030    fn test_range_pct_zero_for_flat_bar() {
3031        // high == low → range = 0
3032        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3033        let pct = bar.range_pct().unwrap();
3034        assert_eq!(pct, 0.0);
3035    }
3036
3037    // ── OhlcvBar::body_size ──────────────────────────────────────────────────
3038
3039    #[test]
3040    fn test_body_size_bullish_bar() {
3041        // open=100, close=110 → body = 10
3042        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
3043        assert_eq!(bar.body_size(), dec!(10));
3044    }
3045
3046    #[test]
3047    fn test_body_size_bearish_bar() {
3048        // open=110, close=100 → body = 10
3049        let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
3050        assert_eq!(bar.body_size(), dec!(10));
3051    }
3052
3053    #[test]
3054    fn test_body_size_doji() {
3055        // open == close → body = 0
3056        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
3057        assert_eq!(bar.body_size(), dec!(0));
3058    }
3059
3060    // ── OhlcvBar::volume_delta / is_consolidating ────────────────────────────
3061
3062    #[test]
3063    fn test_volume_delta_positive_when_increasing() {
3064        let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3065        prev.volume = dec!(1000);
3066        let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
3067        bar.volume = dec!(1500);
3068        assert_eq!(bar.volume_delta(&prev), dec!(500));
3069    }
3070
3071    #[test]
3072    fn test_volume_delta_negative_when_decreasing() {
3073        let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3074        prev.volume = dec!(1500);
3075        let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
3076        bar.volume = dec!(1000);
3077        assert_eq!(bar.volume_delta(&prev), dec!(-500));
3078    }
3079
3080    #[test]
3081    fn test_is_consolidating_true_when_small_range() {
3082        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
3083        let bar  = make_ohlcv_bar(dec!(102), dec!(106), dec!(100), dec!(104)); // range=6 < 10
3084        assert!(bar.is_consolidating(&prev));
3085    }
3086
3087    #[test]
3088    fn test_is_consolidating_false_when_large_range() {
3089        let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); // range=20
3090        let bar  = make_ohlcv_bar(dec!(102), dec!(115), dec!(95), dec!(110)); // range=20, not < 10
3091        assert!(!bar.is_consolidating(&prev));
3092    }
3093
3094    // ── OhlcvBar::relative_volume / intraday_reversal ─────────────────────────
3095
3096    #[test]
3097    fn test_relative_volume_correct() {
3098        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
3099        // bar.volume = dec!(1) (default), avg = 2 → ratio = 0.5
3100        let rv = bar.relative_volume(dec!(2)).unwrap();
3101        assert!((rv - 0.5).abs() < 1e-9);
3102    }
3103
3104    #[test]
3105    fn test_relative_volume_none_when_avg_zero() {
3106        let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
3107        assert!(bar.relative_volume(dec!(0)).is_none());
3108    }
3109
3110    #[test]
3111    fn test_intraday_reversal_true_for_bullish_then_bearish() {
3112        // prev: open=100, close=105 (bullish)
3113        let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
3114        // this: opens at 105 (≥ prev close), closes below prev open (100) → reversal
3115        let bar = make_ohlcv_bar(dec!(105), dec!(107), dec!(97), dec!(98));
3116        assert!(bar.intraday_reversal(&prev));
3117    }
3118
3119    #[test]
3120    fn test_intraday_reversal_false_for_continuation() {
3121        // prev: open=100, close=105 (bullish), this also bullish at lower open
3122        let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
3123        let bar = make_ohlcv_bar(dec!(104), dec!(115), dec!(103), dec!(113));
3124        assert!(!bar.intraday_reversal(&prev));
3125    }
3126
3127    // ── OhlcvBar::price_at_pct ───────────────────────────────────────────────
3128
3129    #[test]
3130    fn test_price_at_pct_zero_returns_low() {
3131        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3132        assert_eq!(bar.price_at_pct(0.0), dec!(90));
3133    }
3134
3135    #[test]
3136    fn test_price_at_pct_one_returns_high() {
3137        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3138        assert_eq!(bar.price_at_pct(1.0), dec!(110));
3139    }
3140
3141    #[test]
3142    fn test_price_at_pct_half_returns_midpoint() {
3143        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3144        // low=90, range=20, 0.5*20=10 → 90+10=100
3145        assert_eq!(bar.price_at_pct(0.5), dec!(100));
3146    }
3147
3148    #[test]
3149    fn test_price_at_pct_clamped_above_one() {
3150        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3151        assert_eq!(bar.price_at_pct(2.0), dec!(110));
3152    }
3153
3154    // ── mean_volume ───────────────────────────────────────────────────────────
3155
3156    #[test]
3157    fn test_mean_volume_none_when_empty() {
3158        assert!(OhlcvBar::mean_volume(&[]).is_none());
3159    }
3160
3161    #[test]
3162    fn test_mean_volume_single_bar() {
3163        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3164        bar.volume = dec!(200);
3165        assert_eq!(OhlcvBar::mean_volume(&[bar]), Some(dec!(200)));
3166    }
3167
3168    #[test]
3169    fn test_mean_volume_multiple_bars() {
3170        let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3171        b1.volume = dec!(100);
3172        let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3173        b2.volume = dec!(200);
3174        let mut b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3175        b3.volume = dec!(300);
3176        assert_eq!(OhlcvBar::mean_volume(&[b1, b2, b3]), Some(dec!(200)));
3177    }
3178
3179    // ── vwap_deviation ────────────────────────────────────────────────────────
3180
3181    #[test]
3182    fn test_vwap_deviation_none_when_vwap_not_set() {
3183        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3184        assert!(bar.vwap_deviation().is_none());
3185    }
3186
3187    #[test]
3188    fn test_vwap_deviation_zero_when_close_equals_vwap() {
3189        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3190        bar.vwap = Some(dec!(100));
3191        assert_eq!(bar.vwap_deviation(), Some(0.0));
3192    }
3193
3194    #[test]
3195    fn test_vwap_deviation_correct_value() {
3196        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3197        bar.vwap = Some(dec!(100));
3198        // |110-100|/100 = 0.10
3199        let dev = bar.vwap_deviation().unwrap();
3200        assert!((dev - 0.1).abs() < 1e-10);
3201    }
3202
3203    // ── high_close_ratio ──────────────────────────────────────────────────────
3204
3205    #[test]
3206    fn test_high_close_ratio_none_when_high_zero() {
3207        let bar = OhlcvBar {
3208            symbol: "X".into(),
3209            timeframe: Timeframe::Minutes(1),
3210            open: dec!(0),
3211            high: dec!(0),
3212            low: dec!(0),
3213            close: dec!(0),
3214            volume: dec!(1),
3215            bar_start_ms: 0,
3216            trade_count: 1,
3217            is_complete: false,
3218            is_gap_fill: false,
3219            vwap: None,
3220        };
3221        assert!(bar.high_close_ratio().is_none());
3222    }
3223
3224    #[test]
3225    fn test_high_close_ratio_one_when_close_equals_high() {
3226        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3227        let ratio = bar.high_close_ratio().unwrap();
3228        assert!((ratio - 1.0).abs() < 1e-10);
3229    }
3230
3231    #[test]
3232    fn test_high_close_ratio_less_than_one_when_close_below_high() {
3233        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(99));
3234        let ratio = bar.high_close_ratio().unwrap();
3235        assert!(ratio < 1.0);
3236    }
3237
3238    // ── lower_shadow_pct ──────────────────────────────────────────────────────
3239
3240    #[test]
3241    fn test_lower_shadow_pct_none_when_range_zero() {
3242        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3243        assert!(bar.lower_shadow_pct().is_none());
3244    }
3245
3246    #[test]
3247    fn test_lower_shadow_pct_zero_when_no_lower_shadow() {
3248        // open=low=90, close=high=110 → lower_shadow=0
3249        let bar = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
3250        let pct = bar.lower_shadow_pct().unwrap();
3251        assert!(pct.abs() < 1e-10);
3252    }
3253
3254    #[test]
3255    fn test_lower_shadow_pct_correct_value() {
3256        // open=100, close=105, high=110, low=90 → lower_shadow=min(100,105)-90=10, range=20 → 0.5
3257        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
3258        let pct = bar.lower_shadow_pct().unwrap();
3259        assert!((pct - 0.5).abs() < 1e-10);
3260    }
3261
3262    // ── open_close_ratio ──────────────────────────────────────────────────────
3263
3264    #[test]
3265    fn test_open_close_ratio_none_when_open_zero() {
3266        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
3267        assert!(bar.open_close_ratio().is_none());
3268    }
3269
3270    #[test]
3271    fn test_open_close_ratio_one_when_flat() {
3272        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3273        let ratio = bar.open_close_ratio().unwrap();
3274        assert!((ratio - 1.0).abs() < 1e-10);
3275    }
3276
3277    #[test]
3278    fn test_open_close_ratio_above_one_for_bullish_bar() {
3279        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
3280        let ratio = bar.open_close_ratio().unwrap();
3281        assert!(ratio > 1.0);
3282    }
3283
3284    // ── is_wide_range_bar ─────────────────────────────────────────────────────
3285
3286    #[test]
3287    fn test_is_wide_range_bar_true_when_range_exceeds_threshold() {
3288        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110)); // range=25
3289        assert!(bar.is_wide_range_bar(dec!(20)));
3290    }
3291
3292    #[test]
3293    fn test_is_wide_range_bar_false_when_range_equals_threshold() {
3294        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(100), dec!(110)); // range=20
3295        assert!(!bar.is_wide_range_bar(dec!(20)));
3296    }
3297
3298    // ── close_to_low_ratio ────────────────────────────────────────────────────
3299
3300    #[test]
3301    fn test_close_to_low_ratio_none_when_range_zero() {
3302        let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
3303        assert!(bar.close_to_low_ratio().is_none());
3304    }
3305
3306    #[test]
3307    fn test_close_to_low_ratio_one_when_closed_at_high() {
3308        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
3309        let ratio = bar.close_to_low_ratio().unwrap();
3310        assert!((ratio - 1.0).abs() < 1e-10);
3311    }
3312
3313    #[test]
3314    fn test_close_to_low_ratio_zero_when_closed_at_low() {
3315        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
3316        let ratio = bar.close_to_low_ratio().unwrap();
3317        assert!(ratio.abs() < 1e-10);
3318    }
3319
3320    #[test]
3321    fn test_close_to_low_ratio_half_at_midpoint() {
3322        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3323        // (100-90)/(110-90) = 10/20 = 0.5
3324        let ratio = bar.close_to_low_ratio().unwrap();
3325        assert!((ratio - 0.5).abs() < 1e-10);
3326    }
3327
3328    // ── volume_per_trade ──────────────────────────────────────────────────────
3329
3330    #[test]
3331    fn test_volume_per_trade_none_when_trade_count_zero() {
3332        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3333        bar.trade_count = 0;
3334        assert!(bar.volume_per_trade().is_none());
3335    }
3336
3337    #[test]
3338    fn test_volume_per_trade_correct_value() {
3339        let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3340        bar.volume = dec!(500);
3341        bar.trade_count = 5;
3342        assert_eq!(bar.volume_per_trade(), Some(dec!(100)));
3343    }
3344
3345    // ── price_range_overlap ───────────────────────────────────────────────────
3346
3347    #[test]
3348    fn test_price_range_overlap_true_when_ranges_overlap() {
3349        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3350        let b = make_ohlcv_bar(dec!(105), dec!(120), dec!(95), dec!(110));
3351        assert!(a.price_range_overlap(&b));
3352    }
3353
3354    #[test]
3355    fn test_price_range_overlap_false_when_no_overlap() {
3356        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3357        let b = make_ohlcv_bar(dec!(120), dec!(130), dec!(115), dec!(125));
3358        assert!(!a.price_range_overlap(&b));
3359    }
3360
3361    #[test]
3362    fn test_price_range_overlap_true_at_exact_touch() {
3363        let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
3364        let b = make_ohlcv_bar(dec!(115), dec!(125), dec!(110), dec!(120));
3365        assert!(a.price_range_overlap(&b));
3366    }
3367
3368    // ── bar_height_pct ────────────────────────────────────────────────────────
3369
3370    #[test]
3371    fn test_bar_height_pct_none_when_open_zero() {
3372        let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
3373        assert!(bar.bar_height_pct().is_none());
3374    }
3375
3376    #[test]
3377    fn test_bar_height_pct_correct_value() {
3378        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // range=20
3379        // 20/100 = 0.2
3380        let pct = bar.bar_height_pct().unwrap();
3381        assert!((pct - 0.2).abs() < 1e-10);
3382    }
3383
3384    // ── is_bullish_engulfing ──────────────────────────────────────────────────
3385
3386    #[test]
3387    fn test_is_bullish_engulfing_true_for_valid_pattern() {
3388        // prev: bearish bar (open=110, close=100), this: bullish, engulfs (open=98, close=112)
3389        let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
3390        let bar = make_ohlcv_bar(dec!(98), dec!(115), dec!(95), dec!(112));
3391        assert!(bar.is_bullish_engulfing(&prev));
3392    }
3393
3394    #[test]
3395    fn test_is_bullish_engulfing_false_when_bearish() {
3396        let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
3397        let bar = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(95));
3398        assert!(!bar.is_bullish_engulfing(&prev));
3399    }
3400
3401    // ── close_gap ─────────────────────────────────────────────────────────────
3402
3403    #[test]
3404    fn test_close_gap_positive_for_gap_up() {
3405        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3406        let bar = make_ohlcv_bar(dec!(106), dec!(110), dec!(103), dec!(108)); // open=106 > prev close=102
3407        assert_eq!(bar.close_gap(&prev), dec!(4));
3408    }
3409
3410    #[test]
3411    fn test_close_gap_negative_for_gap_down() {
3412        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3413        let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97)); // open=98 < prev close=102
3414        assert_eq!(bar.close_gap(&prev), dec!(-4));
3415    }
3416
3417    #[test]
3418    fn test_close_gap_zero_when_no_gap() {
3419        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
3420        let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108));
3421        assert_eq!(bar.close_gap(&prev), dec!(0));
3422    }
3423
3424    // ── close_above_midpoint ──────────────────────────────────────────────────
3425
3426    #[test]
3427    fn test_close_above_midpoint_true_when_above_mid() {
3428        // high=110, low=90 → mid=100; close=105 > 100
3429        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
3430        assert!(bar.close_above_midpoint());
3431    }
3432
3433    #[test]
3434    fn test_close_above_midpoint_false_when_at_mid() {
3435        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); // close=mid=100
3436        assert!(!bar.close_above_midpoint());
3437    }
3438
3439    // ── close_momentum ────────────────────────────────────────────────────────
3440
3441    #[test]
3442    fn test_close_momentum_positive_when_rising() {
3443        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
3444        let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
3445        assert_eq!(bar.close_momentum(&prev), dec!(10));
3446    }
3447
3448    #[test]
3449    fn test_close_momentum_zero_when_unchanged() {
3450        let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
3451        let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(100));
3452        assert_eq!(bar.close_momentum(&prev), dec!(0));
3453    }
3454
3455    // ── bar_range ─────────────────────────────────────────────────────────────
3456
3457    #[test]
3458    fn test_bar_range_correct() {
3459        let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
3460        assert_eq!(bar.bar_range(), dec!(30));
3461    }
3462}