Skip to main content

fin_primitives/ohlcv/
mod.rs

1//! # Module: ohlcv
2//!
3//! ## Responsibility
4//! Provides OHLCV bar data structures, timeframe definitions, tick-to-bar aggregation,
5//! and an ordered bar series with window queries.
6//!
7//! ## Guarantees
8//! - `OhlcvBar::validate()` enforces: `high >= open`, `high >= close`, `low <= open`,
9//!   `low <= close`, `high >= low`
10//! - `OhlcvAggregator::push_tick` returns all completed bars including gap-fill bars
11//!   when ticks skip multiple timeframe buckets
12//! - `OhlcvSeries::push` maintains insertion order
13//! - `OhlcvSeries` implements `IntoIterator` for `&OhlcvSeries`
14//!
15//! ## NOT Responsible For
16//! - Persistence
17//! - Cross-symbol aggregation
18
19use crate::error::FinError;
20use crate::tick::Tick;
21use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
22use rust_decimal::Decimal;
23
24/// A completed OHLCV bar for a single symbol and timeframe bucket.
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
26pub struct OhlcvBar {
27    /// The instrument.
28    pub symbol: Symbol,
29    /// Opening price of the bar.
30    pub open: Price,
31    /// Highest price during the bar.
32    pub high: Price,
33    /// Lowest price during the bar.
34    pub low: Price,
35    /// Closing price of the bar.
36    pub close: Price,
37    /// Total traded volume during the bar.
38    pub volume: Quantity,
39    /// Timestamp of the first tick in the bar.
40    pub ts_open: NanoTimestamp,
41    /// Timestamp of the last tick in the bar.
42    pub ts_close: NanoTimestamp,
43    /// Number of ticks that contributed to this bar.
44    pub tick_count: u64,
45}
46
47/// Classic floor-trader pivot levels derived from a prior bar's H/L/C.
48///
49/// - `pp`: Pivot Point `(H + L + C) / 3`
50/// - `r1`, `r2`: Resistance levels 1 and 2
51/// - `s1`, `s2`: Support levels 1 and 2
52#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
53pub struct PivotPoints {
54    /// Pivot Point
55    pub pp: Decimal,
56    /// First resistance level
57    pub r1: Decimal,
58    /// First support level
59    pub s1: Decimal,
60    /// Second resistance level
61    pub r2: Decimal,
62    /// Second support level
63    pub s2: Decimal,
64}
65
66impl OhlcvBar {
67    /// Constructs and validates an `OhlcvBar` from individual components.
68    ///
69    /// Equivalent to building the struct literal then calling `validate()`,
70    /// but more convenient for test and user code that does not want to
71    /// spell out all nine named fields.
72    ///
73    /// # Errors
74    /// Returns [`FinError::BarInvariant`] if the OHLCV invariants are violated.
75    #[allow(clippy::too_many_arguments)]
76    pub fn new(
77        symbol: Symbol,
78        open: Price,
79        high: Price,
80        low: Price,
81        close: Price,
82        volume: Quantity,
83        ts_open: NanoTimestamp,
84        ts_close: NanoTimestamp,
85        tick_count: u64,
86    ) -> Result<Self, FinError> {
87        let bar = Self {
88            symbol,
89            open,
90            high,
91            low,
92            close,
93            volume,
94            ts_open,
95            ts_close,
96            tick_count,
97        };
98        bar.validate()?;
99        Ok(bar)
100    }
101
102    /// Validates OHLCV invariants.
103    ///
104    /// # Errors
105    /// Returns [`FinError::BarInvariant`] if any of these fail:
106    /// - `high >= open`
107    /// - `high >= close`
108    /// - `low <= open`
109    /// - `low <= close`
110    /// - `high >= low`
111    pub fn validate(&self) -> Result<(), FinError> {
112        let h = self.high.value();
113        let l = self.low.value();
114        let o = self.open.value();
115        let c = self.close.value();
116        if h < o {
117            return Err(FinError::BarInvariant(format!("high {h} < open {o}")));
118        }
119        if h < c {
120            return Err(FinError::BarInvariant(format!("high {h} < close {c}")));
121        }
122        if l > o {
123            return Err(FinError::BarInvariant(format!("low {l} > open {o}")));
124        }
125        if l > c {
126            return Err(FinError::BarInvariant(format!("low {l} > close {c}")));
127        }
128        if h < l {
129            return Err(FinError::BarInvariant(format!("high {h} < low {l}")));
130        }
131        Ok(())
132    }
133
134    /// Converts this bar to a [`crate::signals::BarInput`] for signal computation.
135    pub fn to_bar_input(&self) -> crate::signals::BarInput {
136        crate::signals::BarInput::from(self)
137    }
138
139    /// Returns the typical price: `(high + low + close) / 3`.
140    pub fn typical_price(&self) -> Decimal {
141        (self.high.value() + self.low.value() + self.close.value()) / Decimal::from(3u32)
142    }
143
144    /// Returns the price range: `high - low`.
145    pub fn range(&self) -> Decimal {
146        self.high.value() - self.low.value()
147    }
148
149    /// Returns the HLCC/4 price: `(high + low + close + close) / 4`.
150    ///
151    /// Weights the close price twice, giving it more significance than the
152    /// typical price. Commonly used as a weighted price reference.
153    pub fn hlcc4(&self) -> Decimal {
154        (self.high.value() + self.low.value() + self.close.value() + self.close.value())
155            / Decimal::from(4u32)
156    }
157
158    /// Returns the weighted close price: `(high + low + close * 2) / 4`.
159    ///
160    /// Alias for `hlcc4`. Commonly called "weighted close" in technical analysis
161    /// literature; emphasises the closing price over the high and low.
162    pub fn weighted_close(&self) -> Decimal {
163        self.hlcc4()
164    }
165
166    /// Returns the OHLC/4 price: `(open + high + low + close) / 4`.
167    ///
168    /// Equal weight for all four price components. Common in smoothed candlestick
169    /// calculations and some custom charting systems.
170    pub fn ohlc4(&self) -> Decimal {
171        (self.open.value() + self.high.value() + self.low.value() + self.close.value())
172            / Decimal::from(4u32)
173    }
174
175    /// Returns the dollar volume of this bar: `typical_price × volume`.
176    ///
177    /// Dollar volume is a common liquidity metric: high dollar volume means
178    /// large amounts of capital changed hands, making the instrument easier to
179    /// trade without excessive market impact.
180    pub fn dollar_volume(&self) -> Decimal {
181        self.typical_price() * self.volume.value()
182    }
183
184    /// Returns `true` if this bar is a gap-fill placeholder (zero ticks).
185    ///
186    /// Gap-fill bars are emitted by `OhlcvAggregator` when a tick arrives several
187    /// buckets ahead of the current one. They have `tick_count == 0` and zero volume.
188    pub fn is_gap_fill(&self) -> bool {
189        self.tick_count == 0
190    }
191
192    /// Returns `true` if this bar is an inside bar relative to `prev`.
193    ///
194    /// An inside bar is fully contained within the previous bar's range:
195    /// `self.high < prev.high && self.low > prev.low`. Commonly used in price
196    /// action analysis to identify consolidation before a potential breakout.
197    pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
198        self.high.value() < prev.high.value() && self.low.value() > prev.low.value()
199    }
200
201    /// Returns `true` if this bar's range completely contains the previous bar's range.
202    ///
203    /// An outside bar has `high > prev.high && low < prev.low`. Signals potential
204    /// volatility expansion or reversal — the opposite of an inside bar.
205    pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
206        self.high.value() > prev.high.value() && self.low.value() < prev.low.value()
207    }
208
209    /// Returns `true` if this bar engulfs the previous bar (bullish or bearish engulfing).
210    ///
211    /// A bullish engulfing bar: `prev` is bearish and `self` is a bullish bar whose
212    /// body completely contains `prev`'s body. Bearish is the mirror image.
213    pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
214        let s_o = self.open.value();
215        let s_c = self.close.value();
216        let p_o = prev.open.value();
217        let p_c = prev.close.value();
218        let bullish = p_c < p_o && s_c > s_o && s_c >= p_o && s_o <= p_c;
219        let bearish = p_c > p_o && s_c < s_o && s_c <= p_o && s_o >= p_c;
220        bullish || bearish
221    }
222
223    /// Returns `true` if `close >= open`.
224    pub fn is_bullish(&self) -> bool {
225        self.close.value() >= self.open.value()
226    }
227
228    /// Returns `true` if `close < open`.
229    pub fn is_bearish(&self) -> bool {
230        self.close.value() < self.open.value()
231    }
232
233    /// Returns `true` if the bar has a hammer candlestick shape.
234    ///
235    /// Criteria: lower shadow ≥ 2 × body size, upper shadow ≤ body size, non-zero body.
236    pub fn is_hammer(&self) -> bool {
237        let body = self.body_size();
238        if body.is_zero() {
239            return false;
240        }
241        self.lower_shadow() >= body * Decimal::TWO && self.upper_shadow() <= body
242    }
243
244    /// Returns `true` if the bar is a marubozu: a full-body candle with negligible shadows.
245    ///
246    /// Criteria: both upper and lower shadows are each < 5% of the bar's total range,
247    /// and the body is non-zero.
248    pub fn is_marubozu(&self) -> bool {
249        let range = self.range();
250        if range.is_zero() {
251            return false;
252        }
253        let body = self.body_size();
254        if body.is_zero() {
255            return false;
256        }
257        let threshold = range / Decimal::from(20u32); // 5% of range
258        self.upper_shadow() < threshold && self.lower_shadow() < threshold
259    }
260
261    /// Returns `true` if the bar is a spinning top: a small body with significant upper
262    /// and lower shadows.
263    ///
264    /// Criteria: body is less than 30% of the total range, and both shadows are each
265    /// at least 20% of the range.
266    pub fn is_spinning_top(&self) -> bool {
267        let range = self.range();
268        if range.is_zero() {
269            return false;
270        }
271        let body = self.body_size();
272        let body_ratio = body / range;
273        let upper_ratio = self.upper_shadow() / range;
274        let lower_ratio = self.lower_shadow() / range;
275        let threshold_30 = Decimal::new(30, 2);
276        let threshold_20 = Decimal::new(20, 2);
277        body_ratio < threshold_30 && upper_ratio >= threshold_20 && lower_ratio >= threshold_20
278    }
279
280    /// Returns `true` if the bar has a shooting star candlestick shape.
281    ///
282    /// Criteria: upper shadow ≥ 2 × body size, lower shadow ≤ body size, non-zero body.
283    pub fn is_shooting_star(&self) -> bool {
284        let body = self.body_size();
285        if body.is_zero() {
286            return false;
287        }
288        self.upper_shadow() >= body * Decimal::TWO && self.lower_shadow() <= body
289    }
290
291    /// Returns the body size as a percentage of the open price: `body_size / open * 100`.
292    ///
293    /// Returns `None` when `open` is zero.
294    pub fn body_pct(&self) -> Option<Decimal> {
295        let o = self.open.value();
296        if o.is_zero() {
297            return None;
298        }
299        Some(self.body_size() / o * Decimal::ONE_HUNDRED)
300    }
301
302    /// Returns the open-to-close return as a percentage: `(close - open) / open * 100`.
303    ///
304    /// Returns `None` when `open` is zero.
305    pub fn bar_return(&self) -> Option<Decimal> {
306        let o = self.open.value();
307        if o.is_zero() {
308            return None;
309        }
310        Some((self.close.value() - o) / o * Decimal::ONE_HUNDRED)
311    }
312
313    /// Returns the midpoint price: `(high + low) / 2` (HL2).
314    pub fn midpoint(&self) -> Decimal {
315        (self.high.value() + self.low.value()) / Decimal::TWO
316    }
317
318    /// Returns the absolute candlestick body size: `|close - open|`.
319    pub fn body_size(&self) -> Decimal {
320        (self.close.value() - self.open.value()).abs()
321    }
322
323    /// Body-to-range ratio: `body_size() / range()`.
324    ///
325    /// Returns `None` when `range() == 0` (flat bar). A value near 1 means the
326    /// bar is all body; near 0 means the bar is mostly wicks.
327    pub fn body_to_range_ratio(&self) -> Option<Decimal> {
328        let r = self.range();
329        if r.is_zero() {
330            return None;
331        }
332        Some(self.body_size() / r)
333    }
334
335    /// Returns `true` if the bar's body is large relative to its range.
336    ///
337    /// A bar is considered "long" when `body_size / range >= factor`.
338    /// Returns `false` when `range == 0` (flat bar).
339    pub fn is_long_candle(&self, factor: Decimal) -> bool {
340        let r = self.range();
341        if r == Decimal::ZERO {
342            return false;
343        }
344        self.body_size() / r >= factor
345    }
346
347    /// Returns `true` if the bar is a doji: `body_size / range < threshold`.
348    ///
349    /// A doji indicates indecision. Returns `false` when `range == 0` (flat bar)
350    /// and `threshold == 0`; returns `true` for a flat bar with any positive threshold.
351    pub fn is_doji(&self, threshold: Decimal) -> bool {
352        let r = self.range();
353        if r == Decimal::ZERO {
354            return threshold > Decimal::ZERO;
355        }
356        self.body_size() / r < threshold
357    }
358
359    /// Returns the ratio of body to range: `body_size / range`.
360    ///
361    /// Returns `None` when `range == 0` (doji / flat bar) to avoid division by zero.
362    /// Values close to `1` indicate a strong directional candle; values close to `0`
363    /// indicate a spinning top or doji.
364    pub fn body_ratio(&self) -> Option<Decimal> {
365        let r = self.range();
366        if r == Decimal::ZERO {
367            return None;
368        }
369        Some(self.body_size() / r)
370    }
371
372    /// Returns the True Range for this bar.
373    ///
374    /// True Range is the maximum of:
375    /// - `high - low`
376    /// - `|high - prev_close|` (if `prev` is `Some`)
377    /// - `|low  - prev_close|` (if `prev` is `Some`)
378    ///
379    /// When `prev` is `None`, True Range falls back to `high - low`.
380    /// This is the building block for ATR and volatility calculations.
381    pub fn true_range(&self, prev: Option<&OhlcvBar>) -> Decimal {
382        let hl = self.high.value() - self.low.value();
383        match prev {
384            None => hl,
385            Some(p) => {
386                let pc = p.close.value();
387                let hc = (self.high.value() - pc).abs();
388                let lc = (self.low.value() - pc).abs();
389                hl.max(hc).max(lc)
390            }
391        }
392    }
393
394    /// Returns the ratio of total shadow to range: `(upper_shadow + lower_shadow) / range`.
395    ///
396    /// A value near `1.0` indicates most of the bar's range is wick (indecision).
397    /// Returns `None` when `range == 0`.
398    pub fn shadow_ratio(&self) -> Option<Decimal> {
399        let r = self.range();
400        if r.is_zero() {
401            return None;
402        }
403        Some((self.upper_shadow() + self.lower_shadow()) / r)
404    }
405
406    /// Returns `true` if this bar opens above the previous bar's high (gap up).
407    pub fn gap_up_from(&self, prev: &OhlcvBar) -> bool {
408        self.low.value() > prev.high.value()
409    }
410
411    /// Returns `true` if this bar opens below the previous bar's low (gap down).
412    pub fn gap_down_from(&self, prev: &OhlcvBar) -> bool {
413        self.high.value() < prev.low.value()
414    }
415
416    /// Signed gap from prior bar: `self.open - prev.close`.
417    ///
418    /// Positive = gap up, negative = gap down, zero = no gap.
419    pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
420        self.open.value() - prev.close.value()
421    }
422
423    /// Returns the upper shadow length: `high - max(open, close)`.
424    pub fn upper_shadow(&self) -> Decimal {
425        let body_top = self.open.value().max(self.close.value());
426        self.high.value() - body_top
427    }
428
429    /// Returns the lower shadow length: `min(open, close) - low`.
430    pub fn lower_shadow(&self) -> Decimal {
431        let body_bottom = self.open.value().min(self.close.value());
432        body_bottom - self.low.value()
433    }
434
435    /// Returns the duration of this bar in nanoseconds: `ts_close - ts_open`.
436    ///
437    /// For gap-fill bars (no ticks), both timestamps are equal and this returns 0.
438    pub fn duration_nanos(&self) -> i64 {
439        self.ts_close.nanos() - self.ts_open.nanos()
440    }
441
442    /// Returns the percentage gap between `prev.close` and `self.open`.
443    ///
444    /// `gap_pct = (self.open - prev.close) / prev.close * 100`
445    ///
446    /// Returns `None` if `prev.close` is zero. Positive values indicate an upward gap;
447    /// negative values a downward gap.
448    pub fn gap_pct(&self, prev: &OhlcvBar) -> Option<Decimal> {
449        let prev_close = prev.close.value();
450        if prev_close.is_zero() {
451            return None;
452        }
453        Some((self.open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
454    }
455
456    /// Returns `true` if this bar opened with a gap larger than `pct_threshold` percent.
457    ///
458    /// A gap exists when `|gap_pct| >= pct_threshold`. Returns `false` when
459    /// `gap_pct` cannot be computed (zero previous close).
460    pub fn has_gap(&self, prev: &OhlcvBar, pct_threshold: Decimal) -> bool {
461        self.gap_pct(prev)
462            .map_or(false, |g| g.abs() >= pct_threshold)
463    }
464
465    /// Creates a single-tick OHLCV bar from a `Tick`.
466    ///
467    /// All price fields are set to the tick's price, volume to the tick's quantity,
468    /// and both timestamps to the tick's timestamp.
469    pub fn from_tick(tick: &Tick) -> Self {
470        Self {
471            symbol: tick.symbol.clone(),
472            open: tick.price,
473            high: tick.price,
474            low: tick.price,
475            close: tick.price,
476            volume: tick.quantity,
477            ts_open: tick.timestamp,
478            ts_close: tick.timestamp,
479            tick_count: 1,
480        }
481    }
482
483    /// Merges `other` into `self`, producing a combined bar spanning both time ranges.
484    ///
485    /// - `open` comes from `self` (the earlier bar)
486    /// - `close` comes from `other` (the later bar)
487    /// - `high` / `low` are the extremes across both bars
488    /// - `volume` and `tick_count` are summed
489    /// - `ts_open` from `self`, `ts_close` from `other`
490    ///
491    /// # Errors
492    /// Returns [`FinError::BarInvariant`] if the merged bar fails invariant checks (should not
493    /// occur for well-formed inputs but is checked defensively).
494    pub fn merge(&self, other: &OhlcvBar) -> Result<OhlcvBar, FinError> {
495        let high = self.high.value().max(other.high.value());
496        let low = self.low.value().min(other.low.value());
497        let volume_sum = self.volume.value() + other.volume.value();
498        let bar = OhlcvBar {
499            symbol: self.symbol.clone(),
500            open: self.open,
501            high: Price::new(high)?,
502            low: Price::new(low)?,
503            close: other.close,
504            volume: Quantity::new(volume_sum)?,
505            ts_open: self.ts_open,
506            ts_close: other.ts_close,
507            tick_count: self.tick_count + other.tick_count,
508        };
509        bar.validate()?;
510        Ok(bar)
511    }
512
513    /// Returns `true` if this bar is a bullish engulfing of `prev`.
514    ///
515    /// Conditions:
516    /// - `prev` is bearish (`open > close`)
517    /// - `self` is bullish (`close > open`)
518    /// - `self.open <= prev.close` (opens at or below prev close)
519    /// - `self.close >= prev.open` (closes at or above prev open)
520    pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
521        let prev_bearish = prev.open.value() > prev.close.value();
522        let self_bullish = self.close.value() > self.open.value();
523        prev_bearish
524            && self_bullish
525            && self.open.value() <= prev.close.value()
526            && self.close.value() >= prev.open.value()
527    }
528
529    /// Returns `true` if this bar is a bearish engulfing of `prev`.
530    ///
531    /// Conditions:
532    /// - `prev` is bullish (`close > open`)
533    /// - `self` is bearish (`open > close`)
534    /// - `self.open >= prev.close` (opens at or above prev close)
535    /// - `self.close <= prev.open` (closes at or below prev open)
536    pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
537        let prev_bullish = prev.close.value() > prev.open.value();
538        let self_bearish = self.open.value() > self.close.value();
539        prev_bullish
540            && self_bearish
541            && self.open.value() >= prev.close.value()
542            && self.close.value() <= prev.open.value()
543    }
544
545}
546
547/// A timeframe for bar aggregation.
548#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
549pub enum Timeframe {
550    /// Aggregation by N seconds.
551    Seconds(u32),
552    /// Aggregation by N minutes.
553    Minutes(u32),
554    /// Aggregation by N hours.
555    Hours(u32),
556    /// Aggregation by N days.
557    Days(u32),
558    /// Aggregation by N weeks (7-day periods).
559    Weeks(u32),
560}
561
562impl Timeframe {
563    /// Returns the timeframe duration in nanoseconds.
564    ///
565    /// # Errors
566    /// Returns [`FinError::InvalidTimeframe`] if the duration is zero.
567    pub fn to_nanos(&self) -> Result<i64, FinError> {
568        let secs: u64 = match self {
569            Timeframe::Seconds(n) => u64::from(*n),
570            Timeframe::Minutes(n) => u64::from(*n) * 60,
571            Timeframe::Hours(n) => u64::from(*n) * 3_600,
572            Timeframe::Days(n) => u64::from(*n) * 86_400,
573            Timeframe::Weeks(n) => u64::from(*n) * 7 * 86_400,
574        };
575        if secs == 0 {
576            return Err(FinError::InvalidTimeframe);
577        }
578        #[allow(clippy::cast_possible_wrap)]
579        Ok((secs * 1_000_000_000) as i64)
580    }
581
582    /// Returns the bucket start timestamp for a given tick timestamp.
583    ///
584    /// # Errors
585    /// Returns [`FinError::InvalidTimeframe`] if the timeframe duration is zero.
586    pub fn bucket_start(&self, ts: NanoTimestamp) -> Result<NanoTimestamp, FinError> {
587        let nanos = self.to_nanos()?;
588        let bucket = (ts.nanos() / nanos) * nanos;
589        Ok(NanoTimestamp::new(bucket))
590    }
591}
592
593/// Aggregates ticks into OHLCV bars according to a fixed timeframe.
594///
595/// `push_tick` returns a `Vec<OhlcvBar>` — normally empty (tick absorbed into current
596/// bar) or a single element (bar completed). When a tick arrives several buckets ahead
597/// of the current one, gap-fill bars are emitted for the empty intervening buckets,
598/// using the last bar's close for OHLC and zero volume.
599pub struct OhlcvAggregator {
600    symbol: Symbol,
601    timeframe: Timeframe,
602    current_bar: Option<OhlcvBar>,
603    current_bucket_start: Option<NanoTimestamp>,
604    /// Close price of the most recently completed bar, used for gap-filling.
605    last_close: Option<Price>,
606    /// Count of fully completed bars emitted (via push_tick or flush).
607    bars_emitted: usize,
608}
609
610impl OhlcvAggregator {
611    /// Constructs a new `OhlcvAggregator`.
612    ///
613    /// # Errors
614    /// Returns [`FinError::InvalidTimeframe`] if the timeframe is zero-duration.
615    pub fn new(symbol: Symbol, timeframe: Timeframe) -> Result<Self, FinError> {
616        timeframe.to_nanos()?;
617        Ok(Self {
618            symbol,
619            timeframe,
620            current_bar: None,
621            current_bucket_start: None,
622            last_close: None,
623            bars_emitted: 0,
624        })
625    }
626
627    /// Processes a single tick, returning all completed bars.
628    ///
629    /// # Returns
630    /// - `Ok(vec![])`: tick was absorbed into the current bar (same bucket)
631    /// - `Ok(vec![bar])`: one bar completed (tick starts the next bucket)
632    /// - `Ok(vec![bar, gap1, gap2, ..., gap_n])`: the completed bar followed by
633    ///   gap-fill bars for any empty intervening buckets
634    ///
635    /// Ticks for a different symbol are silently ignored and return `Ok(vec![])`.
636    ///
637    /// # Errors
638    /// Returns [`FinError::InvalidTimeframe`] if `timeframe.bucket_start` fails.
639    pub fn push_tick(&mut self, tick: &Tick) -> Result<Vec<OhlcvBar>, FinError> {
640        if tick.symbol != self.symbol {
641            return Ok(vec![]);
642        }
643        let bucket = self.timeframe.bucket_start(tick.timestamp)?;
644        match self.current_bucket_start {
645            None => {
646                self.current_bucket_start = Some(bucket);
647                self.current_bar = Some(self.new_bar(tick));
648                Ok(vec![])
649            }
650            Some(current_bucket) if bucket == current_bucket => {
651                self.update_bar(tick);
652                Ok(vec![])
653            }
654            Some(_) => {
655                let completed = self.current_bar.take().expect("current bar must be Some here");
656                self.last_close = Some(completed.close);
657
658                // Emit gap-fill bars for any buckets between the completed bar and the new one.
659                let mut out = vec![completed];
660                let nanos = self.timeframe.to_nanos()?;
661                let prev_bucket = self.current_bucket_start.expect("set above");
662                let mut gap_bucket = NanoTimestamp::new(prev_bucket.nanos() + nanos);
663                while gap_bucket < bucket {
664                    if let Some(close) = self.last_close {
665                        out.push(OhlcvBar {
666                            symbol: self.symbol.clone(),
667                            open: close,
668                            high: close,
669                            low: close,
670                            close,
671                            volume: Quantity::zero(),
672                            ts_open: gap_bucket,
673                            ts_close: gap_bucket,
674                            tick_count: 0,
675                        });
676                    }
677                    gap_bucket = NanoTimestamp::new(gap_bucket.nanos() + nanos);
678                }
679
680                self.bars_emitted += out.len();
681                self.current_bucket_start = Some(bucket);
682                self.current_bar = Some(self.new_bar(tick));
683                Ok(out)
684            }
685        }
686    }
687
688    /// Flushes the current partial bar, returning it if one exists.
689    pub fn flush(&mut self) -> Option<OhlcvBar> {
690        self.current_bucket_start = None;
691        let bar = self.current_bar.take();
692        if let Some(ref b) = bar {
693            self.last_close = Some(b.close);
694            self.bars_emitted += 1;
695        }
696        bar
697    }
698
699    /// Returns the symbol this aggregator is tracking.
700    pub fn symbol(&self) -> &Symbol {
701        &self.symbol
702    }
703
704    /// Returns the timeframe this aggregator is configured for.
705    pub fn timeframe(&self) -> Timeframe {
706        self.timeframe
707    }
708
709    /// Resets the aggregator, discarding any partial bar and last-close state.
710    ///
711    /// After calling `reset()` the aggregator behaves as if freshly constructed.
712    /// Useful for walk-forward backtesting without recreating the aggregator.
713    pub fn reset(&mut self) {
714        self.current_bar = None;
715        self.current_bucket_start = None;
716        self.last_close = None;
717        self.bars_emitted = 0;
718    }
719
720    /// Returns the number of fully completed bars emitted so far (via `push_tick` or `flush`).
721    pub fn bar_count(&self) -> usize {
722        self.bars_emitted
723    }
724
725    /// Returns a reference to the current (incomplete) bar, if any.
726    pub fn current_bar(&self) -> Option<&OhlcvBar> {
727        self.current_bar.as_ref()
728    }
729
730    /// Returns the bucket-start timestamp of the current open bar, or `None` if no bar is open.
731    ///
732    /// This is the lower boundary of the current timeframe bucket, not the timestamp of the
733    /// first tick received in the bar.
734    pub fn current_bar_open_ts(&self) -> Option<NanoTimestamp> {
735        self.current_bucket_start
736    }
737
738    fn new_bar(&self, tick: &Tick) -> OhlcvBar {
739        OhlcvBar {
740            symbol: self.symbol.clone(),
741            open: tick.price,
742            high: tick.price,
743            low: tick.price,
744            close: tick.price,
745            volume: tick.quantity,
746            ts_open: tick.timestamp,
747            ts_close: tick.timestamp,
748            tick_count: 1,
749        }
750    }
751
752    fn update_bar(&mut self, tick: &Tick) {
753        if let Some(ref mut bar) = self.current_bar {
754            if tick.price > bar.high {
755                bar.high = tick.price;
756            }
757            if tick.price < bar.low {
758                bar.low = tick.price;
759            }
760            bar.close = tick.price;
761            bar.volume =
762                Quantity::new(bar.volume.value() + tick.quantity.value()).unwrap_or(bar.volume);
763            bar.ts_close = tick.timestamp;
764            bar.tick_count += 1;
765        }
766    }
767}
768
769/// An ordered collection of completed OHLCV bars.
770pub struct OhlcvSeries {
771    bars: Vec<OhlcvBar>,
772}
773
774impl OhlcvSeries {
775    /// Creates an empty `OhlcvSeries`.
776    pub fn new() -> Self {
777        Self { bars: Vec::new() }
778    }
779
780    /// Constructs an `OhlcvSeries` from a `Vec<OhlcvBar>`, validating each bar.
781    ///
782    /// # Errors
783    /// Returns [`FinError::BarInvariant`] on the first bar that fails validation.
784    pub fn from_bars(bars: Vec<OhlcvBar>) -> Result<Self, FinError> {
785        for bar in &bars {
786            bar.validate()?;
787        }
788        Ok(Self { bars })
789    }
790
791    /// Creates an empty `OhlcvSeries` with a pre-allocated capacity.
792    ///
793    /// Avoids reallocations when the approximate number of bars is known in advance.
794    pub fn with_capacity(capacity: usize) -> Self {
795        Self {
796            bars: Vec::with_capacity(capacity),
797        }
798    }
799
800    /// Appends a bar to the series after validating its invariants.
801    ///
802    /// # Errors
803    /// Returns [`FinError::BarInvariant`] if `bar.validate()` fails.
804    pub fn push(&mut self, bar: OhlcvBar) -> Result<(), FinError> {
805        bar.validate()?;
806        self.bars.push(bar);
807        Ok(())
808    }
809
810    /// Returns the number of bars in the series.
811    pub fn len(&self) -> usize {
812        self.bars.len()
813    }
814
815    /// Returns `true` if there are no bars.
816    pub fn is_empty(&self) -> bool {
817        self.bars.is_empty()
818    }
819
820    /// Removes all bars from the series, retaining allocated capacity.
821    pub fn clear(&mut self) {
822        self.bars.clear();
823    }
824
825    /// Retains only the bars for which `predicate` returns `true`, removing the rest in-place.
826    ///
827    /// Order is preserved. Useful for filtering out gap-fill bars or bars outside a time range.
828    pub fn retain(&mut self, mut predicate: impl FnMut(&OhlcvBar) -> bool) {
829        self.bars.retain(|b| predicate(b));
830    }
831
832    /// Returns the bar at `index`, or `None` if out of bounds.
833    pub fn get(&self, index: usize) -> Option<&OhlcvBar> {
834        self.bars.get(index)
835    }
836
837    /// Returns the oldest (first inserted) bar, or `None` if empty.
838    pub fn first(&self) -> Option<&OhlcvBar> {
839        self.bars.first()
840    }
841
842    /// Returns the most recent bar, or `None` if empty.
843    pub fn last(&self) -> Option<&OhlcvBar> {
844        self.bars.last()
845    }
846
847    /// Returns the bar `n` positions from the end (0 = most recent), or `None` if out of bounds.
848    ///
849    /// `n_bars_ago(0)` is equivalent to `last()`. Useful in signal logic where
850    /// you need to compare the current bar against bars 1, 2, or 3 periods back.
851    pub fn n_bars_ago(&self, n: usize) -> Option<&OhlcvBar> {
852        let len = self.bars.len();
853        if n >= len {
854            return None;
855        }
856        self.bars.get(len - 1 - n)
857    }
858
859    /// Returns the last `n` bars as a slice (fewer if series has fewer than `n`).
860    pub fn window(&self, n: usize) -> &[OhlcvBar] {
861        let len = self.bars.len();
862        if n >= len {
863            &self.bars
864        } else {
865            &self.bars[len - n..]
866        }
867    }
868
869    /// Returns an iterator over the bars in insertion order.
870    pub fn iter(&self) -> std::slice::Iter<'_, OhlcvBar> {
871        self.bars.iter()
872    }
873
874    /// Returns the count of consecutive bullish bars at the tail of the series.
875    ///
876    /// A bar is bullish when `close >= open`. Returns 0 for an empty series.
877    pub fn consecutive_ups(&self) -> usize {
878        self.bars
879            .iter()
880            .rev()
881            .take_while(|b| b.is_bullish())
882            .count()
883    }
884
885    /// Returns the count of consecutive bearish bars at the tail of the series.
886    ///
887    /// A bar is bearish when `close < open`. Returns 0 for an empty series.
888    pub fn consecutive_downs(&self) -> usize {
889        self.bars
890            .iter()
891            .rev()
892            .take_while(|b| b.is_bearish())
893            .count()
894    }
895
896    /// Returns a `Vec` of open prices in series order.
897    pub fn opens(&self) -> Vec<Decimal> {
898        self.bars.iter().map(|b| b.open.value()).collect()
899    }
900
901    /// Returns a `Vec` of high prices in series order.
902    pub fn highs(&self) -> Vec<Decimal> {
903        self.bars.iter().map(|b| b.high.value()).collect()
904    }
905
906    /// Returns a `Vec` of low prices in series order.
907    pub fn lows(&self) -> Vec<Decimal> {
908        self.bars.iter().map(|b| b.low.value()).collect()
909    }
910
911    /// Returns a `Vec` of close prices in series order.
912    pub fn closes(&self) -> Vec<Decimal> {
913        self.bars.iter().map(|b| b.close.value()).collect()
914    }
915
916    /// Returns a `Vec` of volumes in series order.
917    pub fn volumes(&self) -> Vec<Decimal> {
918        self.bars.iter().map(|b| b.volume.value()).collect()
919    }
920
921    /// Returns a `Vec` of typical prices `(high + low + close) / 3` in series order.
922    pub fn typical_prices(&self) -> Vec<Decimal> {
923        self.bars.iter().map(|b| b.typical_price()).collect()
924    }
925
926    /// Returns a direct slice of all bars in insertion order.
927    pub fn bars(&self) -> &[OhlcvBar] {
928        &self.bars
929    }
930
931    /// Returns the maximum high price across all bars, or `None` if empty.
932    pub fn max_high(&self) -> Option<Decimal> {
933        self.bars.iter().map(|b| b.high.value()).reduce(Decimal::max)
934    }
935
936    /// Returns the minimum low price across all bars, or `None` if empty.
937    pub fn min_low(&self) -> Option<Decimal> {
938        self.bars.iter().map(|b| b.low.value()).reduce(Decimal::min)
939    }
940
941    /// Returns the highest high price among the last `n` bars, or `None` if empty.
942    ///
943    /// If `n > self.len()`, considers all bars.
944    pub fn highest_high(&self, n: usize) -> Option<Decimal> {
945        let start = self.bars.len().saturating_sub(n);
946        self.bars[start..].iter().map(|b| b.high.value()).reduce(Decimal::max)
947    }
948
949    /// Returns the lowest low price among the last `n` bars, or `None` if empty.
950    ///
951    /// If `n > self.len()`, considers all bars.
952    pub fn lowest_low(&self, n: usize) -> Option<Decimal> {
953        let start = self.bars.len().saturating_sub(n);
954        self.bars[start..].iter().map(|b| b.low.value()).reduce(Decimal::min)
955    }
956
957    /// Returns the volume-weighted average price (VWAP) across all bars, or `None` if empty
958    /// or if total volume is zero.
959    ///
960    /// `VWAP = Σ(typical_price × volume) / Σ(volume)`
961    pub fn vwap(&self) -> Option<Decimal> {
962        if self.bars.is_empty() {
963            return None;
964        }
965        let total_vol: Decimal = self.bars.iter().map(|b| b.volume.value()).sum();
966        if total_vol == Decimal::ZERO {
967            return None;
968        }
969        let weighted_sum: Decimal = self
970            .bars
971            .iter()
972            .map(|b| b.typical_price() * b.volume.value())
973            .sum();
974        Some(weighted_sum / total_vol)
975    }
976
977    /// Returns the total traded volume across all bars in the series.
978    pub fn sum_volume(&self) -> Decimal {
979        self.bars.iter().map(|b| b.volume.value()).sum()
980    }
981
982    /// Returns the average volume over the last `n` bars, or `None` if fewer than `n` bars exist.
983    pub fn avg_volume(&self, n: usize) -> Option<Decimal> {
984        if n == 0 || self.bars.len() < n {
985            return None;
986        }
987        let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.volume.value()).sum();
988        #[allow(clippy::cast_possible_truncation)]
989        Some(sum / Decimal::from(n as u32))
990    }
991
992    /// Returns `highest_high(n) - lowest_low(n)` over the last `n` bars, or `None` if
993    /// fewer than `n` bars exist or `n == 0`.
994    pub fn price_range(&self, n: usize) -> Option<Decimal> {
995        if n == 0 || self.bars.len() < n {
996            return None;
997        }
998        let hh = self.highest_high(n)?;
999        let ll = self.lowest_low(n)?;
1000        Some(hh - ll)
1001    }
1002
1003    /// Returns the average Close Location Value over the last `n` bars, or `None` if
1004    /// fewer than `n` bars exist or `n == 0`.
1005    ///
1006    /// `CLV = ((close - low) - (high - close)) / (high - low)`
1007    ///
1008    /// Each bar's CLV is in `[-1, 1]`; bars with zero range contribute `0`.
1009    pub fn close_location_value(&self, n: usize) -> Option<Decimal> {
1010        if n == 0 || self.bars.len() < n {
1011            return None;
1012        }
1013        let start = self.bars.len() - n;
1014        let sum: Decimal = self.bars[start..].iter().map(|b| {
1015            let h = b.high.value();
1016            let l = b.low.value();
1017            let c = b.close.value();
1018            let range = h - l;
1019            if range == Decimal::ZERO { Decimal::ZERO } else { ((c - l) - (h - c)) / range }
1020        }).sum();
1021        #[allow(clippy::cast_possible_truncation)]
1022        Some(sum / Decimal::from(n as u32))
1023    }
1024
1025    /// Returns the average dollar volume over the last `n` bars.
1026    ///
1027    /// `avg_dollar_volume = Σ(typical_price × volume) / n` for the last `n` bars.
1028    ///
1029    /// Returns `None` when `n == 0` or the series has fewer than `n` bars.
1030    pub fn avg_dollar_volume(&self, n: usize) -> Option<Decimal> {
1031        if n == 0 || self.bars.len() < n {
1032            return None;
1033        }
1034        let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.dollar_volume()).sum();
1035        Some(sum / Decimal::from(n as u64))
1036    }
1037
1038    /// Returns a sub-slice `bars[from..to]`, or `None` if the range is out of bounds.
1039    pub fn slice(&self, from: usize, to: usize) -> Option<&[OhlcvBar]> {
1040        if from > to || to > self.bars.len() {
1041            return None;
1042        }
1043        Some(&self.bars[from..to])
1044    }
1045
1046    /// Retains only the last `n` bars, dropping older ones.
1047    ///
1048    /// If `n >= self.len()`, this is a no-op.
1049    pub fn truncate(&mut self, n: usize) {
1050        let len = self.bars.len();
1051        if n < len {
1052            self.bars.drain(0..len - n);
1053        }
1054    }
1055
1056    /// Pushes multiple bars into the series, validating each one.
1057    ///
1058    /// Stops and returns the first error encountered; bars added before the error remain.
1059    ///
1060    /// # Errors
1061    /// Returns [`FinError::BarInvariant`] if any bar fails OHLCV invariant checks.
1062    pub fn extend(&mut self, bars: impl IntoIterator<Item = OhlcvBar>) -> Result<(), FinError> {
1063        for bar in bars {
1064            self.push(bar)?;
1065        }
1066        Ok(())
1067    }
1068
1069    /// Appends all bars from `other` into this series, validating each one.
1070    ///
1071    /// # Errors
1072    /// Returns [`FinError::BarInvariant`] if any bar from `other` fails validation.
1073    pub fn extend_from_series(&mut self, other: &OhlcvSeries) -> Result<(), FinError> {
1074        for bar in &other.bars {
1075            self.push(bar.clone())?;
1076        }
1077        Ok(())
1078    }
1079
1080    /// Converts the series into a `Vec<BarInput>` for batch signal processing.
1081    ///
1082    /// Allows feeding an entire historical series into indicators without manually
1083    /// iterating and converting each bar.
1084    pub fn to_bar_inputs(&self) -> Vec<crate::signals::BarInput> {
1085        self.bars
1086            .iter()
1087            .map(crate::signals::BarInput::from)
1088            .collect()
1089    }
1090
1091    /// Feeds every bar in the series into `signal` and collects the results.
1092    ///
1093    /// Errors from individual bars are propagated immediately (fail-fast).
1094    /// Use this for batch back-testing where you want to apply one signal to
1095    /// an entire historical dataset in one call.
1096    ///
1097    /// # Errors
1098    /// Returns [`FinError`] if any call to `signal.update_bar()` fails.
1099    pub fn apply_signal(
1100        &self,
1101        signal: &mut dyn crate::signals::Signal,
1102    ) -> Result<Vec<crate::signals::SignalValue>, FinError> {
1103        self.bars.iter().map(|b| signal.update_bar(b)).collect()
1104    }
1105
1106    /// Returns close-to-close percentage returns: `(close[i] - close[i-1]) / close[i-1]`.
1107    ///
1108    /// Returns an empty `Vec` when the series has fewer than 2 bars.
1109    /// Skips any bar where `close[i-1]` is zero to avoid division by zero.
1110    pub fn returns(&self) -> Vec<Decimal> {
1111        if self.bars.len() < 2 {
1112            return Vec::new();
1113        }
1114        self.bars
1115            .windows(2)
1116            .filter_map(|w| {
1117                let prev = w[0].close.value();
1118                if prev.is_zero() {
1119                    return None;
1120                }
1121                Some((w[1].close.value() - prev) / prev)
1122            })
1123            .collect()
1124    }
1125
1126    /// Returns the highest close price among the last `n` bars, or `None` if empty.
1127    ///
1128    /// If `n > self.len()`, considers all bars.
1129    pub fn highest_close(&self, n: usize) -> Option<Decimal> {
1130        let start = self.bars.len().saturating_sub(n);
1131        self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::max)
1132    }
1133
1134    /// Returns the lowest close price among the last `n` bars, or `None` if empty.
1135    ///
1136    /// If `n > self.len()`, considers all bars.
1137    pub fn lowest_close(&self, n: usize) -> Option<Decimal> {
1138        let start = self.bars.len().saturating_sub(n);
1139        self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::min)
1140    }
1141
1142    /// Returns the mean (average) close price of the last `n` bars, or `None` if empty.
1143    ///
1144    /// If `n > self.len()`, all bars are used.
1145    pub fn mean_close(&self, n: usize) -> Option<Decimal> {
1146        let start = self.bars.len().saturating_sub(n);
1147        let slice = &self.bars[start..];
1148        if slice.is_empty() {
1149            return None;
1150        }
1151        let sum: Decimal = slice.iter().map(|b| b.close.value()).sum();
1152        Some(sum / Decimal::from(slice.len() as u64))
1153    }
1154
1155    /// Returns the population standard deviation of close prices over the last `n` bars.
1156    ///
1157    /// Returns `None` if fewer than 2 bars are in the window.
1158    /// If `n > self.len()`, all bars are used.
1159    pub fn std_dev(&self, n: usize) -> Option<Decimal> {
1160        let start = self.bars.len().saturating_sub(n);
1161        let slice = &self.bars[start..];
1162        if slice.len() < 2 {
1163            return None;
1164        }
1165        let n_dec = Decimal::from(slice.len() as u64);
1166        let mean: Decimal = slice.iter().map(|b| b.close.value()).sum::<Decimal>() / n_dec;
1167        let variance: Decimal = slice
1168            .iter()
1169            .map(|b| { let d = b.close.value() - mean; d * d })
1170            .sum::<Decimal>()
1171            / n_dec;
1172        decimal_sqrt(variance).ok()
1173    }
1174
1175    /// Returns the median close price of the last `n` bars, or `None` if empty.
1176    ///
1177    /// If `n > self.len()`, all bars are used. For an even number of bars the
1178    /// median is the average of the two middle values.
1179    pub fn median_close(&self, n: usize) -> Option<Decimal> {
1180        let start = self.bars.len().saturating_sub(n);
1181        let mut closes: Vec<Decimal> =
1182            self.bars[start..].iter().map(|b| b.close.value()).collect();
1183        if closes.is_empty() {
1184            return None;
1185        }
1186        closes.sort();
1187        let mid = closes.len() / 2;
1188        if closes.len() % 2 == 1 {
1189            Some(closes[mid])
1190        } else {
1191            Some((closes[mid - 1] + closes[mid]) / Decimal::TWO)
1192        }
1193    }
1194
1195    /// Returns what percentile `value` is among the last `n` close prices (0–100).
1196    ///
1197    /// Counts the fraction of bars in the window whose close is strictly less than `value`,
1198    /// then multiplies by 100. Returns `None` if the window is empty.
1199    /// If `n > self.len()`, all bars are used.
1200    pub fn percentile_rank(&self, value: Decimal, n: usize) -> Option<Decimal> {
1201        let start = self.bars.len().saturating_sub(n);
1202        let slice = &self.bars[start..];
1203        if slice.is_empty() {
1204            return None;
1205        }
1206        let below = slice.iter().filter(|b| b.close.value() < value).count();
1207        Some(Decimal::from(below as u64) / Decimal::from(slice.len() as u64) * Decimal::ONE_HUNDRED)
1208    }
1209
1210    /// Computes Pearson correlation between this series' close prices and `other`'s.
1211    ///
1212    /// Uses only the overlapping suffix: `min(self.len(), other.len())` bars from the end.
1213    /// Returns `None` when fewer than 2 overlapping bars exist or standard deviation is zero.
1214    pub fn correlation(&self, other: &OhlcvSeries) -> Option<Decimal> {
1215        let n = self.bars.len().min(other.bars.len());
1216        if n < 2 {
1217            return None;
1218        }
1219        let xs: Vec<Decimal> = self.bars[self.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1220        let ys: Vec<Decimal> = other.bars[other.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1221        let n_dec = Decimal::from(n);
1222        let mean_x: Decimal = xs.iter().copied().sum::<Decimal>() / n_dec;
1223        let mean_y: Decimal = ys.iter().copied().sum::<Decimal>() / n_dec;
1224        let cov: Decimal = xs.iter().zip(ys.iter())
1225            .map(|(x, y)| (*x - mean_x) * (*y - mean_y))
1226            .sum::<Decimal>() / n_dec;
1227        let var_x: Decimal = xs.iter().map(|x| (*x - mean_x) * (*x - mean_x)).sum::<Decimal>() / n_dec;
1228        let var_y: Decimal = ys.iter().map(|y| (*y - mean_y) * (*y - mean_y)).sum::<Decimal>() / n_dec;
1229        if var_x.is_zero() || var_y.is_zero() {
1230            return None;
1231        }
1232        // sqrt via Newton-Raphson (same approach as BollingerB)
1233        let std_x = decimal_sqrt(var_x).ok()?;
1234        let std_y = decimal_sqrt(var_y).ok()?;
1235        Some(cov / (std_x * std_y))
1236    }
1237
1238    /// Returns rolling SMA of close prices with the given `period`.
1239    ///
1240    /// The output `Vec` has the same length as the series. Positions where fewer than
1241    /// `period` bars have been seen contain `None`; the rest contain `Some(sma)`.
1242    pub fn rolling_sma(&self, period: usize) -> Vec<Option<Decimal>> {
1243        if period == 0 {
1244            return self.bars.iter().map(|_| None).collect();
1245        }
1246        let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1247        closes
1248            .windows(period)
1249            .enumerate()
1250            .fold(vec![None; closes.len()], |mut acc, (i, window)| {
1251                let sum: Decimal = window.iter().copied().sum();
1252                acc[i + period - 1] = Some(sum / Decimal::from(period as u64));
1253                acc
1254            })
1255    }
1256
1257    /// Returns rolling z-score of close prices using a window of `period` bars.
1258    ///
1259    /// `z = (close - SMA) / stddev`. Positions with insufficient data or zero stddev
1260    /// yield `None`.
1261    pub fn zscore(&self, period: usize) -> Vec<Option<Decimal>> {
1262        if period < 2 {
1263            return self.bars.iter().map(|_| None).collect();
1264        }
1265        let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1266        let n = closes.len();
1267        let mut result = vec![None; n];
1268        let period_dec = Decimal::from(period as u64);
1269        for i in (period - 1)..n {
1270            let window = &closes[(i + 1 - period)..=i];
1271            let mean: Decimal = window.iter().copied().sum::<Decimal>() / period_dec;
1272            let variance: Decimal = window
1273                .iter()
1274                .map(|x| (*x - mean) * (*x - mean))
1275                .sum::<Decimal>()
1276                / period_dec;
1277            if let Ok(std_dev) = decimal_sqrt(variance) {
1278                if !std_dev.is_zero() {
1279                    result[i] = Some((closes[i] - mean) / std_dev);
1280                }
1281            }
1282        }
1283        result
1284    }
1285
1286    /// Returns log returns: `ln(close[i] / close[i-1])` for each consecutive bar pair.
1287    ///
1288    /// Returns an empty `Vec` when fewer than 2 bars are present.
1289    /// Bars where `close[i-1]` is zero are skipped (yielding no entry at that position).
1290    ///
1291    /// Uses `f64` arithmetic since `rust_decimal` does not provide a logarithm function.
1292    #[allow(clippy::cast_precision_loss)]
1293    pub fn log_returns(&self) -> Vec<f64> {
1294        if self.bars.len() < 2 {
1295            return Vec::new();
1296        }
1297        self.bars
1298            .windows(2)
1299            .filter_map(|w| {
1300                let prev = w[0].close.value();
1301                if prev.is_zero() {
1302                    return None;
1303                }
1304                let ratio = w[1].close.value().checked_div(prev)?;
1305                use rust_decimal::prelude::ToPrimitive;
1306                let ratio_f64 = ratio.to_f64()?;
1307                if ratio_f64 > 0.0 {
1308                    Some(ratio_f64.ln())
1309                } else {
1310                    None
1311                }
1312            })
1313            .collect()
1314    }
1315
1316    /// Returns compounded cumulative returns relative to the first bar's close.
1317    ///
1318    /// `cumret[i] = (close[i] / close[0]) - 1`
1319    ///
1320    /// Returns an empty `Vec` when the series is empty or the first close is zero.
1321    pub fn cumulative_returns(&self) -> Vec<Decimal> {
1322        let first = match self.bars.first() {
1323            Some(b) => b.close.value(),
1324            None => return Vec::new(),
1325        };
1326        if first.is_zero() {
1327            return Vec::new();
1328        }
1329        self.bars
1330            .iter()
1331            .map(|b| b.close.value() / first - Decimal::ONE)
1332            .collect()
1333    }
1334
1335    /// Resamples the series by merging every `n` consecutive bars into one.
1336    ///
1337    /// Trailing bars that don't fill a full group of `n` are merged into the last output bar.
1338    /// Returns an empty `Vec` when `n == 0` or the series is empty.
1339    ///
1340    /// # Errors
1341    /// Returns [`FinError::BarInvariant`] if any merged bar fails invariant checks.
1342    pub fn resample(&self, n: usize) -> Result<Vec<OhlcvBar>, FinError> {
1343        if n == 0 || self.bars.is_empty() {
1344            return Ok(Vec::new());
1345        }
1346        let mut result = Vec::new();
1347        let mut chunks = self.bars.chunks(n);
1348        for chunk in &mut chunks {
1349            let mut merged = chunk[0].clone();
1350            for b in &chunk[1..] {
1351                merged = merged.merge(b)?;
1352            }
1353            result.push(merged);
1354        }
1355        Ok(result)
1356    }
1357
1358    /// Returns the maximum peak-to-trough drawdown on close prices.
1359    ///
1360    /// Iterates through close prices, tracking the running peak and computing
1361    /// the largest percentage decline from any peak to any subsequent trough.
1362    ///
1363    /// Returns `None` when the series is empty. Returns `0` when no decline occurs.
1364    pub fn max_drawdown(&self) -> Option<Decimal> {
1365        let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1366        if closes.is_empty() {
1367            return None;
1368        }
1369        let mut peak = closes[0];
1370        let mut max_dd = Decimal::ZERO;
1371        for &c in &closes[1..] {
1372            if c > peak {
1373                peak = c;
1374            } else if !peak.is_zero() {
1375                let dd = (peak - c) / peak;
1376                if dd > max_dd {
1377                    max_dd = dd;
1378                }
1379            }
1380        }
1381        Some(max_dd)
1382    }
1383
1384    /// Computes the annualized Sharpe ratio from log returns.
1385    ///
1386    /// `Sharpe = (mean_log_return - risk_free_rate_per_bar) / stddev_log_return * sqrt(bars_per_year)`
1387    ///
1388    /// `bars_per_year` defaults to 252 (US equity trading days). Pass `0.0` for `risk_free_rate`
1389    /// when working with intraday or crypto series where a risk-free benchmark is not applicable.
1390    ///
1391    /// Returns `None` when fewer than 2 bars exist or if log-return standard deviation is zero.
1392    pub fn sharpe_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1393        let lr = self.log_returns();
1394        if lr.len() < 2 {
1395            return None;
1396        }
1397        let n = lr.len() as f64;
1398        let mean = lr.iter().sum::<f64>() / n;
1399        let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
1400        let std_dev = variance.sqrt();
1401        if std_dev == 0.0 {
1402            return None;
1403        }
1404        let bars_per_year = if bars_per_year <= 0.0 { 252.0 } else { bars_per_year };
1405        Some((mean - risk_free_rate) / std_dev * bars_per_year.sqrt())
1406    }
1407
1408    /// Returns the percentage price change from `n` bars ago to the latest close.
1409    ///
1410    /// `(last_close - close[len-1-n]) / close[len-1-n] * 100`
1411    ///
1412    /// Returns `None` when the series has fewer than `n + 1` bars or the reference
1413    /// close is zero.
1414    pub fn price_change_pct(&self, n: usize) -> Option<Decimal> {
1415        let len = self.bars.len();
1416        if len < n + 1 {
1417            return None;
1418        }
1419        let ref_close = self.bars[len - 1 - n].close.value();
1420        if ref_close.is_zero() {
1421            return None;
1422        }
1423        let last_close = self.bars[len - 1].close.value();
1424        Some((last_close - ref_close) / ref_close * Decimal::ONE_HUNDRED)
1425    }
1426
1427    /// Returns the count of bullish bars in the last `n` bars.
1428    ///
1429    /// A bar is bullish when `close >= open`. If `n` exceeds the series length,
1430    /// all bars are counted.
1431    pub fn count_bullish(&self, n: usize) -> usize {
1432        let start = self.bars.len().saturating_sub(n);
1433        self.bars[start..].iter().filter(|b| b.is_bullish()).count()
1434    }
1435
1436    /// Returns the count of bearish bars in the last `n` bars.
1437    ///
1438    /// A bar is bearish when `close < open`. If `n` exceeds the series length,
1439    /// all bars are counted.
1440    pub fn count_bearish(&self, n: usize) -> usize {
1441        let start = self.bars.len().saturating_sub(n);
1442        self.bars[start..].iter().filter(|b| b.is_bearish()).count()
1443    }
1444
1445    /// Returns the count of inside bars in the entire series.
1446    ///
1447    /// An inside bar has a lower high and higher low than the previous bar,
1448    /// indicating consolidation. The first bar is never counted (no prior bar).
1449    pub fn count_inside_bars(&self) -> usize {
1450        self.bars
1451            .windows(2)
1452            .filter(|w| w[1].is_inside_bar(&w[0]))
1453            .count()
1454    }
1455
1456    /// Returns the count of outside bars in the entire series.
1457    ///
1458    /// An outside bar completely contains the prior bar's range.
1459    /// The first bar is never counted (no prior bar).
1460    pub fn count_outside_bars(&self) -> usize {
1461        self.bars
1462            .windows(2)
1463            .filter(|w| w[1].is_outside_bar(&w[0]))
1464            .count()
1465    }
1466
1467    /// Returns the indices of pivot highs — bars whose high is strictly greater than
1468    /// the `n` bars on each side.
1469    ///
1470    /// A pivot high at index `i` satisfies:
1471    /// `bars[i].high > bars[i-j].high` and `bars[i].high > bars[i+j].high` for all `j` in `1..=n`.
1472    ///
1473    /// Bars within `n` of either end of the series are excluded.
1474    pub fn pivot_highs(&self, n: usize) -> Vec<usize> {
1475        if n == 0 || self.bars.len() < 2 * n + 1 {
1476            return vec![];
1477        }
1478        let mut pivots = Vec::new();
1479        for i in n..self.bars.len() - n {
1480            let h = self.bars[i].high.value();
1481            let is_pivot = (1..=n).all(|j| {
1482                h > self.bars[i - j].high.value() && h > self.bars[i + j].high.value()
1483            });
1484            if is_pivot {
1485                pivots.push(i);
1486            }
1487        }
1488        pivots
1489    }
1490
1491    /// Returns the indices of pivot lows — bars whose low is strictly less than
1492    /// the `n` bars on each side.
1493    ///
1494    /// A pivot low at index `i` satisfies:
1495    /// `bars[i].low < bars[i-j].low` and `bars[i].low < bars[i+j].low` for all `j` in `1..=n`.
1496    ///
1497    /// Bars within `n` of either end of the series are excluded.
1498    pub fn pivot_lows(&self, n: usize) -> Vec<usize> {
1499        if n == 0 || self.bars.len() < 2 * n + 1 {
1500            return vec![];
1501        }
1502        let mut pivots = Vec::new();
1503        for i in n..self.bars.len() - n {
1504            let l = self.bars[i].low.value();
1505            let is_pivot = (1..=n).all(|j| {
1506                l < self.bars[i - j].low.value() && l < self.bars[i + j].low.value()
1507            });
1508            if is_pivot {
1509                pivots.push(i);
1510            }
1511        }
1512        pivots
1513    }
1514
1515    /// Returns the count of bars (in the last `n`) where `close > SMA(close, period)`.
1516    ///
1517    /// If `n` exceeds the series length, all eligible bars are considered.
1518    /// Returns `0` if there are fewer than `period` bars (SMA cannot be computed).
1519    #[allow(clippy::cast_possible_truncation)]
1520    pub fn above_sma(&self, period: usize, n: usize) -> usize {
1521        if self.bars.len() < period || period == 0 {
1522            return 0;
1523        }
1524        let start = self.bars.len().saturating_sub(n);
1525        let window_start = start.saturating_sub(period - 1);
1526        let mut count = 0usize;
1527        for i in start..self.bars.len() {
1528            if i + 1 < period {
1529                continue;
1530            }
1531            let sma_start = i + 1 - period;
1532            let sma: Decimal = self.bars[sma_start..=i]
1533                .iter()
1534                .map(|b| b.close.value())
1535                .sum::<Decimal>()
1536                / Decimal::from(period as u32);
1537            if self.bars[i].close.value() > sma {
1538                count += 1;
1539            }
1540        }
1541        let _ = window_start; // used indirectly via sma_start logic
1542        count
1543    }
1544
1545    /// Returns the count of bars (in the last `n`) where `close < SMA(close, period)`.
1546    ///
1547    /// Mirrors [`OhlcvSeries::above_sma`] for the bearish side.
1548    #[allow(clippy::cast_possible_truncation)]
1549    pub fn below_sma(&self, period: usize, n: usize) -> usize {
1550        if self.bars.len() < period || period == 0 {
1551            return 0;
1552        }
1553        let start = self.bars.len().saturating_sub(n);
1554        let mut count = 0usize;
1555        for i in start..self.bars.len() {
1556            if i + 1 < period {
1557                continue;
1558            }
1559            let sma_start = i + 1 - period;
1560            let sma: Decimal = self.bars[sma_start..=i]
1561                .iter()
1562                .map(|b| b.close.value())
1563                .sum::<Decimal>()
1564                / Decimal::from(period as u32);
1565            if self.bars[i].close.value() < sma {
1566                count += 1;
1567            }
1568        }
1569        count
1570    }
1571
1572    /// Returns `true` if the latest close is above the EMA(period) of closes.
1573    ///
1574    /// Returns `false` if there are fewer than `period` bars or `period == 0`.
1575    #[allow(clippy::cast_possible_truncation)]
1576    pub fn above_ema(&self, period: usize) -> bool {
1577        if period == 0 || self.bars.len() < period {
1578            return false;
1579        }
1580        let k = Decimal::TWO / Decimal::from((period + 1) as u32);
1581        let seed: Decimal = self.bars[..period].iter().map(|b| b.close.value()).sum::<Decimal>()
1582            / Decimal::from(period as u32);
1583        let mut ema = seed;
1584        for bar in &self.bars[period..] {
1585            ema = bar.close.value() * k + ema * (Decimal::ONE - k);
1586        }
1587        self.bars.last().map_or(false, |b| b.close.value() > ema)
1588    }
1589
1590    /// Returns the count of bullish engulfing patterns in the last `n` bars.
1591    ///
1592    /// A bullish engulfing occurs when a bar's body fully engulfs the previous bar's
1593    /// body and the bar closes higher than it opens.
1594    pub fn bullish_engulfing_count(&self, n: usize) -> usize {
1595        if self.bars.len() < 2 {
1596            return 0;
1597        }
1598        let start = self.bars.len().saturating_sub(n).max(1);
1599        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1600            let prev = &self.bars[start + i - 1];
1601            bar.is_bullish_engulfing(prev)
1602        }).count()
1603    }
1604
1605    /// Returns the ratio of the current bar's range to the average range over the last `n` bars.
1606    ///
1607    /// Values > 1 indicate range expansion; < 1 indicate contraction.
1608    /// Returns `None` if fewer than `n` bars exist, `n == 0`, or average range is zero.
1609    pub fn range_expansion(&self, n: usize) -> Option<Decimal> {
1610        let last = self.bars.last()?;
1611        if n == 0 || self.bars.len() < n {
1612            return None;
1613        }
1614        let start = self.bars.len() - n;
1615        let avg_range: Decimal = self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>();
1616        #[allow(clippy::cast_possible_truncation)]
1617        let avg_range = avg_range / Decimal::from(n as u32);
1618        if avg_range == Decimal::ZERO {
1619            return None;
1620        }
1621        Some(last.range() / avg_range)
1622    }
1623
1624    /// Returns the count of bearish engulfing patterns in the last `n` bars.
1625    ///
1626    /// A bearish engulfing bar opens above the previous close and closes below the previous open.
1627    pub fn bearish_engulfing_count(&self, n: usize) -> usize {
1628        if self.bars.len() < 2 {
1629            return 0;
1630        }
1631        let start = self.bars.len().saturating_sub(n).max(1);
1632        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1633            let prev = &self.bars[start + i - 1];
1634            // bearish: prev bullish, current opens above prev close, closes below prev open
1635            let p_o = prev.open.value();
1636            let p_c = prev.close.value();
1637            let s_o = bar.open.value();
1638            let s_c = bar.close.value();
1639            p_c > p_o && s_c < s_o && s_o >= p_c && s_c <= p_o
1640        }).count()
1641    }
1642
1643    /// Returns a trend-strength ratio over the last `n` bars.
1644    ///
1645    /// `trend_strength = |close[last] - close[first]| / Σ|close[i] - close[i-1]|`
1646    ///
1647    /// Values near 1 indicate a clean directional trend; near 0 indicate chop.
1648    /// Returns `None` if fewer than 2 bars exist in the window or total movement is zero.
1649    pub fn trend_strength(&self, n: usize) -> Option<Decimal> {
1650        if n < 2 || self.bars.len() < n {
1651            return None;
1652        }
1653        let start = self.bars.len() - n;
1654        let window = &self.bars[start..];
1655        let net = (window.last()?.close.value() - window[0].close.value()).abs();
1656        let total: Decimal = window.windows(2)
1657            .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1658            .sum();
1659        if total == Decimal::ZERO {
1660            return None;
1661        }
1662        Some(net / total)
1663    }
1664
1665    /// Returns the average volume over the last `n` bars, or `None` if the series is empty.
1666    ///
1667    /// Returns the average `(close - open) / open` per bar over the last `n` bars.
1668    ///
1669    /// Returns `None` if fewer than `n` bars exist, `n == 0`, or any open is zero.
1670    pub fn open_to_close_return(&self, n: usize) -> Option<Decimal> {
1671        if n == 0 || self.bars.len() < n {
1672            return None;
1673        }
1674        let start = self.bars.len() - n;
1675        let mut sum = Decimal::ZERO;
1676        for b in &self.bars[start..] {
1677            let o = b.open.value();
1678            if o == Decimal::ZERO {
1679                return None;
1680            }
1681            sum += (b.close.value() - o) / o;
1682        }
1683        #[allow(clippy::cast_possible_truncation)]
1684        Some(sum / Decimal::from(n as u32))
1685    }
1686
1687    /// Returns the count of bars in the last `n` where `open > prev_close` (gap up).
1688    pub fn gap_up_count(&self, n: usize) -> usize {
1689        if self.bars.len() < 2 {
1690            return 0;
1691        }
1692        let start = self.bars.len().saturating_sub(n).max(1);
1693        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1694            bar.open.value() > self.bars[start + i - 1].close.value()
1695        }).count()
1696    }
1697
1698    /// Returns the count of bars in the last `n` where `open < prev_close` (gap down).
1699    pub fn gap_down_count(&self, n: usize) -> usize {
1700        if self.bars.len() < 2 {
1701            return 0;
1702        }
1703        let start = self.bars.len().saturating_sub(n).max(1);
1704        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1705            bar.open.value() < self.bars[start + i - 1].close.value()
1706        }).count()
1707    }
1708
1709    /// Returns the average overnight gap percentage over the last `n` bars.
1710    ///
1711    /// `overnight_gap_pct = (open - prev_close) / prev_close × 100`
1712    ///
1713    /// Returns `None` if fewer than 2 bars in window, `n == 0`, or any prev_close is zero.
1714    pub fn overnight_gap_pct(&self, n: usize) -> Option<Decimal> {
1715        if n == 0 || self.bars.len() < 2 {
1716            return None;
1717        }
1718        let start = self.bars.len().saturating_sub(n).max(1);
1719        let window_len = self.bars.len() - start;
1720        if window_len == 0 {
1721            return None;
1722        }
1723        let mut sum = Decimal::ZERO;
1724        for i in start..self.bars.len() {
1725            let pc = self.bars[i - 1].close.value();
1726            if pc == Decimal::ZERO {
1727                return None;
1728            }
1729            sum += (self.bars[i].open.value() - pc) / pc * Decimal::ONE_HUNDRED;
1730        }
1731        #[allow(clippy::cast_possible_truncation)]
1732        Some(sum / Decimal::from(window_len as u32))
1733    }
1734
1735    /// Returns the percentile rank (0–100) of the latest close within the last `n` closes.
1736    ///
1737    /// `close_rank = count(closes < current) / (n-1) × 100`
1738    ///
1739    /// Returns `None` if fewer than 2 bars in window or `n == 0`.
1740    pub fn close_rank(&self, n: usize) -> Option<Decimal> {
1741        if n < 2 || self.bars.len() < n {
1742            return None;
1743        }
1744        let start = self.bars.len() - n;
1745        let current = self.bars.last()?.close.value();
1746        let below = self.bars[start..self.bars.len() - 1]
1747            .iter()
1748            .filter(|b| b.close.value() < current)
1749            .count();
1750        #[allow(clippy::cast_possible_truncation)]
1751        Some(Decimal::from(below as u32) / Decimal::from((n - 1) as u32) * Decimal::ONE_HUNDRED)
1752    }
1753
1754    /// Returns `highest_high(n) / lowest_low(n)` over the last `n` bars.
1755    ///
1756    /// Returns `None` if fewer than `n` bars, `n == 0`, or lowest_low is zero.
1757    pub fn high_low_ratio(&self, n: usize) -> Option<Decimal> {
1758        if n == 0 || self.bars.len() < n {
1759            return None;
1760        }
1761        let hh = self.highest_high(n)?;
1762        let ll = self.lowest_low(n)?;
1763        if ll == Decimal::ZERO {
1764            return None;
1765        }
1766        Some(hh / ll)
1767    }
1768
1769    /// If `n` exceeds the series length, all bars are included.
1770    #[allow(clippy::cast_possible_truncation)]
1771    pub fn average_volume(&self, n: usize) -> Option<Decimal> {
1772        let start = self.bars.len().saturating_sub(n);
1773        let slice = &self.bars[start..];
1774        if slice.is_empty() {
1775            return None;
1776        }
1777        let sum: Decimal = slice.iter().map(|b| b.volume.value()).sum();
1778        Some(sum / Decimal::from(slice.len() as u32))
1779    }
1780
1781    /// Returns the close prices for the last `n` bars in chronological order.
1782    ///
1783    /// Returns fewer than `n` values if the series is shorter.
1784    pub fn last_n_closes(&self, n: usize) -> Vec<Decimal> {
1785        let start = self.bars.len().saturating_sub(n);
1786        self.bars[start..].iter().map(|b| b.close.value()).collect()
1787    }
1788
1789    /// Returns `true` if the last bar's volume exceeds the average of the prior `n` bars
1790    /// multiplied by `multiplier`.
1791    ///
1792    /// Returns `false` if there are fewer than 2 bars or `multiplier` is zero.
1793    pub fn volume_spike(&self, n: usize, multiplier: Decimal) -> bool {
1794        if self.bars.len() < 2 || multiplier.is_zero() {
1795            return false;
1796        }
1797        let last_vol = self.bars.last().unwrap().volume.value();
1798        // average of all bars except the last one (up to n bars)
1799        let prior_count = self.bars.len() - 1;
1800        let start = prior_count.saturating_sub(n);
1801        let prior = &self.bars[start..prior_count];
1802        if prior.is_empty() {
1803            return false;
1804        }
1805        let avg: Decimal = prior.iter().map(|b| b.volume.value()).sum::<Decimal>()
1806            / Decimal::from(prior.len() as u32);
1807        last_vol > avg * multiplier
1808    }
1809
1810    /// Returns the average bar range (high − low) over the last `n` bars, or `None` if empty.
1811    ///
1812    /// If `n` exceeds the series length, all bars are included.
1813    #[allow(clippy::cast_possible_truncation)]
1814    pub fn average_range(&self, n: usize) -> Option<Decimal> {
1815        let start = self.bars.len().saturating_sub(n);
1816        let slice = &self.bars[start..];
1817        if slice.is_empty() {
1818            return None;
1819        }
1820        let sum: Decimal = slice.iter().map(|b| b.range()).sum();
1821        Some(sum / Decimal::from(slice.len() as u32))
1822    }
1823
1824    /// Returns the mean of typical prices `(high + low + close) / 3` over the last `n` bars.
1825    ///
1826    /// Returns `None` if the series is empty.
1827    #[allow(clippy::cast_possible_truncation)]
1828    pub fn typical_price_mean(&self, n: usize) -> Option<Decimal> {
1829        let start = self.bars.len().saturating_sub(n);
1830        let slice = &self.bars[start..];
1831        if slice.is_empty() {
1832            return None;
1833        }
1834        let sum: Decimal = slice.iter().map(|b| b.typical_price()).sum();
1835        Some(sum / Decimal::from(slice.len() as u32))
1836    }
1837
1838    /// Returns the Sortino ratio using bar log-returns.
1839    ///
1840    /// Only negative returns contribute to the downside deviation denominator.
1841    /// Returns `None` if there are fewer than 2 bars or if downside deviation is zero.
1842    pub fn sortino_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1843        let log_rets = self.log_returns();
1844        if log_rets.len() < 2 {
1845            return None;
1846        }
1847        let mean_ret = log_rets.iter().copied().sum::<f64>() / log_rets.len() as f64;
1848        let downside: Vec<f64> = log_rets.iter().map(|&r| if r < 0.0 { r * r } else { 0.0 }).collect();
1849        let downside_var = downside.iter().copied().sum::<f64>() / downside.len() as f64;
1850        let downside_dev = downside_var.sqrt();
1851        if downside_dev == 0.0 {
1852            return None;
1853        }
1854        let rf_per_bar = risk_free_rate / bars_per_year;
1855        Some((mean_ret - rf_per_bar) / downside_dev * bars_per_year.sqrt())
1856    }
1857
1858    /// Returns the number of consecutive bullish bars (close > open) counting from the end.
1859    ///
1860    /// Returns 0 if the series is empty or the last bar is not bullish.
1861    pub fn close_above_open_streak(&self) -> usize {
1862        self.bars
1863            .iter()
1864            .rev()
1865            .take_while(|b| b.is_bullish())
1866            .count()
1867    }
1868
1869    /// Returns the maximum peak-to-trough drawdown percentage over the last `n` bars.
1870    ///
1871    /// Computed on close prices: scans for the largest `(peak - trough) / peak * 100`.
1872    /// Returns `None` if fewer than 2 bars are available in the window.
1873    pub fn max_drawdown_pct(&self, n: usize) -> Option<f64> {
1874        let window: Vec<f64> = self
1875            .bars
1876            .iter()
1877            .rev()
1878            .take(n)
1879            .map(|b| { use rust_decimal::prelude::ToPrimitive; b.close.value().to_f64().unwrap_or(0.0) })
1880            .collect::<Vec<_>>()
1881            .into_iter()
1882            .rev()
1883            .collect();
1884        if window.len() < 2 {
1885            return None;
1886        }
1887        let mut max_dd = 0.0f64;
1888        let mut peak = window[0];
1889        for &price in &window[1..] {
1890            if price > peak {
1891                peak = price;
1892            }
1893            if peak > 0.0 {
1894                let dd = (peak - price) / peak * 100.0;
1895                if dd > max_dd {
1896                    max_dd = dd;
1897                }
1898            }
1899        }
1900        Some(max_dd)
1901    }
1902
1903    /// Returns the Average True Range for each bar as `Vec<Option<Decimal>>`.
1904    ///
1905    /// Uses a simple rolling average of True Range over `period` bars.
1906    /// The first `period - 1` entries are `None`; the rest are `Some(atr)`.
1907    #[allow(clippy::cast_possible_truncation)]
1908    pub fn atr_series(&self, period: usize) -> Vec<Option<Decimal>> {
1909        let n = self.bars.len();
1910        let mut result = vec![None; n];
1911        if period == 0 || n == 0 {
1912            return result;
1913        }
1914        let trs: Vec<Decimal> = self
1915            .bars
1916            .iter()
1917            .enumerate()
1918            .map(|(i, b)| {
1919                let prev = if i == 0 { None } else { Some(&self.bars[i - 1]) };
1920                b.true_range(prev)
1921            })
1922            .collect();
1923        for i in (period - 1)..n {
1924            let sum: Decimal = trs[i + 1 - period..=i].iter().copied().sum();
1925            result[i] = Some(sum / Decimal::from(period as u32));
1926        }
1927        result
1928    }
1929
1930    /// Returns the count of bars (in the last `n`) where `close > prev_close`.
1931    ///
1932    /// If `n` exceeds the series length, all eligible bars are counted.
1933    /// The first bar in the series is never an "up day" (no prior bar).
1934    pub fn up_days(&self, n: usize) -> usize {
1935        if self.bars.len() < 2 {
1936            return 0;
1937        }
1938        let start = self.bars.len().saturating_sub(n).max(1);
1939        self.bars[start..]
1940            .iter()
1941            .enumerate()
1942            .filter(|(i, b)| b.close.value() > self.bars[start + i - 1].close.value())
1943            .count()
1944    }
1945
1946    /// Returns the count of bars (in the last `n`) where `close < prev_close`.
1947    ///
1948    /// Mirrors [`OhlcvSeries::up_days`] for the downside.
1949    pub fn down_days(&self, n: usize) -> usize {
1950        if self.bars.len() < 2 {
1951            return 0;
1952        }
1953        let start = self.bars.len().saturating_sub(n).max(1);
1954        self.bars[start..]
1955            .iter()
1956            .enumerate()
1957            .filter(|(i, b)| b.close.value() < self.bars[start + i - 1].close.value())
1958            .count()
1959    }
1960
1961    /// Returns the per-bar range (`high - low`) as a `Vec<Decimal>`.
1962    ///
1963    /// One value per bar; empty if the series is empty.
1964    pub fn range_series(&self) -> Vec<Decimal> {
1965        self.bars.iter().map(|b| b.range()).collect()
1966    }
1967
1968    /// Returns absolute close-to-close changes: `|close[i] - close[i-1]|` for each bar.
1969    ///
1970    /// The result has `len() - 1` entries (first bar has no previous bar).
1971    /// Empty when the series has fewer than 2 bars.
1972    pub fn close_to_close_changes(&self) -> Vec<Decimal> {
1973        if self.bars.len() < 2 {
1974            return vec![];
1975        }
1976        self.bars
1977            .windows(2)
1978            .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1979            .collect()
1980    }
1981
1982    /// Returns the ratio of short-period ATR to long-period ATR.
1983    ///
1984    /// A ratio > 1 means recent volatility is higher than the longer baseline;
1985    /// < 1 means it is lower. Returns `None` if either ATR value is unavailable
1986    /// (series too short) or if the long-period ATR is zero.
1987    pub fn volatility_ratio(&self, short: usize, long: usize) -> Option<Decimal> {
1988        let n = self.bars.len();
1989        if short == 0 || long == 0 || n == 0 {
1990            return None;
1991        }
1992        let short_atr = *self.atr_series(short).last()?;
1993        let long_atr = *self.atr_series(long).last()?;
1994        let s = short_atr?;
1995        let l = long_atr?;
1996        if l.is_zero() {
1997            return None;
1998        }
1999        Some(s / l)
2000    }
2001
2002    /// Returns the length of the current consecutive close-to-close streak.
2003    ///
2004    /// A positive value means the last N closes were each higher than the prior close
2005    /// (bullish streak). A negative value means consecutive lower closes (bearish streak).
2006    /// Returns `0` when the series has fewer than 2 bars.
2007    ///
2008    /// # Example
2009    /// ```
2010    /// # use fin_primitives::ohlcv::OhlcvSeries;
2011    /// # use fin_primitives::types::{Price, Quantity, Symbol, NanoTimestamp};
2012    /// # use fin_primitives::ohlcv::OhlcvBar;
2013    /// # fn bar(close: f64) -> OhlcvBar {
2014    /// #     let p = Price::new(close.to_string().parse().unwrap()).unwrap();
2015    /// #     let q = Quantity::new(rust_decimal::Decimal::ONE).unwrap();
2016    /// #     OhlcvBar { symbol: Symbol::new("X").unwrap(), open: p, high: p, low: p, close: p,
2017    /// #                volume: q, ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1 }
2018    /// # }
2019    /// let mut s = OhlcvSeries::new();
2020    /// s.push(bar(10.0)); s.push(bar(11.0)); s.push(bar(12.0));
2021    /// assert_eq!(s.streak(), 2);
2022    /// ```
2023    pub fn streak(&self) -> i32 {
2024        let n = self.bars.len();
2025        if n < 2 {
2026            return 0;
2027        }
2028        let mut count: i32 = 0;
2029        for i in (1..n).rev() {
2030            let prev = self.bars[i - 1].close.value();
2031            let curr = self.bars[i].close.value();
2032            if curr > prev {
2033                if count < 0 {
2034                    break;
2035                }
2036                count += 1;
2037            } else if curr < prev {
2038                if count > 0 {
2039                    break;
2040                }
2041                count -= 1;
2042            } else {
2043                break;
2044            }
2045        }
2046        count
2047    }
2048
2049    /// Returns the Calmar ratio: annualised return divided by maximum drawdown.
2050    ///
2051    /// Annualised return is computed as `mean_log_return * bars_per_year`.
2052    /// Requires at least 2 bars and a non-zero `max_drawdown`.
2053    ///
2054    /// Returns `None` when there is insufficient data or the max drawdown is zero.
2055    pub fn calmar_ratio(&self, bars_per_year: f64) -> Option<f64> {
2056        let lr = self.log_returns();
2057        if lr.len() < 2 {
2058            return None;
2059        }
2060        let ann_return = (lr.iter().sum::<f64>() / lr.len() as f64) * bars_per_year;
2061        let dd = self.max_drawdown()?;
2062        use rust_decimal::prelude::ToPrimitive;
2063        let dd_f64 = dd.to_f64()?;
2064        if dd_f64 == 0.0_f64 {
2065            return None;
2066        }
2067        Some(ann_return / dd_f64)
2068    }
2069
2070    /// Returns `(highest_high, lowest_low)` over the last `n` bars, or `None` if empty.
2071    ///
2072    /// If `n` exceeds the series length, all bars are considered. Provides a convenient
2073    /// way to get both extremes in one call without scanning the series twice.
2074    pub fn session_high_low(&self, n: usize) -> Option<(Decimal, Decimal)> {
2075        let start = self.bars.len().saturating_sub(n);
2076        let slice = &self.bars[start..];
2077        if slice.is_empty() {
2078            return None;
2079        }
2080        let h = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
2081        let l = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
2082        Some((h, l))
2083    }
2084
2085    /// Returns bar-to-bar percentage changes: `(close[i] - close[i-1]) / close[i-1] * 100`.
2086    ///
2087    /// The result has `len() - 1` entries. Returns an empty vec when the series
2088    /// has fewer than 2 bars or when a previous close is zero.
2089    pub fn percentage_change_series(&self) -> Vec<Option<Decimal>> {
2090        if self.bars.len() < 2 {
2091            return vec![];
2092        }
2093        self.bars
2094            .windows(2)
2095            .map(|w| {
2096                let prev_c = w[0].close.value();
2097                if prev_c.is_zero() {
2098                    None
2099                } else {
2100                    Some((w[1].close.value() - prev_c) / prev_c * Decimal::ONE_HUNDRED)
2101                }
2102            })
2103            .collect()
2104    }
2105
2106    /// Realized volatility: standard deviation of log returns over the last `n` bars,
2107    /// annualised by multiplying by `sqrt(bars_per_year)`.
2108    ///
2109    /// Returns `None` if `n == 0` or there are fewer than `n + 1` bars.
2110    pub fn realized_volatility(&self, n: usize, bars_per_year: f64) -> Option<f64> {
2111        if n == 0 || self.bars.len() < n + 1 {
2112            return None;
2113        }
2114        let start = self.bars.len() - n - 1;
2115        let lr: Vec<f64> = self.bars[start..]
2116            .windows(2)
2117            .filter_map(|w| {
2118                let prev = w[0].close.value();
2119                if prev.is_zero() {
2120                    return None;
2121                }
2122                use rust_decimal::prelude::ToPrimitive;
2123                let ratio = (w[1].close.value() / prev).to_f64()?;
2124                Some(ratio.ln())
2125            })
2126            .collect();
2127        if lr.len() < 2 {
2128            return None;
2129        }
2130        let mean = lr.iter().sum::<f64>() / lr.len() as f64;
2131        let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / lr.len() as f64;
2132        Some(variance.sqrt() * bars_per_year.sqrt())
2133    }
2134
2135    /// Pearson correlation of closes between `self` and `other` over the last `n` bars.
2136    ///
2137    /// Returns `None` when either series has fewer than `n` bars, `n < 2`, or
2138    /// either series has zero variance over the window.
2139    pub fn rolling_correlation(&self, other: &OhlcvSeries, n: usize) -> Option<f64> {
2140        if n < 2 || self.bars.len() < n || other.bars.len() < n {
2141            return None;
2142        }
2143        use rust_decimal::prelude::ToPrimitive;
2144        let xs: Vec<f64> = self.bars[self.bars.len() - n..]
2145            .iter()
2146            .filter_map(|b| b.close.value().to_f64())
2147            .collect();
2148        let ys: Vec<f64> = other.bars[other.bars.len() - n..]
2149            .iter()
2150            .filter_map(|b| b.close.value().to_f64())
2151            .collect();
2152        if xs.len() != n || ys.len() != n {
2153            return None;
2154        }
2155        let n_f = n as f64;
2156        let mx = xs.iter().sum::<f64>() / n_f;
2157        let my = ys.iter().sum::<f64>() / n_f;
2158        let cov = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum::<f64>() / n_f;
2159        let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2160        let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2161        if sx == 0.0 || sy == 0.0 {
2162            return None;
2163        }
2164        Some(cov / (sx * sy))
2165    }
2166
2167    /// CAPM beta: `cov(self, market) / var(market)` over the last `n` log-return bars.
2168    ///
2169    /// Returns `None` when either series has fewer than `n + 1` bars, `n < 2`, or
2170    /// the market variance is zero.
2171    pub fn beta(&self, market: &OhlcvSeries, n: usize) -> Option<f64> {
2172        if n < 2 || self.bars.len() < n + 1 || market.bars.len() < n + 1 {
2173            return None;
2174        }
2175        use rust_decimal::prelude::ToPrimitive;
2176        let asset_lr: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2177            .windows(2)
2178            .filter_map(|w| {
2179                let prev = w[0].close.value();
2180                if prev.is_zero() { return None; }
2181                (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2182            })
2183            .collect();
2184        let mkt_lr: Vec<f64> = market.bars[market.bars.len() - n - 1..]
2185            .windows(2)
2186            .filter_map(|w| {
2187                let prev = w[0].close.value();
2188                if prev.is_zero() { return None; }
2189                (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2190            })
2191            .collect();
2192        let len = asset_lr.len().min(mkt_lr.len());
2193        if len < 2 {
2194            return None;
2195        }
2196        let n_f = len as f64;
2197        let ma = asset_lr[..len].iter().sum::<f64>() / n_f;
2198        let mm = mkt_lr[..len].iter().sum::<f64>() / n_f;
2199        let cov = asset_lr[..len].iter().zip(mkt_lr[..len].iter())
2200            .map(|(a, m)| (a - ma) * (m - mm))
2201            .sum::<f64>() / n_f;
2202        let var_m = mkt_lr[..len].iter().map(|m| (m - mm).powi(2)).sum::<f64>() / n_f;
2203        if var_m == 0.0 { return None; }
2204        Some(cov / var_m)
2205    }
2206
2207    /// Information ratio: `(mean_excess_return) / tracking_error` over the last `n` bars.
2208    ///
2209    /// Excess return is `asset_log_return - benchmark_log_return` per bar.
2210    /// Returns `None` when there is insufficient data or tracking error is zero.
2211    pub fn information_ratio(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
2212        if n < 2 || self.bars.len() < n + 1 || benchmark.bars.len() < n + 1 {
2213            return None;
2214        }
2215        use rust_decimal::prelude::ToPrimitive;
2216        let excess: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2217            .windows(2)
2218            .zip(benchmark.bars[benchmark.bars.len() - n - 1..].windows(2))
2219            .filter_map(|(aw, bw)| {
2220                let ap = aw[0].close.value();
2221                let bp = bw[0].close.value();
2222                if ap.is_zero() || bp.is_zero() { return None; }
2223                let ar = (aw[1].close.value() / ap).to_f64()?.ln();
2224                let br = (bw[1].close.value() / bp).to_f64()?.ln();
2225                Some(ar - br)
2226            })
2227            .collect();
2228        if excess.len() < 2 { return None; }
2229        let n_f = excess.len() as f64;
2230        let mean = excess.iter().sum::<f64>() / n_f;
2231        let te = (excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / n_f).sqrt();
2232        if te == 0.0 { return None; }
2233        Some(mean / te)
2234    }
2235
2236    /// Per-bar drawdown series from the rolling high-water mark.
2237    ///
2238    /// Each element is `(rolling_high - close) / rolling_high` expressed as a positive
2239    /// fraction (0 = at new high, 0.1 = 10% below peak). Empty when the series is empty.
2240    pub fn drawdown_series(&self) -> Vec<Decimal> {
2241        if self.bars.is_empty() {
2242            return vec![];
2243        }
2244        let mut peak = Decimal::MIN;
2245        self.bars
2246            .iter()
2247            .map(|b| {
2248                let close = b.close.value();
2249                if close > peak {
2250                    peak = close;
2251                }
2252                if peak.is_zero() {
2253                    Decimal::ZERO
2254                } else {
2255                    (peak - close) / peak
2256                }
2257            })
2258            .collect()
2259    }
2260
2261    /// Returns `true` if the last close is above the SMA of the last `period` closes.
2262    ///
2263    /// Returns `None` when there are fewer than `period` bars or `period == 0`.
2264    pub fn above_moving_average(&self, period: usize) -> Option<bool> {
2265        if period == 0 || self.bars.len() < period {
2266            return None;
2267        }
2268        let start = self.bars.len() - period;
2269        #[allow(clippy::cast_possible_truncation)]
2270        let sma: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2271            / Decimal::from(period as u32);
2272        Some(self.bars.last()?.close.value() > sma)
2273    }
2274
2275    /// Count bars in the last `n` where `high > prev_bar.high` (consecutive higher highs proxy).
2276    ///
2277    /// Returns 0 when the series has fewer than 2 bars or `n == 0`.
2278    pub fn consecutive_higher_highs(&self, n: usize) -> usize {
2279        if n == 0 || self.bars.len() < 2 {
2280            return 0;
2281        }
2282        let start = self.bars.len().saturating_sub(n).max(1);
2283        self.bars[start..]
2284            .iter()
2285            .enumerate()
2286            .filter(|(i, b)| b.high.value() > self.bars[start + i - 1].high.value())
2287            .count()
2288    }
2289
2290    /// Counts bars where each bar's low is strictly below the prior bar's low,
2291    /// looking at the last `n` consecutive bar pairs.
2292    ///
2293    /// Returns `0` when `n == 0` or the series has fewer than 2 bars.
2294    pub fn consecutive_lower_lows(&self, n: usize) -> usize {
2295        if n == 0 || self.bars.len() < 2 {
2296            return 0;
2297        }
2298        let start = self.bars.len().saturating_sub(n).max(1);
2299        self.bars[start..]
2300            .iter()
2301            .enumerate()
2302            .filter(|(i, b)| b.low.value() < self.bars[start + i - 1].low.value())
2303            .count()
2304    }
2305
2306    /// Distance of the latest close from its `n`-bar VWAP, as a percentage of VWAP.
2307    ///
2308    /// `deviation_pct = (close - vwap) / vwap * 100`
2309    ///
2310    /// Returns `None` if `n == 0`, series is shorter than `n`, or total volume is zero.
2311    pub fn vwap_deviation(&self, n: usize) -> Option<Decimal> {
2312        if n == 0 || self.bars.len() < n {
2313            return None;
2314        }
2315        let start = self.bars.len().saturating_sub(n);
2316        let slice = &self.bars[start..];
2317        let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
2318        if total_vol.is_zero() {
2319            return None;
2320        }
2321        let vwap: Decimal = slice.iter()
2322            .map(|b| {
2323                let tp = b.typical_price();
2324                tp * b.volume.value()
2325            })
2326            .sum::<Decimal>() / total_vol;
2327        if vwap.is_zero() {
2328            return None;
2329        }
2330        let last_close = self.bars.last()?.close.value();
2331        Some((last_close - vwap) / vwap * Decimal::ONE_HUNDRED)
2332    }
2333
2334    /// ATR as a percentage of the last closing price over the last `n` bars.
2335    ///
2336    /// Computed as `mean(ATR) / close * 100`. Returns `None` if fewer than `n` bars,
2337    /// `n == 0`, or the last close is zero.
2338    pub fn average_true_range_pct(&self, n: usize) -> Option<f64> {
2339        use rust_decimal::prelude::ToPrimitive;
2340        if n == 0 || self.bars.len() < n {
2341            return None;
2342        }
2343        let atrs = self.atr_series(n);
2344        let last_close = self.bars.last()?.close.value();
2345        if last_close.is_zero() {
2346            return None;
2347        }
2348        let atr = (*atrs.last()?.as_ref()?).to_f64()?;
2349        let close_f64 = last_close.to_f64()?;
2350        Some(atr / close_f64 * 100.0)
2351    }
2352
2353    /// Count bars in the last `n` that are doji candles (body ≤ `threshold` × range).
2354    ///
2355    /// Delegates to [`OhlcvBar::is_doji`] for each bar.
2356    pub fn count_doji(&self, n: usize, threshold: Decimal) -> usize {
2357        if n == 0 {
2358            return 0;
2359        }
2360        let start = self.bars.len().saturating_sub(n);
2361        self.bars[start..].iter().filter(|b| b.is_doji(threshold)).count()
2362    }
2363
2364    /// Counts bars in the last `n` where `open > prev_close` (gap-up).
2365    ///
2366    /// Returns `0` if the series has fewer than 2 bars or `n == 0`.
2367    pub fn gap_up_bars(&self, n: usize) -> usize {
2368        if n == 0 || self.bars.len() < 2 {
2369            return 0;
2370        }
2371        let start = self.bars.len().saturating_sub(n + 1);
2372        self.bars[start..].windows(2).filter(|w| w[1].gap_up_from(&w[0])).count()
2373    }
2374
2375    /// Counts bars in the last `n` where `open < prev_close` (gap-down).
2376    ///
2377    /// Returns `0` if the series has fewer than 2 bars or `n == 0`.
2378    pub fn gap_down_bars(&self, n: usize) -> usize {
2379        if n == 0 || self.bars.len() < 2 {
2380            return 0;
2381        }
2382        let start = self.bars.len().saturating_sub(n + 1);
2383        self.bars[start..].windows(2).filter(|w| w[1].gap_down_from(&w[0])).count()
2384    }
2385
2386    /// Returns the cumulative volume over the last `n` bars.
2387    ///
2388    /// Returns `Decimal::ZERO` if `n == 0` or the series is empty.
2389    pub fn cum_volume(&self, n: usize) -> Decimal {
2390        if n == 0 {
2391            return Decimal::ZERO;
2392        }
2393        let start = self.bars.len().saturating_sub(n);
2394        self.bars[start..].iter().map(|b| b.volume.value()).sum()
2395    }
2396
2397    /// Dual-period momentum score: `(sma_short - sma_long) / sma_long * 100`.
2398    ///
2399    /// Returns `None` when the series has fewer than `long` bars, `short == 0`,
2400    /// `long == 0`, `short >= long`, or the long SMA is zero.
2401    pub fn momentum_score(&self, short: usize, long: usize) -> Option<f64> {
2402        use rust_decimal::prelude::ToPrimitive;
2403        if short == 0 || long == 0 || short >= long || self.bars.len() < long {
2404            return None;
2405        }
2406        #[allow(clippy::cast_possible_truncation)]
2407        let sma = |n: usize| -> Option<Decimal> {
2408            let start = self.bars.len().saturating_sub(n);
2409            let s: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
2410            Some(s / Decimal::from(n as u32))
2411        };
2412        let sma_s = sma(short)?;
2413        let sma_l = sma(long)?;
2414        if sma_l.is_zero() {
2415            return None;
2416        }
2417        ((sma_s - sma_l) / sma_l * Decimal::ONE_HUNDRED).to_f64()
2418    }
2419
2420    /// Returns the first bar in the series, or `None` if empty.
2421    pub fn first_bar(&self) -> Option<&OhlcvBar> {
2422        self.bars.first()
2423    }
2424
2425    /// Volume-weighted close over the last `n` bars: `Σ(close × volume) / Σ(volume)`.
2426    ///
2427    /// Returns `None` when `n == 0`, the series has fewer than `n` bars, or total volume is zero.
2428    pub fn volume_weighted_close(&self, n: usize) -> Option<Decimal> {
2429        if n == 0 || self.bars.len() < n {
2430            return None;
2431        }
2432        let start = self.bars.len() - n;
2433        let vol_sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
2434        if vol_sum.is_zero() {
2435            return None;
2436        }
2437        let pv_sum: Decimal = self.bars[start..]
2438            .iter()
2439            .map(|b| b.close.value() * b.volume.value())
2440            .sum();
2441        Some(pv_sum / vol_sum)
2442    }
2443
2444    /// Last bar range divided by average range over the last `n` bars.
2445    ///
2446    /// Values > 1 indicate volatility expansion; < 1 contraction.
2447    /// Returns `None` when `n == 0`, the series has fewer than `n` bars, or average range is zero.
2448    pub fn range_expansion_ratio(&self, n: usize) -> Option<f64> {
2449        use rust_decimal::prelude::ToPrimitive;
2450        if n == 0 || self.bars.len() < n {
2451            return None;
2452        }
2453        let last_range = self.bars.last()?.range();
2454        let start = self.bars.len() - n;
2455        let avg_range = self.bars[start..]
2456            .iter()
2457            .map(|b| b.range())
2458            .sum::<Decimal>();
2459        #[allow(clippy::cast_possible_truncation)]
2460        let avg = avg_range / Decimal::from(n as u32);
2461        if avg.is_zero() {
2462            return None;
2463        }
2464        (last_range / avg).to_f64()
2465    }
2466
2467    /// Kaufman Efficiency Ratio over the last `n` bars.
2468    ///
2469    /// `ER = |close[end] - close[start]| / Σ|close[i] - close[i-1]|`.
2470    /// Returns `None` if fewer than `n+1` bars or the total path length is zero.
2471    pub fn efficiency_ratio(&self, n: usize) -> Option<Decimal> {
2472        if n == 0 || self.bars.len() <= n {
2473            return None;
2474        }
2475        let start = self.bars.len() - n - 1;
2476        let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2477        let direction = (closes[n] - closes[0]).abs();
2478        let path: Decimal = closes.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
2479        if path.is_zero() {
2480            return None;
2481        }
2482        Some(direction / path)
2483    }
2484
2485    /// Body-size as a percentage of range for the last `n` bars.
2486    ///
2487    /// Each element is `|close - open| / (high - low) * 100`, or `None` when
2488    /// the bar's high equals its low.
2489    pub fn body_pct_series(&self, n: usize) -> Vec<Option<Decimal>> {
2490        let start = self.bars.len().saturating_sub(n);
2491        self.bars[start..]
2492            .iter()
2493            .map(|b| {
2494                let range = b.range();
2495                if range.is_zero() {
2496                    None
2497                } else {
2498                    let body = b.body_size();
2499                    Some(body / range * Decimal::ONE_HUNDRED)
2500                }
2501            })
2502            .collect()
2503    }
2504
2505    /// Count of candle direction changes in the last `n` bars.
2506    ///
2507    /// A change is when the current bar's direction (close ≥ open vs close < open)
2508    /// differs from the previous bar. Returns `0` if fewer than 2 bars available.
2509    pub fn candle_color_changes(&self, n: usize) -> usize {
2510        let start = self.bars.len().saturating_sub(n);
2511        let slice = &self.bars[start..];
2512        if slice.len() < 2 {
2513            return 0;
2514        }
2515        slice.windows(2)
2516            .filter(|w| {
2517                let prev_bull = w[0].close.value() >= w[0].open.value();
2518                let curr_bull = w[1].close.value() >= w[1].open.value();
2519                prev_bull != curr_bull
2520            })
2521            .count()
2522    }
2523
2524    /// Typical price `(high + low + close) / 3` for each of the last `n` bars.
2525    pub fn typical_price_series(&self, n: usize) -> Vec<Decimal> {
2526        let start = self.bars.len().saturating_sub(n);
2527        self.bars[start..]
2528            .iter()
2529            .map(|b| b.typical_price())
2530            .collect()
2531    }
2532
2533    /// Returns the open-gap percentage for each consecutive bar pair in the full series.
2534    ///
2535    /// `gap_pct[i] = (open[i] - close[i-1]) / close[i-1] * 100`
2536    ///
2537    /// Returns an empty vec if the series has fewer than 2 bars.
2538    pub fn open_gap_series(&self) -> Vec<Decimal> {
2539        if self.bars.len() < 2 {
2540            return Vec::new();
2541        }
2542        self.bars
2543            .windows(2)
2544            .filter_map(|w| {
2545                let prev_close = w[0].close.value();
2546                if prev_close.is_zero() {
2547                    return None;
2548                }
2549                Some((w[1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
2550            })
2551            .collect()
2552    }
2553
2554    /// Average intraday range as a percentage of open: `mean((high - low) / open * 100)` over last `n` bars.
2555    ///
2556    /// Returns `None` if `n == 0`, the series is empty, or any open is zero.
2557    pub fn intraday_range_pct(&self, n: usize) -> Option<Decimal> {
2558        if n == 0 || self.bars.is_empty() {
2559            return None;
2560        }
2561        let start = self.bars.len().saturating_sub(n);
2562        let slice = &self.bars[start..];
2563        let count = slice.len();
2564        if count == 0 {
2565            return None;
2566        }
2567        let sum: Option<Decimal> = slice.iter().try_fold(Decimal::ZERO, |acc, b| {
2568            let o = b.open.value();
2569            if o.is_zero() { return None; }
2570            Some(acc + (b.range()) / o * Decimal::ONE_HUNDRED)
2571        });
2572        #[allow(clippy::cast_possible_truncation)]
2573        Some(sum? / Decimal::from(count as u32))
2574    }
2575
2576    /// Counts bars in the last `n` where `close > prev_high` (breakout above prior high).
2577    ///
2578    /// Returns `0` if `n == 0` or the series has fewer than 2 bars.
2579    pub fn close_above_prior_high(&self, n: usize) -> usize {
2580        if n == 0 || self.bars.len() < 2 {
2581            return 0;
2582        }
2583        let start = self.bars.len().saturating_sub(n + 1);
2584        self.bars[start..].windows(2).filter(|w| w[1].close.value() > w[0].high.value()).count()
2585    }
2586
2587    /// Skewness of close prices over the last `n` bars (Fisher's moment coefficient of skewness).
2588    ///
2589    /// Returns `None` if `n < 3`, series has fewer than `n` bars, or std dev is zero.
2590    pub fn skewness(&self, n: usize) -> Option<f64> {
2591        use rust_decimal::prelude::ToPrimitive;
2592        if n < 3 || self.bars.len() < n {
2593            return None;
2594        }
2595        let start = self.bars.len().saturating_sub(n);
2596        let vals: Vec<f64> = self.bars[start..]
2597            .iter()
2598            .filter_map(|b| b.close.value().to_f64())
2599            .collect();
2600        if vals.len() < 3 {
2601            return None;
2602        }
2603        let n_f = vals.len() as f64;
2604        let mean = vals.iter().sum::<f64>() / n_f;
2605        let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2606        let std_dev = variance.sqrt();
2607        if std_dev == 0.0 {
2608            return None;
2609        }
2610        let skew = vals.iter().map(|x| ((x - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
2611        Some(skew)
2612    }
2613
2614    /// Excess kurtosis of close prices over the last `n` bars.
2615    ///
2616    /// Excess kurtosis = (fourth central moment / variance²) - 3.
2617    /// Returns `None` if `n < 4`, series has fewer than `n` bars, or variance is zero.
2618    pub fn kurtosis(&self, n: usize) -> Option<f64> {
2619        use rust_decimal::prelude::ToPrimitive;
2620        if n < 4 || self.bars.len() < n {
2621            return None;
2622        }
2623        let start = self.bars.len().saturating_sub(n);
2624        let vals: Vec<f64> = self.bars[start..]
2625            .iter()
2626            .filter_map(|b| b.close.value().to_f64())
2627            .collect();
2628        if vals.len() < 4 {
2629            return None;
2630        }
2631        let n_f = vals.len() as f64;
2632        let mean = vals.iter().sum::<f64>() / n_f;
2633        let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2634        if variance == 0.0 {
2635            return None;
2636        }
2637        let kurt = vals.iter().map(|x| ((x - mean) / variance.sqrt()).powi(4)).sum::<f64>() / n_f - 3.0;
2638        Some(kurt)
2639    }
2640
2641    /// Returns `true` when the fast SMA is above the slow SMA (golden-cross condition).
2642    ///
2643    /// Returns `false` if the series does not have enough bars for the slow period,
2644    /// or if `fast_period >= slow_period`.
2645    pub fn sma_crossover(&self, fast_period: usize, slow_period: usize) -> bool {
2646        if fast_period == 0 || slow_period == 0 || fast_period >= slow_period {
2647            return false;
2648        }
2649        if self.bars.len() < slow_period {
2650            return false;
2651        }
2652        let fast_start = self.bars.len() - fast_period;
2653        let slow_start = self.bars.len() - slow_period;
2654        let fast_avg: Decimal = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2655            / Decimal::from(fast_period as u32);
2656        let slow_avg: Decimal = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2657            / Decimal::from(slow_period as u32);
2658        fast_avg > slow_avg
2659    }
2660
2661    /// Fraction of the last `n` closing prices that are at or below `price`.
2662    ///
2663    /// Returns a value in `[0.0, 1.0]`. Returns `None` if `n == 0` or the series is empty.
2664    pub fn price_percentile(&self, price: Decimal, n: usize) -> Option<f64> {
2665        if n == 0 || self.bars.is_empty() {
2666            return None;
2667        }
2668        let start = self.bars.len().saturating_sub(n);
2669        let slice = &self.bars[start..];
2670        let count = slice.iter().filter(|b| b.close.value() <= price).count();
2671        Some(count as f64 / slice.len() as f64)
2672    }
2673
2674    /// Mean of `(high - low)` over the last `n` bars.
2675    ///
2676    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
2677    pub fn intraday_range_mean(&self, n: usize) -> Option<Decimal> {
2678        if n == 0 || self.bars.len() < n {
2679            return None;
2680        }
2681        let start = self.bars.len() - n;
2682        let sum: Decimal = self.bars[start..].iter().map(|b| b.range()).sum();
2683        #[allow(clippy::cast_possible_truncation)]
2684        Some(sum / Decimal::from(n as u32))
2685    }
2686
2687    /// Returns `(current_range / ATR) * 100`, showing how the current bar's
2688    /// high-low range compares to the average true range over the last `n` bars.
2689    ///
2690    /// Returns `None` if fewer than `n+1` bars, `n == 0`, or ATR is zero.
2691    pub fn range_to_atr_ratio(&self, n: usize) -> Option<Decimal> {
2692        if n == 0 || self.bars.len() < n + 1 {
2693            return None;
2694        }
2695        let start = self.bars.len() - n - 1;
2696        let slice = &self.bars[start..];
2697        let mut tr_sum = Decimal::ZERO;
2698        for w in slice.windows(2) {
2699            let prev_close = w[0].close.value();
2700            let high = w[1].high.value();
2701            let low = w[1].low.value();
2702            let tr = (high - low)
2703                .max((high - prev_close).abs())
2704                .max((low - prev_close).abs());
2705            tr_sum += tr;
2706        }
2707        #[allow(clippy::cast_possible_truncation)]
2708        let atr = tr_sum / Decimal::from(n as u32);
2709        if atr.is_zero() {
2710            return None;
2711        }
2712        let last = self.bars.last()?;
2713        let current_range = last.range();
2714        Some(current_range / atr * Decimal::ONE_HUNDRED)
2715    }
2716
2717    /// Returns percentage momentum: `(close - close[n]) / close[n] * 100`.
2718    ///
2719    /// Positive when price has risen over the last `n` bars.
2720    /// Returns `None` if fewer than `n+1` bars, `n == 0`, or reference close is zero.
2721    pub fn close_momentum(&self, n: usize) -> Option<Decimal> {
2722        if n == 0 || self.bars.len() < n + 1 {
2723            return None;
2724        }
2725        let ref_close = self.bars[self.bars.len() - n - 1].close.value();
2726        if ref_close.is_zero() {
2727            return None;
2728        }
2729        let current = self.bars.last()?.close.value();
2730        Some((current - ref_close) / ref_close * Decimal::ONE_HUNDRED)
2731    }
2732
2733    /// Mean absolute gap percentage over the last `n` bars.
2734    ///
2735    /// `gap_pct[i] = |open[i] - close[i-1]| / close[i-1] * 100`.
2736    /// Returns `None` if fewer than `n+1` bars or `n == 0`.
2737    pub fn average_gap_pct(&self, n: usize) -> Option<Decimal> {
2738        if n == 0 || self.bars.len() <= n {
2739            return None;
2740        }
2741        let start = self.bars.len() - n - 1;
2742        let slice = &self.bars[start..];
2743        let mut count = 0;
2744        let mut sum = Decimal::ZERO;
2745        for pair in slice.windows(2) {
2746            let pc = pair[0].close.value();
2747            if pc.is_zero() {
2748                continue;
2749            }
2750            sum += (pair[1].open.value() - pc).abs() / pc * Decimal::ONE_HUNDRED;
2751            count += 1;
2752        }
2753        if count == 0 {
2754            None
2755        } else {
2756            #[allow(clippy::cast_possible_truncation)]
2757            Some(sum / Decimal::from(count as u32))
2758        }
2759    }
2760
2761    /// Bar-over-bar log returns for the last `n` close-to-close periods.
2762    ///
2763    /// Returns a `Vec` of up to `n` values. Requires at least `n + 1` bars in the series.
2764    /// Returns an empty `Vec` when `n == 0` or fewer than 2 bars exist.
2765    pub fn returns_series(&self, n: usize) -> Vec<Decimal> {
2766        if n == 0 || self.bars.len() < 2 {
2767            return vec![];
2768        }
2769        use rust_decimal::prelude::ToPrimitive;
2770        let start = self.bars.len().saturating_sub(n + 1);
2771        let slice = &self.bars[start..];
2772        slice
2773            .windows(2)
2774            .map(|w| {
2775                let prev = w[0].close.value();
2776                let curr = w[1].close.value();
2777                if prev.is_zero() {
2778                    Decimal::ZERO
2779                } else {
2780                    let ratio = (curr / prev).to_f64().unwrap_or(1.0);
2781                    Decimal::try_from(ratio.ln()).unwrap_or(Decimal::ZERO)
2782                }
2783            })
2784            .collect()
2785    }
2786
2787    /// Length of the longest consecutive run of rising closes in the entire series.
2788    ///
2789    /// A close is "rising" when `close[i] > close[i-1]`.
2790    /// Returns `0` when fewer than 2 bars exist.
2791    pub fn max_consecutive_up(&self) -> usize {
2792        if self.bars.len() < 2 {
2793            return 0;
2794        }
2795        let mut max_run = 0usize;
2796        let mut current = 0usize;
2797        for w in self.bars.windows(2) {
2798            if w[1].close.value() > w[0].close.value() {
2799                current += 1;
2800                if current > max_run {
2801                    max_run = current;
2802                }
2803            } else {
2804                current = 0;
2805            }
2806        }
2807        max_run
2808    }
2809
2810    /// Length of the longest consecutive run of falling closes in the entire series.
2811    ///
2812    /// A close is "falling" when `close[i] < close[i-1]`.
2813    /// Returns `0` when fewer than 2 bars exist.
2814    pub fn max_consecutive_down(&self) -> usize {
2815        if self.bars.len() < 2 {
2816            return 0;
2817        }
2818        let mut max_run = 0usize;
2819        let mut current = 0usize;
2820        for w in self.bars.windows(2) {
2821            if w[1].close.value() < w[0].close.value() {
2822                current += 1;
2823                if current > max_run {
2824                    max_run = current;
2825                }
2826            } else {
2827                current = 0;
2828            }
2829        }
2830        max_run
2831    }
2832
2833    /// Simple moving average of the typical price `(high + low + close) / 3`
2834    /// over the last `period` bars.
2835    ///
2836    /// Returns `None` if `period == 0` or fewer than `period` bars exist.
2837    pub fn typical_price_sma(&self, period: usize) -> Option<Decimal> {
2838        if period == 0 || self.bars.len() < period {
2839            return None;
2840        }
2841        let start = self.bars.len() - period;
2842        let sum: Decimal = self.bars[start..]
2843            .iter()
2844            .map(|b| b.typical_price())
2845            .sum();
2846        #[allow(clippy::cast_possible_truncation)]
2847        Some(sum / Decimal::from(period as u32))
2848    }
2849
2850    /// Returns a reference to the bar at position `i`, or `None` if out of bounds.
2851    pub fn bar_at_index(&self, i: usize) -> Option<&OhlcvBar> {
2852        self.bars.get(i)
2853    }
2854
2855    /// Standard deviation of closes over the last `n` bars.
2856    ///
2857    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
2858    #[allow(clippy::cast_possible_truncation)]
2859    pub fn rolling_close_std(&self, n: usize) -> Option<Decimal> {
2860        if n < 2 || self.bars.len() < n {
2861            return None;
2862        }
2863        let start = self.bars.len() - n;
2864        let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2865        let mean = closes.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
2866        let variance = closes
2867            .iter()
2868            .map(|c| { let d = *c - mean; d * d })
2869            .sum::<Decimal>()
2870            / Decimal::from((n - 1) as u32);
2871        use rust_decimal::prelude::ToPrimitive;
2872        let std = variance.to_f64()?.sqrt();
2873        Decimal::try_from(std).ok()
2874    }
2875
2876    /// Returns a `Vec<i8>` of gap directions (`+1` = gap up, `-1` = gap down, `0` = flat)
2877    /// for bar-over-bar open-to-prev-close gaps over the last `n` bars.
2878    ///
2879    /// A gap is defined as `open[i] != close[i-1]`. Returns at most `n - 1` values.
2880    /// Returns empty `Vec` when `n < 2` or fewer than 2 bars exist.
2881    pub fn gap_direction_series(&self, n: usize) -> Vec<i8> {
2882        if n < 2 || self.bars.len() < 2 {
2883            return vec![];
2884        }
2885        let start = self.bars.len().saturating_sub(n);
2886        self.bars[start..]
2887            .windows(2)
2888            .map(|w| {
2889                let gap = w[1].open.value() - w[0].close.value();
2890                if gap > Decimal::ZERO {
2891                    1i8
2892                } else if gap < Decimal::ZERO {
2893                    -1i8
2894                } else {
2895                    0i8
2896                }
2897            })
2898            .collect()
2899    }
2900
2901    /// Returns the linear regression slope of volume over the last `n` bars.
2902    ///
2903    /// Positive slope → volume is trending up; negative → down.
2904    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
2905    pub fn volume_trend(&self, n: usize) -> Option<f64> {
2906        use rust_decimal::prelude::ToPrimitive;
2907        if n < 2 || self.bars.len() < n {
2908            return None;
2909        }
2910        let start = self.bars.len() - n;
2911        let vols: Vec<f64> = self.bars[start..]
2912            .iter()
2913            .filter_map(|b| b.volume.value().to_f64())
2914            .collect();
2915        if vols.len() < 2 {
2916            return None;
2917        }
2918        let n_f = vols.len() as f64;
2919        let sum_x: f64 = (0..vols.len()).map(|i| i as f64).sum();
2920        let sum_y: f64 = vols.iter().sum();
2921        let sum_xy: f64 = vols.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
2922        let sum_xx: f64 = (0..vols.len()).map(|i| (i as f64).powi(2)).sum();
2923        let denom = n_f * sum_xx - sum_x * sum_x;
2924        if denom == 0.0 { return None; }
2925        Some((n_f * sum_xy - sum_x * sum_y) / denom)
2926    }
2927
2928    /// Average ratio of total wick length to body length over the last `n` bars.
2929    ///
2930    /// `wick = (high - low) - |close - open|`; `body = |close - open|`
2931    /// Returns `None` if `n == 0`, fewer than `n` bars, or all bodies are zero.
2932    pub fn wick_body_ratio(&self, n: usize) -> Option<f64> {
2933        use rust_decimal::prelude::ToPrimitive;
2934        if n == 0 || self.bars.len() < n {
2935            return None;
2936        }
2937        let start = self.bars.len() - n;
2938        let mut sum = 0.0f64;
2939        let mut count = 0usize;
2940        for b in &self.bars[start..] {
2941            let body = b.body_size().to_f64()?;
2942            if body == 0.0 { continue; }
2943            let range = (b.range()).to_f64()?;
2944            let wick = (range - body).max(0.0);
2945            sum += wick / body;
2946            count += 1;
2947        }
2948        if count == 0 { return None; }
2949        Some(sum / count as f64)
2950    }
2951
2952    /// Pearson correlation between volume and close price over the last `n` bars.
2953    ///
2954    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or standard deviation is zero.
2955    pub fn volume_price_correlation(&self, n: usize) -> Option<f64> {
2956        use rust_decimal::prelude::ToPrimitive;
2957        if n < 2 || self.bars.len() < n {
2958            return None;
2959        }
2960        let start = self.bars.len() - n;
2961        let xs: Vec<f64> = self.bars[start..]
2962            .iter()
2963            .filter_map(|b| b.volume.value().to_f64())
2964            .collect();
2965        let ys: Vec<f64> = self.bars[start..]
2966            .iter()
2967            .filter_map(|b| b.close.value().to_f64())
2968            .collect();
2969        if xs.len() < 2 { return None; }
2970        let n_f = xs.len() as f64;
2971        let mx = xs.iter().sum::<f64>() / n_f;
2972        let my = ys.iter().sum::<f64>() / n_f;
2973        let num: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum();
2974        let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2975        let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2976        if sx == 0.0 || sy == 0.0 { return None; }
2977        Some(num / (n_f * sx * sy))
2978    }
2979
2980    /// Average bar range as a percentage of close: `(high - low) / close × 100` over `n` bars.
2981    ///
2982    /// Returns `None` if `n == 0`, fewer than `n` bars, or any close is zero.
2983    pub fn bar_range_pct(&self, n: usize) -> Option<Decimal> {
2984        if n == 0 || self.bars.len() < n {
2985            return None;
2986        }
2987        let start = self.bars.len() - n;
2988        let mut sum = Decimal::ZERO;
2989        let mut count = 0u32;
2990        for b in &self.bars[start..] {
2991            let c = b.close.value();
2992            if c.is_zero() { continue; }
2993            sum += (b.range()) / c * Decimal::ONE_HUNDRED;
2994            count += 1;
2995        }
2996        if count == 0 { return None; }
2997        Some(sum / Decimal::from(count))
2998    }
2999
3000    /// Count of bars over the last `n` where close > midpoint of prior bar's high-low range.
3001    ///
3002    /// Returns `0` when `n < 2` or fewer than 2 bars available.
3003    pub fn close_vs_prior_range_count(&self, n: usize) -> usize {
3004        if n < 2 || self.bars.len() < 2 {
3005            return 0;
3006        }
3007        let start = self.bars.len().saturating_sub(n);
3008        let slice = &self.bars[start..];
3009        slice.windows(2)
3010            .filter(|w| {
3011                let mid = (w[0].high.value() + w[0].low.value()) / Decimal::TWO;
3012                w[1].close.value() > mid
3013            })
3014            .count()
3015    }
3016
3017    /// Annualised Sharpe ratio of log returns over the last `n` bars.
3018    ///
3019    /// Uses 252 trading days to annualise. Returns `None` if fewer than 2 bars exist,
3020    /// `n == 0`, or the standard deviation of returns is zero.
3021    pub fn rolling_sharpe(&self, n: usize, risk_free_rate: Decimal) -> Option<Decimal> {
3022        if n == 0 || self.bars.len() < 2 {
3023            return None;
3024        }
3025        use rust_decimal::prelude::ToPrimitive;
3026        let returns = self.returns_series(n);
3027        if returns.len() < 2 {
3028            return None;
3029        }
3030        #[allow(clippy::cast_possible_truncation)]
3031        let len_d = Decimal::from(returns.len() as u32);
3032        let mean: Decimal = returns.iter().copied().sum::<Decimal>() / len_d;
3033        let rf_daily = risk_free_rate / Decimal::from(252u32);
3034        let excess_mean = mean - rf_daily;
3035        let variance = returns
3036            .iter()
3037            .map(|r| { let d = *r - mean; d * d })
3038            .sum::<Decimal>()
3039            / len_d;
3040        let std_f64 = variance.to_f64()?.sqrt();
3041        if std_f64 == 0.0 {
3042            return None;
3043        }
3044        let sharpe = excess_mean.to_f64()? / std_f64 * 252.0f64.sqrt();
3045        Decimal::try_from(sharpe).ok()
3046    }
3047
3048    /// Returns where the latest close sits within the high-low range of the last `n` bars (0–100).
3049    ///
3050    /// `result = (close - lowest_low) / (highest_high - lowest_low) * 100`
3051    ///
3052    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or the range is zero.
3053    pub fn close_range_position(&self, n: usize) -> Option<Decimal> {
3054        if n == 0 || self.bars.len() < n {
3055            return None;
3056        }
3057        let start = self.bars.len() - n;
3058        let slice = &self.bars[start..];
3059        let highest = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
3060        let lowest  = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
3061        let range = highest - lowest;
3062        if range.is_zero() {
3063            return None;
3064        }
3065        let close = self.bars.last()?.close.value();
3066        Some((close - lowest) / range * Decimal::ONE_HUNDRED)
3067    }
3068
3069    /// Returns the number of bars since the highest close in the last `n` bars.
3070    ///
3071    /// Returns `0` if the highest close is the most recent bar, or when `n == 0` or
3072    /// fewer than `n` bars exist.
3073    pub fn bar_count_since_high(&self, n: usize) -> usize {
3074        if n == 0 || self.bars.len() < n {
3075            return 0;
3076        }
3077        let start = self.bars.len() - n;
3078        let slice = &self.bars[start..];
3079        let mut max_val = Decimal::MIN;
3080        let mut max_idx = 0;
3081        for (i, b) in slice.iter().enumerate() {
3082            let c = b.close.value();
3083            if c > max_val {
3084                max_val = c;
3085                max_idx = i;
3086            }
3087        }
3088        slice.len() - 1 - max_idx
3089    }
3090
3091    /// Average `(close / open - 1) * 100` percentage over the last `n` bars.
3092    ///
3093    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all opens are zero.
3094    pub fn close_to_open_ratio(&self, n: usize) -> Option<Decimal> {
3095        if n == 0 || self.bars.len() < n {
3096            return None;
3097        }
3098        let start = self.bars.len() - n;
3099        let mut sum = Decimal::ZERO;
3100        let mut count = 0usize;
3101        for b in &self.bars[start..] {
3102            let o = b.open.value();
3103            if o.is_zero() {
3104                continue;
3105            }
3106            sum += (b.close.value() / o - Decimal::ONE) * Decimal::ONE_HUNDRED;
3107            count += 1;
3108        }
3109        if count == 0 {
3110            return None;
3111        }
3112        #[allow(clippy::cast_possible_truncation)]
3113        Some(sum / Decimal::from(count as u32))
3114    }
3115
3116    /// Lag-`k` autocorrelation of log returns over the last `n` bars.
3117    ///
3118    /// Computes the Pearson correlation between `r[t]` and `r[t-lag]`.
3119    /// Returns `None` if `n == 0`, `lag == 0`, fewer than `n + lag + 1` bars exist,
3120    /// or the standard deviation is zero.
3121    pub fn autocorrelation(&self, n: usize, lag: usize) -> Option<f64> {
3122        if n == 0 || lag == 0 || self.bars.len() < n + lag + 1 {
3123            return None;
3124        }
3125        use rust_decimal::prelude::ToPrimitive;
3126        let returns = self.returns_series(n + lag);
3127        if returns.len() <= lag {
3128            return None;
3129        }
3130        let x: Vec<f64> = returns[..returns.len() - lag].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3131        let y: Vec<f64> = returns[lag..].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3132        let n_f = x.len() as f64;
3133        let mean_x = x.iter().sum::<f64>() / n_f;
3134        let mean_y = y.iter().sum::<f64>() / n_f;
3135        let cov: f64 = x.iter().zip(y.iter()).map(|(xi, yi)| (xi - mean_x) * (yi - mean_y)).sum::<f64>() / n_f;
3136        let std_x = (x.iter().map(|xi| (xi - mean_x).powi(2)).sum::<f64>() / n_f).sqrt();
3137        let std_y = (y.iter().map(|yi| (yi - mean_y).powi(2)).sum::<f64>() / n_f).sqrt();
3138        if std_x == 0.0 || std_y == 0.0 {
3139            return None;
3140        }
3141        Some(cov / (std_x * std_y))
3142    }
3143
3144    /// Hurst exponent estimated via the rescaled range (R/S) method over the last `n` bars.
3145    ///
3146    /// H ≈ 0.5 → random walk; H > 0.5 → trending; H < 0.5 → mean-reverting.
3147    /// Returns `None` if `n < 8` or fewer than `n + 1` bars exist.
3148    pub fn hurst_exponent(&self, n: usize) -> Option<f64> {
3149        if n < 8 || self.bars.len() < n + 1 {
3150            return None;
3151        }
3152        use rust_decimal::prelude::ToPrimitive;
3153        let returns: Vec<f64> = self
3154            .returns_series(n)
3155            .iter()
3156            .map(|r| r.to_f64().unwrap_or(0.0))
3157            .collect();
3158        if returns.is_empty() {
3159            return None;
3160        }
3161        let mean = returns.iter().sum::<f64>() / returns.len() as f64;
3162        let cum: Vec<f64> = returns.iter().scan(0.0f64, |acc, &r| { *acc += r - mean; Some(*acc) }).collect();
3163        let r = cum.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
3164            - cum.iter().cloned().fold(f64::INFINITY, f64::min);
3165        let s = (returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64).sqrt();
3166        if s == 0.0 || r <= 0.0 {
3167            return None;
3168        }
3169        Some((r / s).ln() / (returns.len() as f64).ln())
3170    }
3171
3172    /// Ulcer Index over the last `n` bars: RMS of percentage drawdowns from rolling peak.
3173    ///
3174    /// A measure of downside volatility; higher = more painful drawdowns.
3175    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
3176    pub fn ulcer_index(&self, n: usize) -> Option<Decimal> {
3177        if n == 0 || self.bars.len() < n {
3178            return None;
3179        }
3180        use rust_decimal::prelude::ToPrimitive;
3181        let start = self.bars.len() - n;
3182        let slice = &self.bars[start..];
3183        let mut peak = Decimal::ZERO;
3184        let mut sum_sq = 0.0f64;
3185        for b in slice {
3186            let c = b.close.value();
3187            if c > peak { peak = c; }
3188            if peak.is_zero() { continue; }
3189            let dd_pct = ((c - peak) / peak * Decimal::ONE_HUNDRED).to_f64().unwrap_or(0.0);
3190            sum_sq += dd_pct * dd_pct;
3191        }
3192        let ui = (sum_sq / n as f64).sqrt();
3193        Decimal::try_from(ui).ok()
3194    }
3195
3196    /// Conditional Value-at-Risk (CVaR / Expected Shortfall) at `confidence_pct` over last `n` bars.
3197    ///
3198    /// Returns the average of log returns below the VaR quantile.
3199    /// Returns `None` if `n < 2`, `confidence_pct` is out of `(0, 100)`, or there are
3200    /// fewer than `n + 1` bars.
3201    pub fn cvar(&self, n: usize, confidence_pct: Decimal) -> Option<Decimal> {
3202        use rust_decimal::prelude::ToPrimitive;
3203        if n < 2 || confidence_pct <= Decimal::ZERO || confidence_pct >= Decimal::ONE_HUNDRED {
3204            return None;
3205        }
3206        let mut returns = self.returns_series(n);
3207        if returns.len() < 2 {
3208            return None;
3209        }
3210        returns.sort_unstable_by(|a, b| a.cmp(b));
3211        let cutoff = ((Decimal::ONE - confidence_pct / Decimal::ONE_HUNDRED)
3212            .to_f64()
3213            .unwrap_or(0.05)
3214            * returns.len() as f64)
3215            .ceil() as usize;
3216        let tail = &returns[..cutoff.min(returns.len())];
3217        if tail.is_empty() {
3218            return None;
3219        }
3220        #[allow(clippy::cast_possible_truncation)]
3221        let avg = tail.iter().copied().sum::<Decimal>() / Decimal::from(tail.len() as u32);
3222        Some(avg)
3223    }
3224
3225    /// Returns the percentage change in close price over the last `n` bars.
3226    ///
3227    /// Formula: `(close[-1] - close[-n-1]) / close[-n-1] * 100`.
3228    /// Returns `None` if `n == 0`, fewer than `n + 1` bars exist, or the earlier close is zero.
3229    pub fn close_change_pct(&self, n: usize) -> Option<Decimal> {
3230        if n == 0 || self.bars.len() <= n {
3231            return None;
3232        }
3233        let recent = self.bars.last()?.close.value();
3234        let earlier = self.bars[self.bars.len() - 1 - n].close.value();
3235        if earlier.is_zero() {
3236            return None;
3237        }
3238        Some((recent - earlier) / earlier * Decimal::ONE_HUNDRED)
3239    }
3240
3241    /// Fraction of the last `n` closes that are above the VWAP over that window.
3242    /// Returns `None` if `n` is 0, fewer than `n` bars exist, or total volume is zero.
3243    pub fn close_above_vwap_pct(&self, n: usize) -> Option<f64> {
3244        if n == 0 || self.bars.len() < n { return None; }
3245        let start = self.bars.len() - n;
3246        let window = &self.bars[start..];
3247        let total_vol: Decimal = window.iter().map(|b| b.volume.value()).sum();
3248        if total_vol.is_zero() { return None; }
3249        let vwap = window.iter()
3250            .map(|b| b.typical_price() * b.volume.value())
3251            .sum::<Decimal>() / total_vol;
3252        let above = window.iter().filter(|b| b.close.value() > vwap).count();
3253        Some(above as f64 / n as f64 * 100.0)
3254    }
3255
3256    /// Count of direction reversals in the last `n` closes (close switches from up to down or
3257    /// vice versa). Returns 0 if `n` < 2 or there are insufficient bars.
3258    pub fn reversal_count(&self, n: usize) -> usize {
3259        if n < 2 || self.bars.len() < n { return 0; }
3260        let start = self.bars.len() - n;
3261        self.bars[start..].windows(3)
3262            .filter(|w| {
3263                let prev_dir = w[1].close.value() > w[0].close.value();
3264                let curr_dir = w[2].close.value() > w[1].close.value();
3265                prev_dir != curr_dir
3266            })
3267            .count()
3268    }
3269
3270    /// Percentage of the last `n` bars where a gap open (open != prior close) was filled
3271    /// (i.e., price returned to the prior close within the same bar). Returns `None` if `n` is 0
3272    /// or there are insufficient bars.
3273    pub fn open_gap_fill_rate(&self, n: usize) -> Option<f64> {
3274        if n == 0 || self.bars.len() < n + 1 { return None; }
3275        let start = self.bars.len() - n;
3276        let mut gap_count = 0usize;
3277        let mut filled = 0usize;
3278        for i in start..self.bars.len() {
3279            let prior_close = self.bars[i - 1].close.value();
3280            let bar = &self.bars[i];
3281            let open = bar.open.value();
3282            if open == prior_close { continue; }
3283            gap_count += 1;
3284            let gap_up = open > prior_close;
3285            if gap_up && bar.low.value() <= prior_close {
3286                filled += 1;
3287            } else if !gap_up && bar.high.value() >= prior_close {
3288                filled += 1;
3289            }
3290        }
3291        if gap_count == 0 { return None; }
3292        Some(filled as f64 / gap_count as f64 * 100.0)
3293    }
3294
3295    /// Average candle symmetry over the last `n` bars: ratio of lower shadow to upper shadow
3296    /// where 1.0 means perfectly symmetric. Returns `None` if `n` is 0, fewer than `n` bars
3297    /// exist, or no bar has any shadows.
3298    pub fn candle_symmetry(&self, n: usize) -> Option<f64> {
3299        if n == 0 || self.bars.len() < n { return None; }
3300        let start = self.bars.len() - n;
3301        let mut ratios = Vec::new();
3302        for bar in &self.bars[start..] {
3303            let body_top = bar.close.value().max(bar.open.value());
3304            let body_bot = bar.close.value().min(bar.open.value());
3305            let upper = bar.high.value() - body_top;
3306            let lower = body_bot - bar.low.value();
3307            if upper.is_zero() && lower.is_zero() { continue; }
3308            let total = upper + lower;
3309            if total.is_zero() { continue; }
3310            use rust_decimal::prelude::ToPrimitive;
3311            let ratio: f64 = lower.to_f64().unwrap_or(0.0)
3312                / total.to_f64().unwrap_or(1.0);
3313            ratios.push(ratio);
3314        }
3315        if ratios.is_empty() { return None; }
3316        Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
3317    }
3318
3319}
3320
3321impl Default for OhlcvSeries {
3322    fn default() -> Self {
3323        Self::new()
3324    }
3325}
3326
3327impl<'a> IntoIterator for &'a OhlcvSeries {
3328    type Item = &'a OhlcvBar;
3329    type IntoIter = std::slice::Iter<'a, OhlcvBar>;
3330
3331    fn into_iter(self) -> Self::IntoIter {
3332        self.bars.iter()
3333    }
3334}
3335
3336fn decimal_sqrt(n: Decimal) -> Result<Decimal, FinError> {
3337    if n.is_zero() {
3338        return Ok(Decimal::ZERO);
3339    }
3340    if n.is_sign_negative() {
3341        return Err(FinError::ArithmeticOverflow);
3342    }
3343    let mut x = n;
3344    for _ in 0..20 {
3345        let next = (x + n / x) / Decimal::TWO;
3346        let diff = if next > x { next - x } else { x - next };
3347        x = next;
3348        if diff < Decimal::new(1, 10) {
3349            break;
3350        }
3351    }
3352    Ok(x)
3353}
3354
3355impl OhlcvSeries {
3356    /// Counts the longest consecutive drawdown run: the maximum number of bars where
3357    /// each bar's close is strictly below the previous bar's close.
3358    ///
3359    /// Returns `0` when the series has fewer than 2 bars.
3360    pub fn max_drawdown_duration(&self) -> usize {
3361        if self.bars.len() < 2 {
3362            return 0;
3363        }
3364        let mut max_run = 0usize;
3365        let mut current = 0usize;
3366        for i in 1..self.bars.len() {
3367            if self.bars[i].close.value() < self.bars[i - 1].close.value() {
3368                current += 1;
3369                if current > max_run {
3370                    max_run = current;
3371                }
3372            } else {
3373                current = 0;
3374            }
3375        }
3376        max_run
3377    }
3378
3379    /// Percentage of the last `n` bars where close > open (bullish bar ratio).
3380    ///
3381    /// Returns `None` if `n == 0` or series has fewer than `n` bars.
3382    /// Returns `0.0` when all bars are bearish/doji, `100.0` when all are bullish.
3383    pub fn close_above_open_pct(&self, n: usize) -> Option<f64> {
3384        if n == 0 || self.bars.len() < n {
3385            return None;
3386        }
3387        let start = self.bars.len() - n;
3388        let count = self.bars[start..]
3389            .iter()
3390            .filter(|b| b.is_bullish())
3391            .count();
3392        Some(count as f64 / n as f64 * 100.0)
3393    }
3394
3395    /// Average wick-to-range ratio over the last `n` bars.
3396    ///
3397    /// For each bar: `wick_ratio = (upper_shadow + lower_shadow) / range`.
3398    /// Bars with zero range are excluded from the average.
3399    ///
3400    /// Returns `None` if `n == 0`, series has fewer than `n` bars, or no bar has a
3401    /// non-zero range.
3402    pub fn avg_wick_ratio(&self, n: usize) -> Option<f64> {
3403        use rust_decimal::prelude::ToPrimitive;
3404        if n == 0 || self.bars.len() < n {
3405            return None;
3406        }
3407        let start = self.bars.len() - n;
3408        let mut sum = 0.0f64;
3409        let mut count = 0usize;
3410        for b in &self.bars[start..] {
3411            let range = b.range();
3412            if !range.is_zero() {
3413                let wick = b.upper_shadow() + b.lower_shadow();
3414                if let Some(ratio) = (wick / range).to_f64() {
3415                    sum += ratio;
3416                    count += 1;
3417                }
3418            }
3419        }
3420        if count == 0 {
3421            return None;
3422        }
3423        Some(sum / count as f64)
3424    }
3425
3426    /// Average ratio of up-day return to down-day return magnitude over the last `n` bars.
3427    ///
3428    /// Computes log returns; averages positive returns as "gains" and the absolute value of
3429    /// negative returns as "losses".  Returns `None` if `n == 0`, fewer than `n+1` bars exist,
3430    /// or there are no losing bars (avoiding division by zero).
3431    pub fn gain_loss_ratio(&self, n: usize) -> Option<f64> {
3432        if n == 0 || self.bars.len() < n + 1 {
3433            return None;
3434        }
3435        use rust_decimal::prelude::ToPrimitive;
3436        let start = self.bars.len() - n - 1;
3437        let slice = &self.bars[start..];
3438        let mut gains = 0.0f64;
3439        let mut losses = 0.0f64;
3440        let mut gain_count = 0usize;
3441        let mut loss_count = 0usize;
3442        for w in slice.windows(2) {
3443            let pc = w[0].close.value().to_f64()?;
3444            let cc = w[1].close.value().to_f64()?;
3445            if pc <= 0.0 { continue; }
3446            let r = (cc / pc).ln();
3447            if r > 0.0 {
3448                gains += r;
3449                gain_count += 1;
3450            } else if r < 0.0 {
3451                losses += r.abs();
3452                loss_count += 1;
3453            }
3454        }
3455        if loss_count == 0 || losses == 0.0 {
3456            return None;
3457        }
3458        let avg_gain = gains / gain_count.max(1) as f64;
3459        let avg_loss = losses / loss_count as f64;
3460        Some(avg_gain / avg_loss)
3461    }
3462
3463    /// Count of bars in the last `n` bars where `close > SMA(close, sma_period)` at that bar.
3464    ///
3465    /// The SMA is computed as a rolling SMA ending at each bar.  Bars that do not yet have
3466    /// enough history for the SMA are skipped.  Returns `None` if `n == 0` or the series has
3467    /// fewer than `n` bars.
3468    pub fn bars_above_sma(&self, n: usize, sma_period: usize) -> Option<usize> {
3469        if n == 0 || sma_period == 0 || self.bars.len() < n {
3470            return None;
3471        }
3472        let start = self.bars.len() - n;
3473        let mut count = 0usize;
3474        for i in start..self.bars.len() {
3475            if i + 1 < sma_period {
3476                continue;
3477            }
3478            let sma_start = i + 1 - sma_period;
3479            let sum: Decimal = self.bars[sma_start..=i]
3480                .iter()
3481                .map(|b| b.close.value())
3482                .sum();
3483            let sma = sum / Decimal::from(sma_period as u32);
3484            if self.bars[i].close.value() > sma {
3485                count += 1;
3486            }
3487        }
3488        Some(count)
3489    }
3490
3491    /// Distance of the current close above the lowest low in the last `n` bars.
3492    ///
3493    /// `close_distance_from_low = close[last] - min(low, n)`.
3494    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
3495    pub fn close_distance_from_low(&self, n: usize) -> Option<Decimal> {
3496        if n == 0 || self.bars.len() < n {
3497            return None;
3498        }
3499        let start = self.bars.len() - n;
3500        let min_low = self.bars[start..]
3501            .iter()
3502            .map(|b| b.low.value())
3503            .reduce(Decimal::min)?;
3504        let last_close = self.bars.last()?.close.value();
3505        Some(last_close - min_low)
3506    }
3507
3508    /// Ratio of the latest bar's volume to the average volume over the last `n` bars.
3509    ///
3510    /// `volume_ratio = last_volume / avg_volume(n)`.
3511    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or average volume is zero.
3512    pub fn volume_ratio(&self, n: usize) -> Option<Decimal> {
3513        if n == 0 || self.bars.len() < n {
3514            return None;
3515        }
3516        let start = self.bars.len() - n;
3517        let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
3518        let avg = sum.checked_div(Decimal::from(n as u32))?;
3519        if avg.is_zero() {
3520            return None;
3521        }
3522        let last_vol = self.bars.last()?.volume.value();
3523        last_vol.checked_div(avg)
3524    }
3525
3526    /// Momentum quality: fraction of up-closes among `n` bars where volume was above average.
3527    ///
3528    /// High-volume up days are "quality" momentum; this method returns the ratio of
3529    /// high-volume up closes to total high-volume bars.  Returns `None` if `n == 0`,
3530    /// fewer than `n` bars exist, or no bar in the window has above-average volume.
3531    pub fn momentum_quality(&self, n: usize) -> Option<f64> {
3532        if n == 0 || self.bars.len() < n {
3533            return None;
3534        }
3535        let start = self.bars.len() - n;
3536        let slice = &self.bars[start..];
3537        let avg_vol: Decimal = {
3538            let s: Decimal = slice.iter().map(|b| b.volume.value()).sum();
3539            s.checked_div(Decimal::from(n as u32))?
3540        };
3541        let mut high_vol_bars = 0usize;
3542        let mut high_vol_up = 0usize;
3543        for b in slice {
3544            if b.volume.value() > avg_vol {
3545                high_vol_bars += 1;
3546                if b.close > b.open {
3547                    high_vol_up += 1;
3548                }
3549            }
3550        }
3551        if high_vol_bars == 0 {
3552            return None;
3553        }
3554        Some(high_vol_up as f64 / high_vol_bars as f64)
3555    }
3556
3557    /// Fraction of the last `n` bars that are bullish (close > open), as a value in `[0.0, 1.0]`.
3558    ///
3559    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3560    pub fn bullish_candle_pct(&self, n: usize) -> Option<f64> {
3561        if n == 0 || self.bars.len() < n {
3562            return None;
3563        }
3564        let start = self.bars.len() - n;
3565        let bullish = self.bars[start..].iter().filter(|b| b.close > b.open).count();
3566        Some(bullish as f64 / n as f64)
3567    }
3568
3569    /// Fraction of the last `n` bars where close was above the `period`-bar SMA of closes,
3570    /// as a value in `[0.0, 1.0]`.
3571    ///
3572    /// Returns `None` if `n == 0`, `period == 0`, or the series has fewer than `n + period - 1`
3573    /// bars (not enough history to compute the SMA for all `n` windows).
3574    pub fn price_above_ma_pct(&self, n: usize, period: usize) -> Option<f64> {
3575        if n == 0 || period == 0 || self.bars.len() < n + period - 1 {
3576            return None;
3577        }
3578        let total = self.bars.len();
3579        let mut above = 0usize;
3580        for i in (total - n)..total {
3581            let sma_start = i + 1 - period;
3582            let sma: Decimal = self.bars[sma_start..=i]
3583                .iter()
3584                .map(|b| b.close.value())
3585                .sum::<Decimal>()
3586                / Decimal::from(period as u32);
3587            if self.bars[i].close.value() > sma {
3588                above += 1;
3589            }
3590        }
3591        Some(above as f64 / n as f64)
3592    }
3593
3594    /// Returns the last `n` true-range values as a `Vec<Decimal>`.
3595    ///
3596    /// True range for bar `i` = `max(high, prev_close) − min(low, prev_close)`.
3597    /// The first bar in the series has no previous close, so it contributes `high − low`.
3598    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3599    pub fn true_range_series(&self, n: usize) -> Option<Vec<Decimal>> {
3600        if n == 0 || self.bars.len() < n {
3601            return None;
3602        }
3603        let start = self.bars.len() - n;
3604        let trs: Vec<Decimal> = self.bars[start..]
3605            .iter()
3606            .enumerate()
3607            .map(|(i, bar)| {
3608                let abs_i = start + i;
3609                if abs_i == 0 {
3610                    bar.range()
3611                } else {
3612                    let prev_close = self.bars[abs_i - 1].close.value();
3613                    let high = bar.high.value().max(prev_close);
3614                    let low = bar.low.value().min(prev_close);
3615                    high - low
3616                }
3617            })
3618            .collect();
3619        Some(trs)
3620    }
3621
3622    /// Returns `(last_close − first_open) / first_open × 100` as a percentage.
3623    ///
3624    /// Measures the net intraday move across the entire series.
3625    /// Returns `None` if the series has fewer than 1 bar or `first_open` is zero.
3626    pub fn intraday_return_pct(&self) -> Option<Decimal> {
3627        if self.bars.is_empty() {
3628            return None;
3629        }
3630        let first_open = self.bars.first()?.open.value();
3631        if first_open.is_zero() {
3632            return None;
3633        }
3634        let last_close = self.bars.last()?.close.value();
3635        Some((last_close - first_open) / first_open * Decimal::ONE_HUNDRED)
3636    }
3637
3638    /// Count of bars in the last `n` where `close < open` (bearish bars).
3639    ///
3640    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3641    pub fn bearish_bar_count(&self, n: usize) -> Option<usize> {
3642        if n == 0 || self.bars.len() < n {
3643            return None;
3644        }
3645        let start = self.bars.len() - n;
3646        Some(self.bars[start..].iter().filter(|b| b.close < b.open).count())
3647    }
3648
3649    /// Average body size (|close − open|) over the last `n` bars.
3650    ///
3651    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3652    pub fn avg_body_size(&self, n: usize) -> Option<Decimal> {
3653        if n == 0 || self.bars.len() < n {
3654            return None;
3655        }
3656        let start = self.bars.len() - n;
3657        let sum: Decimal = self.bars[start..]
3658            .iter()
3659            .map(|b| b.body_size())
3660            .sum();
3661        Some(sum / Decimal::from(n as u32))
3662    }
3663
3664    /// Average `(high + low) / 2` midpoint over the last `n` bars.
3665    ///
3666    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3667    pub fn hl_midpoint(&self, n: usize) -> Option<Decimal> {
3668        if n == 0 || self.bars.len() < n {
3669            return None;
3670        }
3671        let start = self.bars.len() - n;
3672        let sum: Decimal = self.bars[start..]
3673            .iter()
3674            .map(|b| b.midpoint())
3675            .sum();
3676        #[allow(clippy::cast_possible_truncation)]
3677        Some(sum / Decimal::from(n as u32))
3678    }
3679
3680    /// Ratio of volume on up-bars (`close > open`) to total volume over the last `n` bars.
3681    ///
3682    /// Returns `None` if `n == 0`, the series has fewer than `n` bars, or total volume is zero.
3683    pub fn up_volume_ratio(&self, n: usize) -> Option<Decimal> {
3684        if n == 0 || self.bars.len() < n {
3685            return None;
3686        }
3687        let start = self.bars.len() - n;
3688        let total_vol: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
3689        if total_vol.is_zero() {
3690            return None;
3691        }
3692        let up_vol: Decimal = self.bars[start..]
3693            .iter()
3694            .filter(|b| b.close > b.open)
3695            .map(|b| b.volume.value())
3696            .sum();
3697        up_vol.checked_div(total_vol)
3698    }
3699
3700    /// Directional efficiency of price movement over the last `n` bars.
3701    ///
3702    /// `efficiency = |close[-1] − close[-n]| / Σ|close[i] − close[i-1]|`
3703    ///
3704    /// - 1.0 = perfectly trending (straight line).
3705    /// - Near 0 = choppy (path much longer than net displacement).
3706    ///
3707    /// Returns `None` if `n < 2`, the series has fewer than `n` bars, or total path is zero.
3708    pub fn price_efficiency(&self, n: usize) -> Option<Decimal> {
3709        if n < 2 || self.bars.len() < n {
3710            return None;
3711        }
3712        let start = self.bars.len() - n;
3713        let net = (self.bars.last()?.close.value() - self.bars[start].close.value()).abs();
3714        let path: Decimal = self.bars[start..]
3715            .windows(2)
3716            .map(|w| (w[1].close.value() - w[0].close.value()).abs())
3717            .sum();
3718        if path.is_zero() {
3719            return None;
3720        }
3721        net.checked_div(path)
3722    }
3723
3724    /// Mean absolute gap (`|open[i] − close[i-1]|`) over the last `n` bars.
3725    ///
3726    /// Measures average overnight jump between bars.
3727    /// Returns `None` if `n == 0` or the series has fewer than `n + 1` bars
3728    /// (need one prior bar for each gap).
3729    pub fn avg_gap(&self, n: usize) -> Option<Decimal> {
3730        if n == 0 || self.bars.len() < n + 1 {
3731            return None;
3732        }
3733        let start = self.bars.len() - n;
3734        let sum: Decimal = (start..self.bars.len())
3735            .map(|i| (self.bars[i].open.value() - self.bars[i - 1].close.value()).abs())
3736            .sum();
3737        #[allow(clippy::cast_possible_truncation)]
3738        Some(sum / Decimal::from(n as u32))
3739    }
3740
3741    /// Population variance of log-returns over the last `n + 1` bars.
3742    ///
3743    /// `log_return[i] = ln(close[i] / close[i-1])`.
3744    /// Requires `n + 1` closes → `n` log-returns.
3745    /// Returns `None` if `n < 2` or the series has fewer than `n + 1` bars.
3746    pub fn realized_variance(&self, n: usize) -> Option<f64> {
3747        if n < 2 || self.bars.len() < n + 1 {
3748            return None;
3749        }
3750        let start = self.bars.len() - (n + 1);
3751        let mut rets = Vec::with_capacity(n);
3752        for i in (start + 1)..=(start + n) {
3753            let prev = self.bars[i - 1].close.value();
3754            let curr = self.bars[i].close.value();
3755            use rust_decimal::prelude::ToPrimitive;
3756            let r = prev.to_f64()?;
3757            let c = curr.to_f64()?;
3758            if r <= 0.0 { return None; }
3759            rets.push((c / r).ln());
3760        }
3761        let mean = rets.iter().sum::<f64>() / rets.len() as f64;
3762        let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
3763        Some(var)
3764    }
3765
3766    /// Mean signed close-to-close change per bar over the last `n` bars.
3767    ///
3768    /// `velocity = (close[-1] - close[-n]) / n`
3769    ///
3770    /// Returns `None` if `n < 2` or the series has fewer than `n` bars.
3771    pub fn close_velocity(&self, n: usize) -> Option<Decimal> {
3772        if n < 2 || self.bars.len() < n {
3773            return None;
3774        }
3775        let start = self.bars.len() - n;
3776        let delta = self.bars.last()?.close.value() - self.bars[start].close.value();
3777        #[allow(clippy::cast_possible_truncation)]
3778        delta.checked_div(Decimal::from(n as u32))
3779    }
3780
3781    /// Mean upper wick length `(high − max(open, close))` over the last `n` bars.
3782    ///
3783    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3784    pub fn avg_upper_wick(&self, n: usize) -> Option<Decimal> {
3785        if n == 0 || self.bars.len() < n {
3786            return None;
3787        }
3788        let start = self.bars.len() - n;
3789        let sum: Decimal = self.bars[start..]
3790            .iter()
3791            .map(|b| b.upper_shadow())
3792            .sum();
3793        #[allow(clippy::cast_possible_truncation)]
3794        Some(sum / Decimal::from(n as u32))
3795    }
3796
3797    /// Median `(high + low) / 2` midpoint value over the last `n` bars.
3798    ///
3799    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3800    pub fn median_price(&self, n: usize) -> Option<Decimal> {
3801        if n == 0 || self.bars.len() < n {
3802            return None;
3803        }
3804        let start = self.bars.len() - n;
3805        let mut mids: Vec<Decimal> = self.bars[start..]
3806            .iter()
3807            .map(|b| b.midpoint())
3808            .collect();
3809        mids.sort();
3810        let mid = n / 2;
3811        if n % 2 == 0 {
3812            Some((mids[mid - 1] + mids[mid]) / Decimal::TWO)
3813        } else {
3814            Some(mids[mid])
3815        }
3816    }
3817
3818    /// Mean upper-shadow ratio `(high − max(open,close)) / (high − low)` over the last `n` bars.
3819    ///
3820    /// Bars where `high == low` (doji) contribute 0. Returns `None` if `n == 0`
3821    /// or the series has fewer than `n` bars.
3822    pub fn upper_shadow_ratio(&self, n: usize) -> Option<Decimal> {
3823        if n == 0 || self.bars.len() < n {
3824            return None;
3825        }
3826        let start = self.bars.len() - n;
3827        let sum: Decimal = self.bars[start..]
3828            .iter()
3829            .map(|b| {
3830                let range = b.range();
3831                if range.is_zero() {
3832                    Decimal::ZERO
3833                } else {
3834                    (b.upper_shadow()) / range
3835                }
3836            })
3837            .sum();
3838        #[allow(clippy::cast_possible_truncation)]
3839        Some(sum / Decimal::from(n as u32))
3840    }
3841
3842    /// Fraction of bars in the last `n + 1` where `open[i] > close[i-1]` (gap up).
3843    ///
3844    /// Returns `None` if `n == 0` or the series has fewer than `n + 1` bars.
3845    pub fn percent_gap_up_bars(&self, n: usize) -> Option<Decimal> {
3846        if n == 0 || self.bars.len() < n + 1 {
3847            return None;
3848        }
3849        let start = self.bars.len() - n;
3850        let count = (start..self.bars.len())
3851            .filter(|&i| self.bars[i].open > self.bars[i - 1].close)
3852            .count();
3853        #[allow(clippy::cast_possible_truncation)]
3854        Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
3855    }
3856
3857    /// Length of the longest run of consecutive higher closes within the last `n` bars.
3858    ///
3859    /// A "higher close" means `close[i] > close[i-1]`.  The run is computed across
3860    /// consecutive comparisons (not against a fixed baseline).
3861    ///
3862    /// Returns `None` if `n < 2` or the series has fewer than `n` bars.
3863    pub fn consecutive_higher_closes(&self, n: usize) -> Option<usize> {
3864        if n < 2 || self.bars.len() < n {
3865            return None;
3866        }
3867        let start = self.bars.len() - n;
3868        let mut max_run = 0usize;
3869        let mut cur_run = 0usize;
3870        for i in (start + 1)..self.bars.len() {
3871            if self.bars[i].close > self.bars[i - 1].close {
3872                cur_run += 1;
3873                if cur_run > max_run { max_run = cur_run; }
3874            } else {
3875                cur_run = 0;
3876            }
3877        }
3878        Some(max_run)
3879    }
3880
3881    /// Volume-weighted average return over the last `n` bars.
3882    ///
3883    /// `return[i] = (close[i] - close[i-1]) / close[i-1]`; each return is weighted by
3884    /// the volume of bar `i`.  Bars with zero prior close are excluded from the sum.
3885    ///
3886    /// Returns `None` if `n < 2`, the series has fewer than `n` bars, or total volume is zero.
3887    pub fn volume_weighted_return(&self, n: usize) -> Option<Decimal> {
3888        if n < 2 || self.bars.len() < n {
3889            return None;
3890        }
3891        let start = self.bars.len() - n;
3892        let mut vol_return_sum = Decimal::ZERO;
3893        let mut vol_sum = Decimal::ZERO;
3894        for i in (start + 1)..self.bars.len() {
3895            let prev_close = self.bars[i - 1].close.value();
3896            if prev_close.is_zero() { continue; }
3897            let ret = (self.bars[i].close.value() - prev_close) / prev_close;
3898            let vol = self.bars[i].volume.value();
3899            vol_return_sum += ret * vol;
3900            vol_sum += vol;
3901        }
3902        if vol_sum.is_zero() {
3903            return None;
3904        }
3905        Some(vol_return_sum / vol_sum)
3906    }
3907
3908    /// Returns arithmetic close-to-close returns for the last `n` bars as `(close[i] - close[i-1]) / close[i-1]`.
3909    ///
3910    /// The result has `n - 1` entries (each bar needs a previous bar to compute a return).
3911    /// Returns `None` if `n < 2` or the series has fewer than `n` bars.
3912    pub fn close_returns(&self, n: usize) -> Option<Vec<Decimal>> {
3913        if n < 2 || self.bars.len() < n {
3914            return None;
3915        }
3916        let start = self.bars.len() - n;
3917        let mut returns = Vec::with_capacity(n - 1);
3918        for i in (start + 1)..self.bars.len() {
3919            let prev = self.bars[i - 1].close.value();
3920            if prev.is_zero() {
3921                returns.push(Decimal::ZERO);
3922            } else {
3923                returns.push((self.bars[i].close.value() - prev) / prev);
3924            }
3925        }
3926        Some(returns)
3927    }
3928
3929    /// Classifies recent volatility as `"low"`, `"medium"`, or `"high"` by comparing
3930    /// the average ATR of the last `atr_period` bars to its own mean over the last `lookback` bars.
3931    ///
3932    /// - **low**: latest ATR < 80% of the rolling mean
3933    /// - **high**: latest ATR > 120% of the rolling mean
3934    /// - **medium**: otherwise
3935    ///
3936    /// Returns `None` if there are fewer than `lookback + 1` bars (need history to compute ATR)
3937    /// or if `atr_period == 0` or `lookback == 0`.
3938    pub fn volatility_regime(&self, atr_period: usize, lookback: usize) -> Option<&'static str> {
3939        if atr_period == 0 || lookback == 0 {
3940            return None;
3941        }
3942        let needed = lookback + atr_period;
3943        if self.bars.len() < needed {
3944            return None;
3945        }
3946        let atr_series = self.atr_series(atr_period);
3947        let recent_atrs: Vec<Decimal> = atr_series
3948            .iter()
3949            .rev()
3950            .take(lookback)
3951            .filter_map(|v| *v)
3952            .collect();
3953        if recent_atrs.is_empty() {
3954            return None;
3955        }
3956        let mean: Decimal = recent_atrs.iter().copied().sum::<Decimal>()
3957            / Decimal::from(recent_atrs.len() as u32);
3958        if mean.is_zero() {
3959            return Some("medium");
3960        }
3961        let latest = *recent_atrs.first()?;
3962        let ratio = latest / mean;
3963        if ratio < Decimal::new(80, 2) {
3964            Some("low")
3965        } else if ratio > Decimal::new(120, 2) {
3966            Some("high")
3967        } else {
3968            Some("medium")
3969        }
3970    }
3971
3972    /// Ratio of total volume on up-bars to total volume on down-bars over the last `n` bars.
3973    ///
3974    /// An up-bar is `close > open`; a down-bar is `close < open`. Doji bars are excluded.
3975    ///
3976    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or there are no down-bars.
3977    pub fn up_down_volume_ratio(&self, n: usize) -> Option<Decimal> {
3978        if n == 0 || self.bars.len() < n {
3979            return None;
3980        }
3981        let start = self.bars.len() - n;
3982        let mut up_vol = Decimal::ZERO;
3983        let mut dn_vol = Decimal::ZERO;
3984        for b in &self.bars[start..] {
3985            let vol = b.volume.value();
3986            if b.close > b.open { up_vol += vol; }
3987            else if b.close < b.open { dn_vol += vol; }
3988        }
3989        if dn_vol.is_zero() { return None; }
3990        Some(up_vol / dn_vol)
3991    }
3992
3993    /// Average bar range (high − low) as a percentage of the typical price, over the last `n` bars.
3994    ///
3995    /// `typical = (H + L + C) / 3`. Bars with zero typical price are excluded.
3996    ///
3997    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or no bar has positive typical price.
3998    pub fn avg_range_pct(&self, n: usize) -> Option<Decimal> {
3999        if n == 0 || self.bars.len() < n {
4000            return None;
4001        }
4002        let start = self.bars.len() - n;
4003        let mut sum = Decimal::ZERO;
4004        let mut count = 0usize;
4005        let hundred = Decimal::from(100u32);
4006        for b in &self.bars[start..] {
4007            let tp = b.typical_price();
4008            if tp.is_zero() { continue; }
4009            sum += (b.range()) / tp * hundred;
4010            count += 1;
4011        }
4012        if count == 0 { return None; }
4013        Some(sum / Decimal::from(count as u32))
4014    }
4015
4016    /// Bar efficiency over the last `n` bars: net directional move / total path length.
4017    ///
4018    /// `efficiency = |close[last] - close[first]| / Σ|close[i] - close[i-1]|`
4019    ///
4020    /// A value of 1.0 means perfectly directional; near 0 means highly erratic.
4021    ///
4022    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or total path is zero.
4023    pub fn bar_efficiency(&self, n: usize) -> Option<f64> {
4024        use rust_decimal::prelude::ToPrimitive;
4025        if n < 2 || self.bars.len() < n {
4026            return None;
4027        }
4028        let start = self.bars.len() - n;
4029        let net = (self.bars.last().unwrap().close.value()
4030            - self.bars[start].close.value())
4031            .abs()
4032            .to_f64()
4033            .unwrap_or(0.0);
4034        let path: f64 = (start + 1..self.bars.len())
4035            .map(|i| {
4036                (self.bars[i].close.value() - self.bars[i - 1].close.value())
4037                    .abs()
4038                    .to_f64()
4039                    .unwrap_or(0.0)
4040            })
4041            .sum();
4042        if path == 0.0 { return None; }
4043        Some(net / path)
4044    }
4045
4046    /// Average number of bars between successive new `n`-bar highs in the last `m` bars.
4047    ///
4048    /// A new high at bar `i` means `close[i] > max(close[i-n..i])`.
4049    ///
4050    /// Returns `None` if `m <= n`, fewer than `m` bars exist, or no new high is found.
4051    pub fn avg_bars_between_highs(&self, n: usize, m: usize) -> Option<f64> {
4052        if n == 0 || m <= n || self.bars.len() < m {
4053            return None;
4054        }
4055        let start = self.bars.len() - m;
4056        let mut high_indices: Vec<usize> = Vec::new();
4057        for i in (start + n)..self.bars.len() {
4058            let prev_max = self.bars[(i - n)..i]
4059                .iter()
4060                .map(|b| b.close.value())
4061                .max()
4062                .unwrap_or(Decimal::ZERO);
4063            if self.bars[i].close.value() > prev_max {
4064                high_indices.push(i);
4065            }
4066        }
4067        if high_indices.len() < 2 { return None; }
4068        let gaps: Vec<usize> = high_indices.windows(2).map(|w| w[1] - w[0]).collect();
4069        Some(gaps.iter().sum::<usize>() as f64 / gaps.len() as f64)
4070    }
4071
4072    /// Number of consecutive bars (from the most recent bar backward) where close exceeded
4073    /// the prior `n`-bar rolling high.
4074    ///
4075    /// A bar at index `i` counts if `close[i] > max(close[i-n..i])`.
4076    /// The first `n` bars of the series are skipped (no prior window).
4077    ///
4078    /// Returns `None` if `n == 0` or the series has fewer than `n + 1` bars.
4079    pub fn breakout_bars(&self, n: usize) -> Option<usize> {
4080        if n == 0 || self.bars.len() <= n {
4081            return None;
4082        }
4083        let mut streak = 0usize;
4084        for i in (n..self.bars.len()).rev() {
4085            let prior_max = self.bars[(i - n)..i]
4086                .iter()
4087                .map(|b| b.close.value())
4088                .max()
4089                .unwrap_or(Decimal::ZERO);
4090            if self.bars[i].close.value() > prior_max {
4091                streak += 1;
4092            } else {
4093                break;
4094            }
4095        }
4096        Some(streak)
4097    }
4098
4099    /// Count of doji candles in the last `n` bars.
4100    ///
4101    /// A bar is a doji when `|close - open| / (high - low) < threshold`.
4102    /// Use `threshold = 0.1` for the classic 10% body rule.
4103    ///
4104    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
4105    pub fn doji_count(&self, n: usize, threshold: f64) -> Option<usize> {
4106        if n == 0 || self.bars.len() < n {
4107            return None;
4108        }
4109        let start = self.bars.len() - n;
4110        use rust_decimal::prelude::ToPrimitive;
4111        let count = self.bars[start..]
4112            .iter()
4113            .filter(|b| {
4114                let range = (b.range()).to_f64().unwrap_or(0.0);
4115                if range == 0.0 {
4116                    return true; // zero-range bar is a perfect doji
4117                }
4118                let body = (b.close.value() - b.open.value())
4119                    .abs()
4120                    .to_f64()
4121                    .unwrap_or(0.0);
4122                body / range < threshold
4123            })
4124            .count();
4125        Some(count)
4126    }
4127
4128    /// Coefficient of variation of closes over the last `n` bars: `std_dev / mean`.
4129    ///
4130    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or mean is zero.
4131    pub fn close_dispersion(&self, n: usize) -> Option<f64> {
4132        use rust_decimal::prelude::ToPrimitive;
4133        if n < 2 || self.bars.len() < n {
4134            return None;
4135        }
4136        let start = self.bars.len() - n;
4137        let vals: Vec<f64> = self.bars[start..]
4138            .iter()
4139            .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4140            .collect();
4141        let mean = vals.iter().sum::<f64>() / n as f64;
4142        if mean == 0.0 { return None; }
4143        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
4144        Some(variance.sqrt() / mean)
4145    }
4146
4147    /// Volume of the most recent bar as a percentage of the average volume over the last `n` bars.
4148    ///
4149    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or average volume is zero.
4150    pub fn relative_volume(&self, n: usize) -> Option<Decimal> {
4151        if n == 0 || self.bars.len() < n {
4152            return None;
4153        }
4154        let start = self.bars.len() - n;
4155        let avg_vol: Decimal = self.bars[start..]
4156            .iter()
4157            .map(|b| b.volume.value())
4158            .sum::<Decimal>()
4159            / Decimal::from(n as u32);
4160        if avg_vol.is_zero() { return None; }
4161        let last_vol = self.bars.last()?.volume.value();
4162        Some(last_vol / avg_vol * Decimal::from(100u32))
4163    }
4164
4165    /// Average midpoint of the open-close range over the last `n` bars.
4166    ///
4167    /// `midpoint[i] = (open[i] + close[i]) / 2`
4168    ///
4169    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4170    pub fn avg_oc_midpoint(&self, n: usize) -> Option<Decimal> {
4171        if n == 0 || self.bars.len() < n {
4172            return None;
4173        }
4174        let start = self.bars.len() - n;
4175        let sum: Decimal = self.bars[start..]
4176            .iter()
4177            .map(|b| (b.open.value() + b.close.value()) / Decimal::TWO)
4178            .sum();
4179        Some(sum / Decimal::from(n as u32))
4180    }
4181
4182    /// Count of bars in the last `n` with volume > `threshold × average_volume`.
4183    ///
4184    /// A `threshold` of `2.0` finds bars with more than twice the average volume.
4185    ///
4186    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or average volume is zero.
4187    pub fn volume_spike_count(&self, n: usize, threshold: Decimal) -> Option<usize> {
4188        if n == 0 || self.bars.len() < n {
4189            return None;
4190        }
4191        let start = self.bars.len() - n;
4192        let avg_vol: Decimal = self.bars[start..]
4193            .iter()
4194            .map(|b| b.volume.value())
4195            .sum::<Decimal>()
4196            / Decimal::from(n as u32);
4197        if avg_vol.is_zero() { return None; }
4198        let limit = avg_vol * threshold;
4199        let count = self.bars[start..].iter().filter(|b| b.volume.value() > limit).count();
4200        Some(count)
4201    }
4202
4203    /// Acceleration of closing prices over the last `n` bars (second derivative).
4204    ///
4205    /// Computes `Δmom = mom[last] - mom[first]` where `mom[i] = close[i] - close[i-1]`.
4206    /// Requires at least `n + 2` bars in the series.
4207    ///
4208    /// Returns `None` if `n == 0` or fewer than `n + 2` bars exist.
4209    pub fn close_acceleration(&self, n: usize) -> Option<Decimal> {
4210        if n == 0 || self.bars.len() < n + 2 {
4211            return None;
4212        }
4213        let total = self.bars.len();
4214        let last_mom = self.bars[total - 1].close.value() - self.bars[total - 2].close.value();
4215        let first_idx = total - n - 1;
4216        let first_mom = self.bars[first_idx + 1].close.value() - self.bars[first_idx].close.value();
4217        Some(last_mom - first_mom)
4218    }
4219
4220    /// Ratio of up-close bars to down-close bars over the last `n` bars.
4221    ///
4222    /// A bar is "up" if `close > open` and "down" if `close < open`. Doji bars are ignored.
4223    ///
4224    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or there are no down bars.
4225    pub fn up_down_ratio(&self, n: usize) -> Option<Decimal> {
4226        if n == 0 || self.bars.len() < n {
4227            return None;
4228        }
4229        let start = self.bars.len() - n;
4230        let ups = self.bars[start..].iter().filter(|b| b.is_bullish()).count();
4231        let downs = self.bars[start..].iter().filter(|b| b.is_bearish()).count();
4232        if downs == 0 {
4233            return None;
4234        }
4235        Some(Decimal::from(ups as u32) / Decimal::from(downs as u32))
4236    }
4237
4238    /// Count of consecutive up-close bars at the end of the series, capped at `n`.
4239    ///
4240    /// Returns `0` if the series is empty or the last bar is not up, `n` if all `n`
4241    /// trailing bars are up. Returns `None` if `n == 0` or fewer than 1 bar exists.
4242    pub fn consecutive_up_bars(&self, n: usize) -> Option<usize> {
4243        if n == 0 || self.bars.is_empty() {
4244            return None;
4245        }
4246        let window_start = self.bars.len().saturating_sub(n);
4247        let count = self.bars[window_start..]
4248            .iter()
4249            .rev()
4250            .take_while(|b| b.is_bullish())
4251            .count();
4252        Some(count)
4253    }
4254
4255    /// Z-score of the last close price vs the `n`-bar rolling mean and standard deviation.
4256    ///
4257    /// `z = (close - mean) / std_dev`
4258    ///
4259    /// Returns `None` if `n < 2`, fewer than `n` bars, or standard deviation is zero (flat prices).
4260    pub fn normalized_close(&self, n: usize) -> Option<f64> {
4261        use rust_decimal::prelude::ToPrimitive;
4262        if n < 2 || self.bars.len() < n {
4263            return None;
4264        }
4265        let start = self.bars.len() - n;
4266        let vals: Vec<f64> = self.bars[start..]
4267            .iter()
4268            .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4269            .collect();
4270        let mean = vals.iter().sum::<f64>() / n as f64;
4271        let std = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64).sqrt();
4272        if std == 0.0 { return None; }
4273        let last = *vals.last()?;
4274        Some((last - mean) / std)
4275    }
4276
4277    /// Counts the number of gap-up and gap-down opens in the last `n` bars as a pair.
4278    ///
4279    /// A gap-up is `open[i] > close[i-1]`; a gap-down is `open[i] < close[i-1]`.
4280    ///
4281    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
4282    /// Returns `(gap_ups, gap_downs)`.
4283    pub fn gap_counts(&self, n: usize) -> Option<(usize, usize)> {
4284        if n < 2 || self.bars.len() < n {
4285            return None;
4286        }
4287        let start = self.bars.len() - n;
4288        let mut ups = 0usize;
4289        let mut downs = 0usize;
4290        for i in (start + 1)..self.bars.len() {
4291            let prior_close = self.bars[i - 1].close.value();
4292            let cur_open = self.bars[i].open.value();
4293            if cur_open > prior_close { ups += 1; }
4294            else if cur_open < prior_close { downs += 1; }
4295        }
4296        Some((ups, downs))
4297    }
4298
4299    /// Count of consecutive recent bars where volume exceeded the `period`-bar average volume
4300    /// by at least `factor`.
4301    ///
4302    /// Counts backward from the most recent bar; stops at the first bar that does NOT satisfy
4303    /// the condition. Returns `None` if `period == 0`, `factor <= 0`, or the series has fewer
4304    /// than `period + 1` bars.
4305    pub fn consecutive_volume_surge(&self, period: usize, factor: f64) -> Option<usize> {
4306        use rust_decimal::prelude::ToPrimitive;
4307        if period == 0 || factor <= 0.0 || self.bars.len() <= period {
4308            return None;
4309        }
4310        let mut streak = 0usize;
4311        // Walk backward from the last bar
4312        let last = self.bars.len() - 1;
4313        let mut i = last;
4314        loop {
4315            if i < period {
4316                break;
4317            }
4318            let avg_vol: f64 = self.bars[(i - period)..i]
4319                .iter()
4320                .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4321                .sum::<f64>()
4322                / period as f64;
4323            let bar_vol = self.bars[i].volume.value().to_f64().unwrap_or(0.0);
4324            if avg_vol > 0.0 && bar_vol >= avg_vol * factor {
4325                streak += 1;
4326            } else {
4327                break;
4328            }
4329            if i == 0 { break; }
4330            i -= 1;
4331        }
4332        Some(streak)
4333    }
4334
4335    /// Ratio of the current bar's high-low range to the average range over the last `n` bars.
4336    ///
4337    /// `ratio = current_range / avg_range(last n bars)`.
4338    /// Values above 1.5 indicate a volatility expansion bar.
4339    ///
4340    /// Returns `None` if `n == 0`, the series has fewer than `n` bars, or the average range is zero.
4341    pub fn intrabar_range_expansion(&self, n: usize) -> Option<f64> {
4342        use rust_decimal::prelude::ToPrimitive;
4343        if n == 0 || self.bars.len() < n {
4344            return None;
4345        }
4346        let start = self.bars.len() - n;
4347        let avg_range: f64 = self.bars[start..]
4348            .iter()
4349            .map(|b| (b.range()).to_f64().unwrap_or(0.0))
4350            .sum::<f64>()
4351            / n as f64;
4352        if avg_range == 0.0 {
4353            return None;
4354        }
4355        let current = self.bars.last()?;
4356        let cur_range = (current.high.value() - current.low.value())
4357            .to_f64()
4358            .unwrap_or(0.0);
4359        Some(cur_range / avg_range)
4360    }
4361
4362    /// Range ratio over the last `n` bars: `(highest_close - lowest_close) / avg_close`.
4363    ///
4364    /// Measures how much price has moved relative to its average level.
4365    ///
4366    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or avg_close is zero.
4367    pub fn price_range_ratio(&self, n: usize) -> Option<Decimal> {
4368        if n < 2 || self.bars.len() < n {
4369            return None;
4370        }
4371        let start = self.bars.len() - n;
4372        let closes: Vec<Decimal> = self.bars[start..]
4373            .iter()
4374            .map(|b| b.close.value())
4375            .collect();
4376        let hi = closes.iter().copied().max()?;
4377        let lo = closes.iter().copied().min()?;
4378        let avg = closes.iter().sum::<Decimal>() / Decimal::from(n as u32);
4379        if avg.is_zero() { return None; }
4380        Some((hi - lo) / avg)
4381    }
4382
4383    /// Rolling Pearson correlation between close prices and volume over the last `n` bars.
4384    ///
4385    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or either standard deviation is zero.
4386    pub fn close_volume_correlation(&self, n: usize) -> Option<f64> {
4387        use rust_decimal::prelude::ToPrimitive;
4388        if n < 2 || self.bars.len() < n {
4389            return None;
4390        }
4391        let start = self.bars.len() - n;
4392        let closes: Vec<f64> = self.bars[start..].iter()
4393            .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4394            .collect();
4395        let vols: Vec<f64> = self.bars[start..].iter()
4396            .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4397            .collect();
4398        let n_f = n as f64;
4399        let mean_c = closes.iter().sum::<f64>() / n_f;
4400        let mean_v = vols.iter().sum::<f64>() / n_f;
4401        let cov: f64 = closes.iter().zip(vols.iter())
4402            .map(|(c, v)| (c - mean_c) * (v - mean_v))
4403            .sum::<f64>() / n_f;
4404        let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / n_f).sqrt();
4405        let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / n_f).sqrt();
4406        if std_c == 0.0 || std_v == 0.0 { return None; }
4407        Some(cov / (std_c * std_v))
4408    }
4409
4410    /// Position of the last bar's close within the `n`-bar high-low range, normalised to `[0, 1]`.
4411    ///
4412    /// `0.0` means close is at the period low; `1.0` means close is at the period high.
4413    ///
4414    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or `high == low`.
4415    pub fn close_relative_to_range(&self, n: usize) -> Option<Decimal> {
4416        if n == 0 || self.bars.len() < n {
4417            return None;
4418        }
4419        let start = self.bars.len() - n;
4420        let slice = &self.bars[start..];
4421        let high = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
4422        let low = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
4423        let range = high - low;
4424        if range.is_zero() {
4425            return None;
4426        }
4427        let close = self.bars.last()?.close.value();
4428        Some((close - low) / range)
4429    }
4430
4431    /// Simple moving average of volume over the last `n` bars.
4432    ///
4433    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4434    pub fn volume_sma(&self, n: usize) -> Option<Decimal> {
4435        if n == 0 || self.bars.len() < n {
4436            return None;
4437        }
4438        let start = self.bars.len() - n;
4439        #[allow(clippy::cast_possible_truncation)]
4440        let avg = self.bars[start..].iter().map(|b| b.volume.value()).sum::<Decimal>()
4441            / Decimal::from(n as u32);
4442        Some(avg)
4443    }
4444
4445    /// Ratio of short-term ATR to long-term ATR — a "squeeze" or "compression" indicator.
4446    ///
4447    /// `compression_ratio = ATR(fast) / ATR(slow)` where ATR is the simple average of
4448    /// true ranges. Values < 1.0 indicate the recent range is tighter than the longer-term
4449    /// range (potential compression / squeeze). Values > 1.0 indicate expansion.
4450    ///
4451    /// Returns `None` if `fast == 0`, `slow == 0`, `fast >= slow`, fewer than `slow + 1` bars
4452    /// exist, or the long-term ATR is zero.
4453    pub fn compression_ratio(&self, fast: usize, slow: usize) -> Option<Decimal> {
4454        if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow + 1 {
4455            return None;
4456        }
4457        let atr_avg = |n: usize| -> Option<Decimal> {
4458            let start = self.bars.len() - n;
4459            let trs: Decimal = self.bars[start..].iter().enumerate().map(|(i, b)| {
4460                let prev = if i == 0 { &self.bars[start - 1] } else { &self.bars[start + i - 1] };
4461                let hl = b.range();
4462                let hpc = (b.high.value() - prev.close.value()).abs();
4463                let lpc = (b.low.value() - prev.close.value()).abs();
4464                hl.max(hpc).max(lpc)
4465            }).sum();
4466            #[allow(clippy::cast_possible_truncation)]
4467            Some(trs / Decimal::from(n as u32))
4468        };
4469        let atr_fast = atr_avg(fast)?;
4470        let atr_slow = atr_avg(slow)?;
4471        if atr_slow.is_zero() { return None; }
4472        atr_fast.checked_div(atr_slow)
4473    }
4474
4475    /// Average typical price `(H + L + C) / 3` over the last `n` bars.
4476    ///
4477    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4478    pub fn typical_price_avg(&self, n: usize) -> Option<Decimal> {
4479        if n == 0 || self.bars.len() < n {
4480            return None;
4481        }
4482        let start = self.bars.len() - n;
4483        #[allow(clippy::cast_possible_truncation)]
4484        let avg = self.bars[start..]
4485            .iter()
4486            .map(|b| b.typical_price())
4487            .sum::<Decimal>()
4488            / Decimal::from(n as u32);
4489        Some(avg)
4490    }
4491
4492    /// Average ratio of candle body to high-low range over the last `n` bars.
4493    ///
4494    /// `body_to_range[i] = |close[i] - open[i]| / (high[i] - low[i])`
4495    ///
4496    /// Bars where `high == low` are skipped. Returns `None` if `n == 0`,
4497    /// fewer than `n` bars exist, or all bars are flat.
4498    pub fn avg_body_to_range(&self, n: usize) -> Option<Decimal> {
4499        if n == 0 || self.bars.len() < n {
4500            return None;
4501        }
4502        let start = self.bars.len() - n;
4503        let mut sum = Decimal::ZERO;
4504        let mut count = 0u32;
4505        for b in &self.bars[start..] {
4506            let range = b.range();
4507            if range.is_zero() { continue; }
4508            let body = b.body_size();
4509            sum += body
4510                .checked_div(range)
4511                .unwrap_or(Decimal::ZERO);
4512            count += 1;
4513        }
4514        if count == 0 { return None; }
4515        Some(sum / Decimal::from(count))
4516    }
4517
4518    /// Rolling mean of the tick count field over the last `n` bars.
4519    ///
4520    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4521    pub fn avg_tick_count(&self, n: usize) -> Option<Decimal> {
4522        if n == 0 || self.bars.len() < n {
4523            return None;
4524        }
4525        let start = self.bars.len() - n;
4526        let sum: u64 = self.bars[start..].iter().map(|b| b.tick_count).sum();
4527        Some(Decimal::from(sum) / Decimal::from(n as u32))
4528    }
4529
4530    /// Current bar's high-low range as a fraction of the maximum range over the last `n` bars.
4531    ///
4532    /// `range_compression = last_range / max_range(n)`
4533    ///
4534    /// Values near 1 mean the current bar's range is close to the recent maximum (expansion);
4535    /// values near 0 mean the range is compressed relative to recent history.
4536    ///
4537    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or max range is zero.
4538    pub fn range_compression(&self, n: usize) -> Option<Decimal> {
4539        if n == 0 || self.bars.len() < n {
4540            return None;
4541        }
4542        let start = self.bars.len() - n;
4543        let max_range = self.bars[start..]
4544            .iter()
4545            .map(|b| b.range())
4546            .max()?;
4547        if max_range.is_zero() {
4548            return None;
4549        }
4550        let last = self.bars.last()?;
4551        let last_range = last.range();
4552        last_range.checked_div(max_range)
4553    }
4554
4555    /// Largest absolute gap (open-to-prev-close) as a percentage of the prior close
4556    /// over the last `n` bars.
4557    ///
4558    /// `gap_pct[i] = |open[i] - close[i-1]| / close[i-1] × 100`
4559    ///
4560    /// Returns `None` if `n < 2` or fewer than `n` bars exist, or if any close is zero.
4561    pub fn largest_gap_pct(&self, n: usize) -> Option<Decimal> {
4562        if n < 2 || self.bars.len() < n {
4563            return None;
4564        }
4565        let start = self.bars.len() - n;
4566        let mut max_gap = Decimal::ZERO;
4567        for i in start + 1..self.bars.len() {
4568            let prev_close = self.bars[i - 1].close.value();
4569            if prev_close.is_zero() { return None; }
4570            let gap = (self.bars[i].open.value() - prev_close).abs()
4571                / prev_close
4572                * Decimal::from(100u32);
4573            if gap > max_gap { max_gap = gap; }
4574        }
4575        Some(max_gap)
4576    }
4577
4578    /// Returns `1` if the last close crossed above SMA(n), `-1` if it crossed below, `0` otherwise.
4579    ///
4580    /// A crossover is defined as: the previous close was on one side of the SMA, and the
4581    /// current close is on the other side (strict inequality crossing).
4582    ///
4583    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
4584    pub fn close_sma_crossover(&self, n: usize) -> Option<i8> {
4585        if n == 0 || self.bars.len() < n + 1 {
4586            return None;
4587        }
4588        let total = self.bars.len();
4589        #[allow(clippy::cast_possible_truncation)]
4590        let sma_now: Decimal = self.bars[total - n..]
4591            .iter()
4592            .map(|b| b.close.value())
4593            .sum::<Decimal>() / Decimal::from(n as u32);
4594        let sma_prev: Decimal = self.bars[total - n - 1..total - 1]
4595            .iter()
4596            .map(|b| b.close.value())
4597            .sum::<Decimal>() / Decimal::from(n as u32);
4598        let close_now = self.bars[total - 1].close.value();
4599        let close_prev = self.bars[total - 2].close.value();
4600        if close_prev <= sma_prev && close_now > sma_now {
4601            Some(1)
4602        } else if close_prev >= sma_prev && close_now < sma_now {
4603            Some(-1)
4604        } else {
4605            Some(0)
4606        }
4607    }
4608
4609
4610    /// Index (0-based within the last `n` bars) of the bar with the highest volume.
4611    ///
4612    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4613    pub fn max_volume_bar_idx(&self, n: usize) -> Option<usize> {
4614        if n == 0 || self.bars.len() < n {
4615            return None;
4616        }
4617        let start = self.bars.len() - n;
4618        self.bars[start..]
4619            .iter()
4620            .enumerate()
4621            .max_by(|a, b| a.1.volume.value().cmp(&b.1.volume.value()))
4622            .map(|(i, _)| i)
4623    }
4624
4625    /// Last bar's range (high−low) as a percentage of the simple ATR over `n` bars.
4626    ///
4627    /// Values > 100% indicate an unusually wide bar; values < 100% indicate compression.
4628    ///
4629    /// Returns `None` if `n == 0`, fewer than `n + 1` bars exist, or ATR is zero.
4630    pub fn range_pct_of_atr(&self, n: usize) -> Option<Decimal> {
4631        let atr = self.avg_true_range(n)?;
4632        if atr.is_zero() { return None; }
4633        let last = self.bars.last()?;
4634        let range = last.range();
4635        range.checked_div(atr).map(|r| r * Decimal::ONE_HUNDRED)
4636    }
4637
4638    /// Maximum peak-to-trough drawdown of closing prices over the last `n` bars, as a percentage.
4639    ///
4640    /// Scans the window left-to-right; tracks a running peak and records the worst
4641    /// (close - peak) / peak × 100 seen (a negative number or zero).
4642    ///
4643    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4644    pub fn max_close_drawdown(&self, n: usize) -> Option<Decimal> {
4645        if n == 0 || self.bars.len() < n {
4646            return None;
4647        }
4648        let start = self.bars.len() - n;
4649        let mut peak = self.bars[start].close.value();
4650        let mut max_dd = Decimal::ZERO;
4651        for b in &self.bars[start..] {
4652            let c = b.close.value();
4653            if c > peak { peak = c; }
4654            if !peak.is_zero() {
4655                let dd = (c - peak) / peak * Decimal::ONE_HUNDRED;
4656                if dd < max_dd { max_dd = dd; }
4657            }
4658        }
4659        Some(max_dd)
4660    }
4661
4662    /// Percentage of the last `n` bars where the close is above its `sma_period`-bar SMA.
4663    ///
4664    /// For each bar `i` in the window, the SMA uses the `sma_period` bars ending at `i`.
4665    /// Bars with insufficient history (fewer than `sma_period` prior bars) are skipped.
4666    ///
4667    /// Returns `None` if `n == 0` or fewer than `n + sma_period - 1` total bars exist.
4668    pub fn close_above_sma_pct(&self, n: usize, sma_period: usize) -> Option<Decimal> {
4669        if n == 0 || sma_period == 0 || self.bars.len() < n + sma_period - 1 {
4670            return None;
4671        }
4672        let window_start = self.bars.len() - n;
4673        let mut above = 0u32;
4674        for (offset, b) in self.bars[window_start..].iter().enumerate() {
4675            let abs_idx = window_start + offset;
4676            if abs_idx + 1 < sma_period { continue; }
4677            let sma_start = abs_idx + 1 - sma_period;
4678            let sma = self.bars[sma_start..=abs_idx]
4679                .iter()
4680                .map(|x| x.close.value())
4681                .sum::<Decimal>()
4682                / Decimal::from(sma_period as u32);
4683            if b.close.value() > sma { above += 1; }
4684        }
4685        Some(Decimal::from(above) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
4686    }
4687
4688    /// Counts swing highs within the last `n` bars.
4689    ///
4690    /// A bar at index `i` is a swing high if its `high` is strictly greater than the
4691    /// highs of the `lookback` bars immediately before and after it.
4692    ///
4693    /// Returns `None` if `n == 0`, `lookback == 0`, or fewer than `n` bars exist.
4694    pub fn swing_high_count(&self, n: usize, lookback: usize) -> Option<usize> {
4695        if n == 0 || lookback == 0 || self.bars.len() < n { return None; }
4696        let start = self.bars.len() - n;
4697        let slice = &self.bars[start..];
4698        let len = slice.len();
4699        let mut count = 0usize;
4700        for i in lookback..len.saturating_sub(lookback) {
4701            let peak = slice[i].high.value();
4702            let is_high = (0..lookback).all(|k| peak > slice[i - 1 - k].high.value())
4703                && (0..lookback).all(|k| peak > slice[i + 1 + k].high.value());
4704            if is_high { count += 1; }
4705        }
4706        Some(count)
4707    }
4708
4709    /// Mean absolute open-to-prev-close gap as a percentage of the prior close
4710    /// over the last `n` bars.
4711    ///
4712    /// `gap_pct[i] = |open[i] - close[i-1]| / close[i-1] × 100`
4713    ///
4714    /// Returns `None` if `n == 0`, `n < 2`, fewer than `n` bars exist, or any prior close is zero.
4715    pub fn open_gap_pct(&self, n: usize) -> Option<Decimal> {
4716        if n < 2 || self.bars.len() < n {
4717            return None;
4718        }
4719        let start = self.bars.len() - n;
4720        let mut sum = Decimal::ZERO;
4721        for i in start..self.bars.len() {
4722            let prev_close = self.bars[i - 1].close.value();
4723            if prev_close.is_zero() { return None; }
4724            let gap = (self.bars[i].open.value() - prev_close).abs();
4725            sum += gap / prev_close * Decimal::ONE_HUNDRED;
4726        }
4727        Some(sum / Decimal::from((n - 1) as u32))
4728    }
4729
4730    /// Ratio of the average volume on up-close bars to the average volume on down-close bars
4731    /// over the last `n` bars.
4732    ///
4733    /// Returns `None` if `n == 0`, fewer than `n` bars exist, there are no up or no down bars,
4734    /// or the average down volume is zero.
4735    pub fn volume_trend_ratio(&self, n: usize) -> Option<Decimal> {
4736        if n == 0 || self.bars.len() < n {
4737            return None;
4738        }
4739        let start = self.bars.len() - n;
4740        let mut up_sum = Decimal::ZERO;
4741        let mut up_count = 0u32;
4742        let mut down_sum = Decimal::ZERO;
4743        let mut down_count = 0u32;
4744        for b in &self.bars[start..] {
4745            let v = b.volume.value();
4746            if b.is_bullish() {
4747                up_sum += v;
4748                up_count += 1;
4749            } else if b.is_bearish() {
4750                down_sum += v;
4751                down_count += 1;
4752            }
4753        }
4754        if up_count == 0 || down_count == 0 { return None; }
4755        let avg_up = up_sum / Decimal::from(up_count);
4756        let avg_down = down_sum / Decimal::from(down_count);
4757        if avg_down.is_zero() { return None; }
4758        avg_up.checked_div(avg_down)
4759    }
4760
4761    /// Average wick percentage over the last `n` bars.
4762    ///
4763    /// For each bar: `wick_pct = (upper_wick + lower_wick) / (high - low) × 100`.
4764    /// A value near 100 means the bar is almost entirely wicks; near 0 means it's mostly body.
4765    ///
4766    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or any bar has zero range.
4767    pub fn avg_wick_pct(&self, n: usize) -> Option<Decimal> {
4768        if n == 0 || self.bars.len() < n { return None; }
4769        let start = self.bars.len() - n;
4770        let mut sum = Decimal::ZERO;
4771        for b in &self.bars[start..] {
4772            let range = b.range();
4773            if range.is_zero() { return None; }
4774            let upper_wick = b.high.value() - b.close.value().max(b.open.value());
4775            let lower_wick = b.close.value().min(b.open.value()) - b.low.value();
4776            sum += (upper_wick + lower_wick) / range * Decimal::from(100u32);
4777        }
4778        #[allow(clippy::cast_possible_truncation)]
4779        Some(sum / Decimal::from(n as u32))
4780    }
4781
4782    /// Percentage of the last `n` bars that moved in the same direction as the prior bar.
4783    ///
4784    /// A bar "trends" when its close-to-close direction matches the previous bar's.
4785    /// Requires `n + 1` bars.
4786    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
4787    pub fn trend_continuation_pct(&self, n: usize) -> Option<Decimal> {
4788        if n == 0 || self.bars.len() < n + 1 { return None; }
4789        let start = self.bars.len() - n - 1;
4790        let mut continuing = 0u32;
4791        for i in 0..n {
4792            let prev_dir = self.bars[start + i].close.value()
4793                .cmp(&self.bars[start + i].open.value());
4794            let curr_dir = self.bars[start + i + 1].close.value()
4795                .cmp(&self.bars[start + i + 1].open.value());
4796            if prev_dir == curr_dir && prev_dir != std::cmp::Ordering::Equal {
4797                continuing += 1;
4798            }
4799        }
4800        #[allow(clippy::cast_possible_truncation)]
4801        Some(Decimal::from(continuing) / Decimal::from(n as u32) * Decimal::from(100u32))
4802    }
4803
4804    /// Count of inside bars (high ≤ prev high AND low ≥ prev low) in the last `n` bars.
4805    pub fn inside_bar_count(&self, n: usize) -> Option<usize> {
4806        if n == 0 || self.bars.len() < n { return None; }
4807        let start = self.bars.len() - n;
4808        let mut count = 0usize;
4809        for i in start..self.bars.len() {
4810            if i == 0 { continue; }
4811            let prev = &self.bars[i - 1];
4812            let cur = &self.bars[i];
4813            if cur.high <= prev.high && cur.low >= prev.low { count += 1; }
4814        }
4815        Some(count)
4816    }
4817
4818    /// Count of outside bars (high > prev high AND low < prev low) in the last `n` bars.
4819    pub fn outside_bar_count(&self, n: usize) -> Option<usize> {
4820        if n == 0 || self.bars.len() < n { return None; }
4821        let start = self.bars.len() - n;
4822        let mut count = 0usize;
4823        for i in start..self.bars.len() {
4824            if i == 0 { continue; }
4825            let prev = &self.bars[i - 1];
4826            let cur = &self.bars[i];
4827            if cur.high > prev.high && cur.low < prev.low { count += 1; }
4828        }
4829        Some(count)
4830    }
4831
4832    /// Close price of the bar with the highest volume among the last `n` bars.
4833    ///
4834    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4835    pub fn high_volume_price(&self, n: usize) -> Option<Decimal> {
4836        if n == 0 || self.bars.len() < n { return None; }
4837        let start = self.bars.len() - n;
4838        self.bars[start..].iter()
4839            .max_by_key(|b| b.volume.value())
4840            .map(|b| b.close.value())
4841    }
4842
4843    /// Average of `close - open` over the last `n` bars.
4844    ///
4845    /// Positive values indicate a net bullish directional bias; negative indicate bearish.
4846    ///
4847    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4848    pub fn avg_close_minus_open(&self, n: usize) -> Option<Decimal> {
4849        if n == 0 || self.bars.len() < n { return None; }
4850        let start = self.bars.len() - n;
4851        let sum: Decimal = self.bars[start..]
4852            .iter()
4853            .map(|b| b.close.value() - b.open.value())
4854            .sum();
4855        #[allow(clippy::cast_possible_truncation)]
4856        Some(sum / Decimal::from(n as u32))
4857    }
4858
4859    /// Average upper shadow length as a percentage of close over the last `n` bars.
4860    ///
4861    /// `upper_shadow = high - max(open, close)`;
4862    /// returned as `upper_shadow / close × 100`.
4863    ///
4864    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4865    pub fn avg_upper_shadow_pct(&self, n: usize) -> Option<Decimal> {
4866        if n == 0 || self.bars.len() < n { return None; }
4867        let start = self.bars.len() - n;
4868        let sum: Decimal = self.bars[start..].iter().map(|b| {
4869            let body_top = b.open.value().max(b.close.value());
4870            let shadow = b.high.value() - body_top;
4871            let close = b.close.value();
4872            if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4873        }).sum();
4874        #[allow(clippy::cast_possible_truncation)]
4875        Some(sum / Decimal::from(n as u32))
4876    }
4877
4878    /// Average lower shadow length as a percentage of close over the last `n` bars.
4879    ///
4880    /// `lower_shadow = min(open, close) - low`;
4881    /// returned as `lower_shadow / close × 100`.
4882    ///
4883    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4884    pub fn avg_lower_shadow_pct(&self, n: usize) -> Option<Decimal> {
4885        if n == 0 || self.bars.len() < n { return None; }
4886        let start = self.bars.len() - n;
4887        let sum: Decimal = self.bars[start..].iter().map(|b| {
4888            let body_bottom = b.open.value().min(b.close.value());
4889            let shadow = body_bottom - b.low.value();
4890            let close = b.close.value();
4891            if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4892        }).sum();
4893        #[allow(clippy::cast_possible_truncation)]
4894        Some(sum / Decimal::from(n as u32))
4895    }
4896
4897    /// Percentage of doji bars (|close - open| / (high - low) < 0.1) over the last `n` bars.
4898    ///
4899    /// Bars with zero range are counted as doji.
4900    ///
4901    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4902    pub fn percent_doji(&self, n: usize) -> Option<Decimal> {
4903        if n == 0 || self.bars.len() < n { return None; }
4904        let start = self.bars.len() - n;
4905        let threshold = rust_decimal_macros::dec!(0.1);
4906        let mut doji_count = 0u32;
4907        for b in &self.bars[start..] {
4908            let range = b.range();
4909            let body = b.body_size();
4910            if range.is_zero() || body / range < threshold {
4911                doji_count += 1;
4912            }
4913        }
4914        #[allow(clippy::cast_possible_truncation)]
4915        Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::from(100u32))
4916    }
4917
4918    /// Average close position within the high-low range over the last `n` bars.
4919    ///
4920    /// `close_range_pct = (close - low) / (high - low) × 100`
4921    ///
4922    /// 100 means close was at the high; 0 means close was at the low; 50 means mid-range.
4923    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or any bar has zero range.
4924    pub fn avg_close_range_pct(&self, n: usize) -> Option<Decimal> {
4925        if n == 0 || self.bars.len() < n { return None; }
4926        let start = self.bars.len() - n;
4927        let mut sum = Decimal::ZERO;
4928        for b in &self.bars[start..] {
4929            let range = b.range();
4930            if range.is_zero() { return None; }
4931            sum += (b.close.value() - b.low.value()) / range * Decimal::from(100u32);
4932        }
4933        #[allow(clippy::cast_possible_truncation)]
4934        Some(sum / Decimal::from(n as u32))
4935    }
4936
4937    /// Price channel width over last `n` bars as a percentage of the channel low.
4938    ///
4939    /// `width = (max_high - min_low) / min_low × 100`
4940    ///
4941    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or `min_low` is zero.
4942    pub fn price_channel_width(&self, n: usize) -> Option<Decimal> {
4943        if n == 0 || self.bars.len() < n { return None; }
4944        let start = self.bars.len() - n;
4945        let slice = &self.bars[start..];
4946        let max_high = slice.iter().map(|b| b.high.value()).max()?;
4947        let min_low = slice.iter().map(|b| b.low.value()).min()?;
4948        if min_low.is_zero() { return None; }
4949        Some((max_high - min_low) / min_low * Decimal::ONE_HUNDRED)
4950    }
4951
4952    /// Average candle efficiency over last `n` bars.
4953    ///
4954    /// `efficiency = |close - open| / (high - low)` per bar (0 = all wick, 1 = all body).
4955    ///
4956    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or any bar has zero range.
4957    pub fn avg_candle_efficiency(&self, n: usize) -> Option<Decimal> {
4958        if n == 0 || self.bars.len() < n { return None; }
4959        let start = self.bars.len() - n;
4960        let mut sum = Decimal::ZERO;
4961        for b in &self.bars[start..] {
4962            let range = b.range();
4963            if range.is_zero() { return None; }
4964            sum += b.body_size() / range;
4965        }
4966        #[allow(clippy::cast_possible_truncation)]
4967        Some(sum / Decimal::from(n as u32))
4968    }
4969
4970    /// Total volume on bars that made a new n-bar high.
4971    ///
4972    /// Counts volume on any bar whose high exceeds all previous bars in the window.
4973    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4974    pub fn volume_at_high(&self, n: usize) -> Option<Decimal> {
4975        if n == 0 || self.bars.len() < n { return None; }
4976        let start = self.bars.len() - n;
4977        let slice = &self.bars[start..];
4978        let mut running_high = slice[0].high.value();
4979        let mut total = slice[0].volume.value();
4980        for b in &slice[1..] {
4981            if b.high.value() > running_high {
4982                running_high = b.high.value();
4983                total += b.volume.value();
4984            }
4985        }
4986        Some(total)
4987    }
4988
4989    /// Percentage of last `n` bars where close > previous bar's close.
4990    ///
4991    /// Requires `n + 1` bars. Returns `None` if `n == 0` or insufficient bars.
4992    pub fn close_momentum_consistency(&self, n: usize) -> Option<Decimal> {
4993        if n == 0 || self.bars.len() < n + 1 { return None; }
4994        let start = self.bars.len() - n - 1;
4995        let mut up = 0u32;
4996        for i in 0..n {
4997            if self.bars[start + i + 1].close > self.bars[start + i].close {
4998                up += 1;
4999            }
5000        }
5001        #[allow(clippy::cast_possible_truncation)]
5002        Some(Decimal::from(up) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5003    }
5004
5005    /// Opening gap percentage: `(open - prev_close) / prev_close * 100` for the most recent bar.
5006    ///
5007    /// Returns `None` if fewer than 2 bars exist or `prev_close` is zero.
5008    pub fn price_gap_pct(&self) -> Option<Decimal> {
5009        let n = self.bars.len();
5010        if n < 2 { return None; }
5011        let prev_close = self.bars[n - 2].close.value();
5012        if prev_close.is_zero() { return None; }
5013        Some((self.bars[n - 1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
5014    }
5015
5016    /// Longest consecutive run of up-closes (close > prev close) across the entire series.
5017    ///
5018    /// Returns `0` if the series has fewer than 2 bars.
5019    pub fn longest_winning_streak(&self) -> usize {
5020        if self.bars.len() < 2 { return 0; }
5021        let mut max_streak = 0usize;
5022        let mut streak = 0usize;
5023        for i in 1..self.bars.len() {
5024            if self.bars[i].close > self.bars[i - 1].close {
5025                streak += 1;
5026                if streak > max_streak { max_streak = streak; }
5027            } else {
5028                streak = 0;
5029            }
5030        }
5031        max_streak
5032    }
5033
5034    /// Average absolute opening gap percentage over the last `n` bars.
5035    ///
5036    /// Each gap is `|open - prev_close| / prev_close * 100`.
5037    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
5038    pub fn avg_gap_pct(&self, n: usize) -> Option<Decimal> {
5039        if n == 0 || self.bars.len() < n + 1 { return None; }
5040        let start = self.bars.len() - n;
5041        let mut sum = Decimal::ZERO;
5042        for i in start..self.bars.len() {
5043            let prev_close = self.bars[i - 1].close.value();
5044            if prev_close.is_zero() { continue; }
5045            sum += (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5046        }
5047        #[allow(clippy::cast_possible_truncation)]
5048        Some(sum / Decimal::from(n as u32))
5049    }
5050
5051    /// Average intrabar momentum over the last `n` bars.
5052    ///
5053    /// Momentum per bar = `(close − open) / (high − low)`.  Bars with zero range are skipped.
5054    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all bars have zero range.
5055    pub fn intrabar_momentum(&self, n: usize) -> Option<Decimal> {
5056        if n == 0 || self.bars.len() < n { return None; }
5057        let start = self.bars.len() - n;
5058        let mut sum = Decimal::ZERO;
5059        let mut count = 0u32;
5060        for bar in &self.bars[start..] {
5061            let range = bar.range();
5062            if range.is_zero() { continue; }
5063            sum += (bar.close.value() - bar.open.value()) / range;
5064            count += 1;
5065        }
5066        if count == 0 { return None; }
5067        Some(sum / Decimal::from(count))
5068    }
5069
5070    /// Average volume per bar over the last `n` bars.
5071    ///
5072    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5073    pub fn volume_per_bar(&self, n: usize) -> Option<Decimal> {
5074        if n == 0 || self.bars.len() < n { return None; }
5075        let start = self.bars.len() - n;
5076        let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
5077        #[allow(clippy::cast_possible_truncation)]
5078        Some(sum / Decimal::from(n as u32))
5079    }
5080
5081    /// Percentage of the last `n` bars where close is within `threshold_pct`% of the bar high.
5082    ///
5083    /// A close is "near the high" when `(high − close) / high * 100 <= threshold_pct`.
5084    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5085    pub fn pct_bars_near_high(&self, n: usize, threshold_pct: Decimal) -> Option<Decimal> {
5086        if n == 0 || self.bars.len() < n { return None; }
5087        let start = self.bars.len() - n;
5088        let mut near = 0u32;
5089        for bar in &self.bars[start..] {
5090            let high = bar.high.value();
5091            if high.is_zero() { continue; }
5092            let dist_pct = (high - bar.close.value()) / high * Decimal::ONE_HUNDRED;
5093            if dist_pct <= threshold_pct {
5094                near += 1;
5095            }
5096        }
5097        #[allow(clippy::cast_possible_truncation)]
5098        Some(Decimal::from(near) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5099    }
5100
5101    /// Average candle body size as a percentage of the bar's range over the last `n` bars.
5102    ///
5103    /// Body % per bar = `|close − open| / (high − low) * 100`.  Bars with zero range are skipped.
5104    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all bars have zero range.
5105    pub fn avg_body_pct(&self, n: usize) -> Option<Decimal> {
5106        if n == 0 || self.bars.len() < n { return None; }
5107        let start = self.bars.len() - n;
5108        let mut sum = Decimal::ZERO;
5109        let mut count = 0u32;
5110        for bar in &self.bars[start..] {
5111            let range = bar.range();
5112            if range.is_zero() { continue; }
5113            sum += bar.body_size() / range * Decimal::ONE_HUNDRED;
5114            count += 1;
5115        }
5116        if count == 0 { return None; }
5117        Some(sum / Decimal::from(count))
5118    }
5119
5120    /// Average upper-to-lower wick ratio over the last `n` bars.
5121    ///
5122    /// Upper wick = `high − max(open, close)`.  Lower wick = `min(open, close) − low`.
5123    /// Bars where the lower wick is zero are skipped.
5124    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all bars have zero lower wick.
5125    pub fn tail_ratio(&self, n: usize) -> Option<Decimal> {
5126        if n == 0 || self.bars.len() < n { return None; }
5127        let start = self.bars.len() - n;
5128        let mut sum = Decimal::ZERO;
5129        let mut count = 0u32;
5130        for bar in &self.bars[start..] {
5131            let body_top = bar.open.value().max(bar.close.value());
5132            let body_bot = bar.open.value().min(bar.close.value());
5133            let upper = bar.high.value() - body_top;
5134            let lower = body_bot - bar.low.value();
5135            if lower.is_zero() { continue; }
5136            sum += upper / lower;
5137            count += 1;
5138        }
5139        if count == 0 { return None; }
5140        Some(sum / Decimal::from(count))
5141    }
5142
5143    /// Ratio of the average volume over the last `n` bars to the average volume over the last `m` bars.
5144    ///
5145    /// Useful for detecting volume surges when `n < m`.
5146    /// Returns `None` if `n == 0`, `m == 0`, insufficient bars, or the `m`-bar average is zero.
5147    pub fn avg_volume_ratio(&self, n: usize, m: usize) -> Option<Decimal> {
5148        let len = self.bars.len();
5149        if n == 0 || m == 0 || len < n.max(m) { return None; }
5150        #[allow(clippy::cast_possible_truncation)]
5151        let avg_n: Decimal = self.bars[len - n..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5152            / Decimal::from(n as u32);
5153        #[allow(clippy::cast_possible_truncation)]
5154        let avg_m: Decimal = self.bars[len - m..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5155            / Decimal::from(m as u32);
5156        if avg_m.is_zero() { return None; }
5157        Some(avg_n / avg_m)
5158    }
5159
5160    /// Pearson correlation between open and close prices over the last `n` bars.
5161    ///
5162    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or standard deviations are zero.
5163    pub fn open_close_correlation(&self, n: usize) -> Option<f64> {
5164        use rust_decimal::prelude::ToPrimitive;
5165        if n < 2 || self.bars.len() < n { return None; }
5166        let start = self.bars.len() - n;
5167        let opens: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.open.value().to_f64()).collect();
5168        let closes: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.close.value().to_f64()).collect();
5169        if opens.len() < 2 { return None; }
5170        let nf = opens.len() as f64;
5171        let mean_o = opens.iter().sum::<f64>() / nf;
5172        let mean_c = closes.iter().sum::<f64>() / nf;
5173        let cov: f64 = opens.iter().zip(closes.iter()).map(|(o, c)| (o - mean_o) * (c - mean_c)).sum::<f64>() / nf;
5174        let std_o = (opens.iter().map(|o| (o - mean_o).powi(2)).sum::<f64>() / nf).sqrt();
5175        let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
5176        if std_o == 0.0 || std_c == 0.0 { return None; }
5177        Some(cov / (std_o * std_c))
5178    }
5179
5180    /// Price acceleration: difference in average close-to-close change between the first and
5181    /// second halves of the last `n` bars.
5182    ///
5183    /// Positive value = momentum is building; negative = fading.
5184    /// Returns `None` if `n < 4` or fewer than `n + 1` bars exist.
5185    pub fn price_acceleration(&self, n: usize) -> Option<Decimal> {
5186        if n < 4 || self.bars.len() < n + 1 { return None; }
5187        let start = self.bars.len() - n - 1;
5188        let half = n / 2;
5189        let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5190            .map(|i| self.bars[i + 1].close.value() - self.bars[i].close.value())
5191            .collect();
5192        #[allow(clippy::cast_possible_truncation)]
5193        let avg_first = changes[..half].iter().sum::<Decimal>() / Decimal::from(half as u32);
5194        #[allow(clippy::cast_possible_truncation)]
5195        let avg_second = changes[half..].iter().sum::<Decimal>() / Decimal::from((changes.len() - half) as u32);
5196        Some(avg_second - avg_first)
5197    }
5198
5199    /// Sample skewness of close-to-close log-returns over the last `n` bars.
5200    ///
5201    /// Requires at least `n + 1` bars and 3+ valid returns.
5202    /// Returns `None` if `n == 0`, insufficient bars, or returns have zero variance.
5203    pub fn returns_skewness(&self, n: usize) -> Option<f64> {
5204        use rust_decimal::prelude::ToPrimitive;
5205        if n == 0 || self.bars.len() < n + 1 { return None; }
5206        let start = self.bars.len() - n - 1;
5207        let returns: Vec<f64> = (start..self.bars.len() - 1)
5208            .filter_map(|i| {
5209                let prev = self.bars[i].close.value().to_f64()?;
5210                let curr = self.bars[i + 1].close.value().to_f64()?;
5211                if prev == 0.0 { return None; }
5212                Some((curr / prev).ln())
5213            })
5214            .collect();
5215        if returns.len() < 3 { return None; }
5216        let m = returns.len() as f64;
5217        let mean = returns.iter().sum::<f64>() / m;
5218        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m;
5219        let std = variance.sqrt();
5220        if std == 0.0 { return None; }
5221        Some(returns.iter().map(|r| ((r - mean) / std).powi(3)).sum::<f64>() / m)
5222    }
5223
5224    /// Z-score of the most recent bar's volume relative to the last `n` bars.
5225    ///
5226    /// Returns `None` if `n < 2` or fewer than `n` bars exist or volume std-dev is zero.
5227    pub fn volume_zscore(&self, n: usize) -> Option<f64> {
5228        use rust_decimal::prelude::ToPrimitive;
5229        if n < 2 || self.bars.len() < n { return None; }
5230        let start = self.bars.len() - n;
5231        let vols: Vec<f64> = self.bars[start..].iter()
5232            .filter_map(|b| b.volume.value().to_f64())
5233            .collect();
5234        if vols.len() < 2 { return None; }
5235        let m = vols.len() as f64;
5236        let mean = vols.iter().sum::<f64>() / m;
5237        let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
5238        let std = variance.sqrt();
5239        if std == 0.0 { return None; }
5240        let last_vol = self.bars.last()?.volume.value().to_f64()?;
5241        Some((last_vol - mean) / std)
5242    }
5243
5244    /// Ratio of upper shadow to lower shadow for the most recent bar.
5245    ///
5246    /// Upper shadow = `high - max(open, close)`.
5247    /// Lower shadow = `min(open, close) - low`.
5248    /// Returns `None` if the lower shadow is zero.
5249    pub fn upper_lower_shadow_ratio(&self) -> Option<Decimal> {
5250        let bar = self.bars.last()?;
5251        let body_top = bar.open.value().max(bar.close.value());
5252        let body_bot = bar.open.value().min(bar.close.value());
5253        let upper = bar.high.value() - body_top;
5254        let lower = body_bot - bar.low.value();
5255        if lower.is_zero() { return None; }
5256        Some(upper / lower)
5257    }
5258
5259    /// Simple moving average of close over last `n` bars.
5260    fn sma(&self, n: usize) -> Option<Decimal> {
5261        if n == 0 || self.bars.len() < n { return None; }
5262        let start = self.bars.len() - n;
5263        let sum: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
5264        Some(sum / Decimal::from(n as u32))
5265    }
5266
5267    /// Mean true range over last `n` bars (requires `n + 1` bars for true range).
5268    fn atr(&self, n: usize) -> Option<Decimal> {
5269        if n == 0 || self.bars.len() < n + 1 { return None; }
5270        let start = self.bars.len() - n - 1;
5271        let mut sum = Decimal::ZERO;
5272        for i in start..self.bars.len() - 1 {
5273            let pc = self.bars[i].close.value();
5274            let h = self.bars[i + 1].high.value();
5275            let l = self.bars[i + 1].low.value();
5276            let tr = (h - l).max((h - pc).abs()).max((l - pc).abs());
5277            sum += tr;
5278        }
5279        Some(sum / Decimal::from(n as u32))
5280    }
5281
5282    /// Exponential moving average of close over `n` bars (SMA seed).
5283    fn ema(&self, n: usize) -> Option<Decimal> {
5284        if n == 0 || self.bars.len() < n { return None; }
5285        let start = self.bars.len() - n;
5286        let seed: Decimal = self.bars[start..start + n.min(self.bars.len() - start)]
5287            .iter().map(|b| b.close.value()).sum::<Decimal>()
5288            / Decimal::from(n as u32);
5289        let k = Decimal::from(2u32) / Decimal::from((n + 1) as u32);
5290        let mut e = seed;
5291        // If extra bars exist beyond seed, smooth them
5292        for bar in &self.bars[start + n..] {
5293            e = e * (Decimal::ONE - k) + bar.close.value() * k;
5294        }
5295        Some(e)
5296    }
5297
5298    /// Mean-reversion score: `|close - sma(n)| / atr(n)`.
5299    ///
5300    /// High values (> 2) suggest price is extended and may revert toward the mean.
5301    /// Returns `None` if `n == 0`, insufficient bars, or ATR is zero.
5302    pub fn mean_reversion_score(&self, n: usize) -> Option<Decimal> {
5303        let close = self.bars.last()?.close.value();
5304        let sma = self.sma(n)?;
5305        let atr = self.atr(n)?;
5306        if atr.is_zero() { return None; }
5307        Some((close - sma).abs() / atr)
5308    }
5309
5310    /// Volume Price Trend: cumulative `(close_pct_change * volume)` over last `n` bars.
5311    ///
5312    /// Combines momentum and volume into a single trend confirmation signal.
5313    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
5314    pub fn volume_price_trend(&self, n: usize) -> Option<Decimal> {
5315        if n == 0 || self.bars.len() < n + 1 { return None; }
5316        let start = self.bars.len() - n - 1;
5317        let mut vpt = Decimal::ZERO;
5318        for i in start..self.bars.len() - 1 {
5319            let prev_close = self.bars[i].close.value();
5320            if prev_close.is_zero() { continue; }
5321            let pct_chg = (self.bars[i + 1].close.value() - prev_close) / prev_close;
5322            vpt += pct_chg * self.bars[i + 1].volume.value();
5323        }
5324        Some(vpt)
5325    }
5326
5327    /// Number of trailing consecutive bars where close < previous close.
5328    ///
5329    /// Returns `0` if the series has fewer than 2 bars or the most recent bar is not bearish.
5330    pub fn bear_run_length(&self) -> usize {
5331        let n = self.bars.len();
5332        if n < 2 { return 0; }
5333        let mut count = 0;
5334        let mut i = n - 1;
5335        while i > 0 && self.bars[i].close.value() < self.bars[i - 1].close.value() {
5336            count += 1;
5337            i -= 1;
5338        }
5339        count
5340    }
5341
5342    /// Average True Range expressed as a percentage of the closing price over `n` bars.
5343    ///
5344    /// `atr_pct = ATR(n) / close * 100`.
5345    /// Returns `None` if `n == 0`, insufficient bars, or the last close is zero.
5346    pub fn avg_true_range_pct(&self, n: usize) -> Option<Decimal> {
5347        let atr = self.atr(n)?;
5348        let close = self.bars.last()?.close.value();
5349        if close.is_zero() { return None; }
5350        Some(atr / close * Decimal::ONE_HUNDRED)
5351    }
5352
5353    /// Deviation of the last close from its EMA(n) as a percentage of the close.
5354    ///
5355    /// `(close - EMA(n)) / close * 100`.  Positive = above EMA, negative = below.
5356    /// Returns `None` if `n == 0`, insufficient bars, or the last close is zero.
5357    pub fn close_vs_ema(&self, n: usize) -> Option<Decimal> {
5358        let ema = self.ema(n)?;
5359        let close = self.bars.last()?.close.value();
5360        if close.is_zero() { return None; }
5361        Some((close - ema) / close * Decimal::ONE_HUNDRED)
5362    }
5363
5364    /// Average per-bar volume change (linear slope) over the last `n` bars.
5365    ///
5366    /// Positive = volume trend is increasing; negative = decreasing.
5367    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
5368    pub fn volume_momentum(&self, n: usize) -> Option<Decimal> {
5369        if n < 2 || self.bars.len() < n { return None; }
5370        let start = self.bars.len() - n;
5371        let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5372            .map(|i| self.bars[i + 1].volume.value() - self.bars[i].volume.value())
5373            .collect();
5374        if changes.is_empty() { return None; }
5375        #[allow(clippy::cast_possible_truncation)]
5376        Some(changes.iter().sum::<Decimal>() / Decimal::from(changes.len() as u32))
5377    }
5378
5379    /// Returns the 0-based index from the end (0 = most recent) of the bar with the highest
5380    /// volume in the last `n` bars.
5381    ///
5382    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5383    pub fn max_volume_bar(&self, n: usize) -> Option<usize> {
5384        if n == 0 || self.bars.len() < n { return None; }
5385        let start = self.bars.len() - n;
5386        let (rel_idx, _) = self.bars[start..]
5387            .iter()
5388            .enumerate()
5389            .max_by_key(|(_, b)| b.volume.value())?;
5390        Some(n - 1 - rel_idx)
5391    }
5392
5393    /// Number of bars in the last `n` where the absolute open-to-open gap ≥ `min_pct`%.
5394    ///
5395    /// Gap is measured as `|open - prev_close| / prev_close * 100`.
5396    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
5397    pub fn gap_count(&self, n: usize, min_pct: Decimal) -> Option<usize> {
5398        if n == 0 || self.bars.len() < n + 1 { return None; }
5399        let start = self.bars.len() - n;
5400        let count = (start..self.bars.len()).filter(|&i| {
5401            let prev_close = self.bars[i - 1].close.value();
5402            if prev_close.is_zero() { return false; }
5403            let gap = (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5404            gap >= min_pct
5405        }).count();
5406        Some(count)
5407    }
5408
5409    /// Mean close-to-close percentage change over the last `n` bars.
5410    ///
5411    /// `avg = mean((close[i] - close[i-1]) / close[i-1] * 100)` for the last n periods.
5412    /// Returns `None` if `n == 0`, fewer than `n + 1` bars exist, or any previous close is zero.
5413    pub fn avg_close_pct_change(&self, n: usize) -> Option<Decimal> {
5414        if n == 0 || self.bars.len() < n + 1 { return None; }
5415        let start = self.bars.len() - n - 1;
5416        let mut sum = Decimal::ZERO;
5417        for i in start..self.bars.len() - 1 {
5418            let prev = self.bars[i].close.value();
5419            if prev.is_zero() { return None; }
5420            sum += (self.bars[i + 1].close.value() - prev) / prev * Decimal::ONE_HUNDRED;
5421        }
5422        #[allow(clippy::cast_possible_truncation)]
5423        Some(sum / Decimal::from(n as u32))
5424    }
5425
5426    /// Bollinger Band width: `(upper - lower) / sma(n)`.
5427    ///
5428    /// Normalized band expansion metric. Values approaching zero indicate a squeeze.
5429    /// Returns `None` if `n == 0`, insufficient bars, or SMA is zero.
5430    pub fn bollinger_width(&self, n: usize, multiplier: Decimal) -> Option<Decimal> {
5431        let sma = self.sma(n)?;
5432        if sma.is_zero() { return None; }
5433        let std = self.std_dev(n)?;
5434        let upper = sma + multiplier * std;
5435        let lower = sma - multiplier * std;
5436        Some((upper - lower) / sma)
5437    }
5438
5439    /// Current consecutive run of bars where close >= SMA(period).
5440    ///
5441    /// Counts backward from the most recent bar. Returns `0` if current close is below SMA
5442    /// or insufficient bars exist.
5443    pub fn close_above_ma_streak(&self, period: usize) -> usize {
5444        if self.bars.len() < period { return 0; }
5445        let mut streak = 0usize;
5446        // Walk backward: for each bar compute its SMA and check
5447        for i in (period - 1..self.bars.len()).rev() {
5448            let sum: Decimal = (0..period).map(|j| self.bars[i + 1 - period + j].close.value()).sum();
5449            #[allow(clippy::cast_possible_truncation)]
5450            let sma = sum / Decimal::from(period as u32);
5451            if self.bars[i].close.value() >= sma {
5452                streak += 1;
5453            } else {
5454                break;
5455            }
5456        }
5457        streak
5458    }
5459
5460    /// Average `|close − open| / (high − low)` (body-to-range ratio) over the last `n` bars.
5461    ///
5462    /// Bars with zero range are skipped.  Returns `None` if all bars have zero range.
5463    pub fn avg_body_to_range_ratio(&self, n: usize) -> Option<Decimal> {
5464        if n == 0 || self.bars.len() < n { return None; }
5465        let start = self.bars.len() - n;
5466        let mut sum = Decimal::ZERO;
5467        let mut count = 0u32;
5468        for bar in &self.bars[start..] {
5469            let range = bar.range();
5470            if range.is_zero() { continue; }
5471            sum += bar.body_size() / range;
5472            count += 1;
5473        }
5474        if count == 0 { return None; }
5475        Some(sum / Decimal::from(count))
5476    }
5477
5478    /// Net directional volume over the last `n` bars.
5479    ///
5480    /// Approximates buy-side volume as `volume * (close - low) / (high - low)` and sell-side as
5481    /// the remainder.  Net = buy_vol − sell_vol.  Bars with zero range contribute zero.
5482    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5483    pub fn net_volume(&self, n: usize) -> Option<Decimal> {
5484        if n == 0 || self.bars.len() < n { return None; }
5485        let start = self.bars.len() - n;
5486        let mut net = Decimal::ZERO;
5487        for bar in &self.bars[start..] {
5488            let range = bar.range();
5489            let vol = bar.volume.value();
5490            if range.is_zero() { continue; }
5491            let buy_frac = (bar.close.value() - bar.low.value()) / range;
5492            let buy_vol = vol * buy_frac;
5493            let sell_vol = vol - buy_vol;
5494            net += buy_vol - sell_vol;
5495        }
5496        Some(net)
5497    }
5498
5499    /// Average `high − open` over the last `n` bars.
5500    ///
5501    /// Measures the typical upper extension from the open.
5502    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5503    pub fn avg_high_minus_open(&self, n: usize) -> Option<Decimal> {
5504        if n == 0 || self.bars.len() < n { return None; }
5505        let start = self.bars.len() - n;
5506        #[allow(clippy::cast_possible_truncation)]
5507        let sum: Decimal = self.bars[start..].iter()
5508            .map(|b| b.high.value() - b.open.value())
5509            .sum();
5510        Some(sum / Decimal::from(n as u32))
5511    }
5512
5513    /// Percentage of the last `n` bars where close is in the upper half of the bar's range.
5514    ///
5515    /// A close is in the upper half when `close >= (high + low) / 2`.
5516    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5517    pub fn close_consistency(&self, n: usize) -> Option<Decimal> {
5518        if n == 0 || self.bars.len() < n { return None; }
5519        let start = self.bars.len() - n;
5520        let upper = self.bars[start..].iter().filter(|b| {
5521            let mid = b.midpoint();
5522            b.close.value() >= mid
5523        }).count();
5524        #[allow(clippy::cast_possible_truncation)]
5525        Some(Decimal::from(upper as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5526    }
5527
5528    /// Difference in momentum between a fast window and a slow window.
5529    ///
5530    /// Momentum is `close - close[n]`. Divergence = `fast_momentum - slow_momentum`.
5531    /// Positive values indicate short-term momentum is stronger than longer-term.
5532    /// Returns `None` if `fast >= slow` or insufficient bars.
5533    pub fn momentum_divergence(&self, fast: usize, slow: usize) -> Option<Decimal> {
5534        if fast == 0 || slow == 0 || fast >= slow { return None; }
5535        if self.bars.len() <= slow { return None; }
5536        let n = self.bars.len();
5537        let current = self.bars[n - 1].close.value();
5538        let fast_prev = self.bars[n - 1 - fast].close.value();
5539        let slow_prev = self.bars[n - 1 - slow].close.value();
5540        Some((current - fast_prev) - (current - slow_prev))
5541    }
5542
5543    /// Normalized price range: `(highest_high - lowest_low) / lowest_low * 100` over `n` bars.
5544    ///
5545    /// Returns `None` if `n == 0`, fewer than `n` bars, or lowest_low is zero.
5546    pub fn price_range_pct(&self, n: usize) -> Option<Decimal> {
5547        if n == 0 || self.bars.len() < n { return None; }
5548        let start = self.bars.len() - n;
5549        let high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5550        let low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5551        if low.is_zero() { return None; }
5552        Some((high - low) / low * Decimal::ONE_HUNDRED)
5553    }
5554
5555    /// Average signed `close − open` over the last `n` bars.
5556    ///
5557    /// Positive = net bullish body on average; negative = net bearish.
5558    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5559    pub fn avg_open_to_close(&self, n: usize) -> Option<Decimal> {
5560        if n == 0 || self.bars.len() < n { return None; }
5561        let start = self.bars.len() - n;
5562        #[allow(clippy::cast_possible_truncation)]
5563        let sum: Decimal = self.bars[start..].iter()
5564            .map(|b| b.close.value() - b.open.value())
5565            .sum();
5566        Some(sum / Decimal::from(n as u32))
5567    }
5568
5569    /// Change in H−L range: last-n average range minus prior-n average range.
5570    ///
5571    /// Positive = ranges are expanding; negative = contracting.
5572    /// Returns `None` if `n == 0` or fewer than `2 * n` bars exist.
5573    pub fn price_range_expansion(&self, n: usize) -> Option<Decimal> {
5574        if n == 0 || self.bars.len() < 2 * n { return None; }
5575        let len = self.bars.len();
5576        #[allow(clippy::cast_possible_truncation)]
5577        let n_dec = Decimal::from(n as u32);
5578        let recent_sum: Decimal = self.bars[len - n..].iter()
5579            .map(|b| b.range())
5580            .sum();
5581        let prior_sum: Decimal = self.bars[len - 2 * n..len - n].iter()
5582            .map(|b| b.range())
5583            .sum();
5584        Some((recent_sum - prior_sum) / n_dec)
5585    }
5586
5587    /// Fraction of total volume contributed by up-bars (close > open) over the last `n` bars.
5588    ///
5589    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or total volume is zero.
5590    pub fn up_volume_fraction(&self, n: usize) -> Option<Decimal> {
5591        if n == 0 || self.bars.len() < n { return None; }
5592        let start = self.bars.len() - n;
5593        let mut up_vol = Decimal::ZERO;
5594        let mut total_vol = Decimal::ZERO;
5595        for bar in &self.bars[start..] {
5596            let v = bar.volume.value();
5597            total_vol += v;
5598            if bar.close.value() > bar.open.value() {
5599                up_vol += v;
5600            }
5601        }
5602        if total_vol.is_zero() { return None; }
5603        Some(up_vol / total_vol)
5604    }
5605
5606    /// Sample standard deviation of volume over the last `n` bars.
5607    ///
5608    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
5609    pub fn std_volume(&self, n: usize) -> Option<f64> {
5610        use rust_decimal::prelude::ToPrimitive;
5611        if n < 2 || self.bars.len() < n { return None; }
5612        let start = self.bars.len() - n;
5613        let vols: Vec<f64> = self.bars[start..].iter()
5614            .filter_map(|b| b.volume.value().to_f64())
5615            .collect();
5616        if vols.len() < 2 { return None; }
5617        let nf = vols.len() as f64;
5618        let mean = vols.iter().sum::<f64>() / nf;
5619        let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0);
5620        Some(var.sqrt())
5621    }
5622
5623    /// Longest consecutive run of bars with close < prev close across the entire series.
5624    ///
5625    /// Returns `0` if the series has fewer than 2 bars.
5626    pub fn longest_losing_streak(&self) -> usize {
5627        if self.bars.len() < 2 { return 0; }
5628        let mut max_streak = 0usize;
5629        let mut current = 0usize;
5630        for i in 1..self.bars.len() {
5631            if self.bars[i].close.value() < self.bars[i - 1].close.value() {
5632                current += 1;
5633                if current > max_streak { max_streak = current; }
5634            } else {
5635                current = 0;
5636            }
5637        }
5638        max_streak
5639    }
5640
5641    /// Maximum close price over the last `n` bars.
5642    ///
5643    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5644    pub fn recent_max_close(&self, n: usize) -> Option<Decimal> {
5645        if n == 0 || self.bars.len() < n { return None; }
5646        let start = self.bars.len() - n;
5647        self.bars[start..].iter().map(|b| b.close.value()).max()
5648    }
5649
5650    /// Minimum close price over the last `n` bars.
5651    ///
5652    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5653    pub fn recent_min_close(&self, n: usize) -> Option<Decimal> {
5654        if n == 0 || self.bars.len() < n { return None; }
5655        let start = self.bars.len() - n;
5656        self.bars[start..].iter().map(|b| b.close.value()).min()
5657    }
5658
5659    /// Chaikin oscillator: fast EMA of (close-change * volume) minus slow EMA.
5660    ///
5661    /// Measures divergence between fast and slow accumulation/distribution momentum.
5662    /// Returns `None` if `fast == 0`, `slow == 0`, `fast >= slow`, or not enough bars.
5663    pub fn chaikin_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
5664        if fast == 0 || slow == 0 || fast >= slow || self.bars.len() <= slow { return None; }
5665        let n = self.bars.len();
5666        // fast EMA: use simple approximation via alpha = 2/(fast+1)
5667        // We compute EMA of (close * volume) over last `slow` bars
5668        let alpha_fast = Decimal::TWO / Decimal::from(fast + 1);
5669        let alpha_slow = Decimal::TWO / Decimal::from(slow + 1);
5670        let start = n - slow;
5671        let mut ema_fast = self.bars[start].close.value() * self.bars[start].volume.value();
5672        let mut ema_slow = ema_fast;
5673        for bar in &self.bars[start + 1..] {
5674            let adv = bar.close.value() * bar.volume.value();
5675            ema_fast = alpha_fast * adv + (Decimal::ONE - alpha_fast) * ema_fast;
5676            ema_slow = alpha_slow * adv + (Decimal::ONE - alpha_slow) * ema_slow;
5677        }
5678        Some(ema_fast - ema_slow)
5679    }
5680
5681    /// Net bullish/bearish bias over the last `n` bars.
5682    ///
5683    /// Returns `(bullish_count as i64) - (bearish_count as i64)` where
5684    /// bullish = `close > open` and bearish = `close < open`.
5685    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5686    pub fn candle_body_trend(&self, n: usize) -> Option<i64> {
5687        if n == 0 || self.bars.len() < n { return None; }
5688        let start = self.bars.len() - n;
5689        let bull = self.bars[start..].iter()
5690            .filter(|b| b.is_bullish()).count() as i64;
5691        let bear = self.bars[start..].iter()
5692            .filter(|b| b.is_bearish()).count() as i64;
5693        Some(bull - bear)
5694    }
5695
5696    /// Percentage of bars over the last `n` that are doji (body ≤ 10% of range).
5697    pub fn pct_doji(&self, n: usize) -> Option<Decimal> {
5698        if n == 0 || self.bars.len() < n { return None; }
5699        let start = self.bars.len() - n;
5700        let doji_count = self.bars[start..].iter().filter(|b| {
5701            let range = b.range();
5702            if range.is_zero() { return true; }
5703            let body = b.body_size();
5704            body / range <= Decimal::new(1, 1)
5705        }).count() as u32;
5706        Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5707    }
5708
5709    /// Linear regression slope direction of closes over `n` bars.
5710    /// Returns `+1` if upward, `-1` if downward, `0` if flat.
5711    pub fn recent_close_trend(&self, n: usize) -> Option<i64> {
5712        if n < 2 || self.bars.len() < n { return None; }
5713        let start = self.bars.len() - n;
5714        let closes: Vec<f64> = self.bars[start..]
5715            .iter()
5716            .map(|b| { use rust_decimal::prelude::ToPrimitive; b.close.value().to_f64().unwrap_or(0.0) })
5717            .collect();
5718        let m = closes.len() as f64;
5719        let x_mean = (m - 1.0) / 2.0;
5720        let y_mean: f64 = closes.iter().sum::<f64>() / m;
5721        let mut num = 0.0f64;
5722        let mut den = 0.0f64;
5723        for (i, &y) in closes.iter().enumerate() {
5724            let dx = i as f64 - x_mean;
5725            num += dx * (y - y_mean);
5726            den += dx * dx;
5727        }
5728        if den == 0.0 { return Some(0); }
5729        let slope = num / den;
5730        if slope > 1e-10 { Some(1) } else if slope < -1e-10 { Some(-1) } else { Some(0) }
5731    }
5732
5733    /// Max high minus min low over the last `n` bars.
5734    pub fn high_low_range(&self, n: usize) -> Option<Decimal> {
5735        if n == 0 || self.bars.len() < n { return None; }
5736        let start = self.bars.len() - n;
5737        let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5738        let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5739        Some(max_high - min_low)
5740    }
5741
5742    /// Count of bars in the last `n` where volume exceeds the rolling average over those `n` bars.
5743    ///
5744    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5745    pub fn volume_above_avg_count(&self, n: usize) -> Option<usize> {
5746        if n == 0 || self.bars.len() < n { return None; }
5747        let start = self.bars.len() - n;
5748        let vols: Vec<Decimal> = self.bars[start..].iter().map(|b| b.volume.value()).collect();
5749        let avg = vols.iter().sum::<Decimal>() / Decimal::from(n);
5750        Some(vols.iter().filter(|&&v| v > avg).count())
5751    }
5752
5753    /// Ratio of the last bar's range to the average range over `n` bars.
5754    ///
5755    /// Returns `None` if fewer than `n` bars exist or average range is zero.
5756    pub fn range_vs_atr_ratio(&self, n: usize) -> Option<Decimal> {
5757        if n == 0 || self.bars.len() < n { return None; }
5758        let start = self.bars.len() - n;
5759        let avg_range = self.bars[start..].iter()
5760            .map(|b| b.range())
5761            .sum::<Decimal>() / Decimal::from(n);
5762        if avg_range.is_zero() { return None; }
5763        let last = self.bars.last()?;
5764        Some((last.range()) / avg_range)
5765    }
5766
5767    /// Average volume on bullish bars (close > open) over the last `n` bars.
5768    pub fn avg_volume_on_up_bars(&self, n: usize) -> Option<Decimal> {
5769        if n == 0 || self.bars.len() < n { return None; }
5770        let start = self.bars.len() - n;
5771        let up_vols: Vec<Decimal> = self.bars[start..].iter()
5772            .filter(|b| b.is_bullish())
5773            .map(|b| b.volume.value())
5774            .collect();
5775        if up_vols.is_empty() { return None; }
5776        Some(up_vols.iter().sum::<Decimal>() / Decimal::from(up_vols.len() as u32))
5777    }
5778
5779    /// Average volume on bearish bars (close < open) over the last `n` bars.
5780    pub fn avg_volume_on_down_bars(&self, n: usize) -> Option<Decimal> {
5781        if n == 0 || self.bars.len() < n { return None; }
5782        let start = self.bars.len() - n;
5783        let down_vols: Vec<Decimal> = self.bars[start..].iter()
5784            .filter(|b| b.is_bearish())
5785            .map(|b| b.volume.value())
5786            .collect();
5787        if down_vols.is_empty() { return None; }
5788        Some(down_vols.iter().sum::<Decimal>() / Decimal::from(down_vols.len() as u32))
5789    }
5790
5791    /// Percentage of bars over the last `n` where close > open.
5792    pub fn pct_bars_close_above_open(&self, n: usize) -> Option<Decimal> {
5793        if n == 0 || self.bars.len() < n { return None; }
5794        let start = self.bars.len() - n;
5795        let bull = self.bars[start..].iter()
5796            .filter(|b| b.is_bullish())
5797            .count() as u32;
5798        Some(Decimal::from(bull) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5799    }
5800
5801    /// Where the latest open sits within the recent `n`-bar high-low range, in `[0, 1]`.
5802    ///
5803    /// `0` = at the low, `1` = at the high. Returns `None` if range is zero.
5804    pub fn open_range_position(&self, n: usize) -> Option<Decimal> {
5805        if n == 0 || self.bars.len() < n { return None; }
5806        let start = self.bars.len() - n;
5807        let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5808        let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5809        let range = max_high - min_low;
5810        if range.is_zero() { return None; }
5811        let last_open = self.bars.last()?.open.value();
5812        Some((last_open - min_low) / range)
5813    }
5814
5815    /// Count of bars in last `n` where `|open - prev_close| / prev_close >= threshold_pct / 100`.
5816    ///
5817    /// Returns `None` if `n < 2` or fewer than `n+1` bars exist.
5818    pub fn overnight_gap_count(&self, n: usize, threshold_pct: Decimal) -> Option<usize> {
5819        if n < 2 || self.bars.len() <= n { return None; }
5820        let start = self.bars.len() - n;
5821        let threshold = threshold_pct / Decimal::ONE_HUNDRED;
5822        let count = self.bars[start..].iter().enumerate().filter(|(i, b)| {
5823            let prev_close = self.bars[start + i - 1].close.value();
5824            if prev_close.is_zero() { return false; }
5825            let gap = (b.open.value() - prev_close).abs() / prev_close;
5826            gap >= threshold
5827        }).count();
5828        Some(count)
5829    }
5830
5831    /// Fraction of bars in the last `n` where close direction matches the overall n-bar trend.
5832    ///
5833    /// Overall trend is up if `close[-1] > close[-n]`, down if < , flat otherwise.
5834    /// Returns `None` if `n < 2` or fewer than `n` bars.
5835    pub fn trend_consistency(&self, n: usize) -> Option<Decimal> {
5836        if n < 2 || self.bars.len() < n { return None; }
5837        let start = self.bars.len() - n;
5838        let first_close = self.bars[start].close.value();
5839        let last_close = self.bars.last()?.close.value();
5840        if first_close == last_close { return Some(Decimal::ZERO); }
5841        let up_trend = last_close > first_close;
5842        let consistent: usize = self.bars[start + 1..].iter().enumerate()
5843            .filter(|(i, b)| {
5844                let prev = self.bars[start + i].close.value();
5845                if up_trend { b.close.value() > prev } else { b.close.value() < prev }
5846            })
5847            .count();
5848        Some(Decimal::from(consistent) / Decimal::from(n - 1))
5849    }
5850
5851    /// The close price of the most recent bar.
5852    pub fn last_close(&self) -> Option<Decimal> {
5853        self.bars.last().map(|b| b.close.value())
5854    }
5855
5856    /// The close price of the earliest bar.
5857    pub fn first_close(&self) -> Option<Decimal> {
5858        self.bars.first().map(|b| b.close.value())
5859    }
5860
5861    /// Absolute close price change between the bar `n` bars ago and the latest bar.
5862    ///
5863    /// Returns `None` if fewer than `n + 1` bars exist.
5864    pub fn close_change_n(&self, n: usize) -> Option<Decimal> {
5865        if n == 0 || self.bars.len() <= n { return None; }
5866        let prev = self.bars[self.bars.len() - 1 - n].close.value();
5867        let last = self.bars.last()?.close.value();
5868        Some(last - prev)
5869    }
5870
5871    /// Percentage close price change over the last `n` bars.
5872    ///
5873    /// Returns `None` if fewer than `n + 1` bars exist or the reference close is zero.
5874    pub fn pct_change_n(&self, n: usize) -> Option<Decimal> {
5875        if n == 0 || self.bars.len() <= n { return None; }
5876        let prev = self.bars[self.bars.len() - 1 - n].close.value();
5877        if prev.is_zero() { return None; }
5878        let last = self.bars.last()?.close.value();
5879        Some((last - prev) / prev * Decimal::ONE_HUNDRED)
5880    }
5881
5882    /// Average ratio of `(high - close) / range` over the last `n` bars.
5883    ///
5884    /// Measures how far the close is from the high on average. Returns `None` if range is always zero.
5885    pub fn close_to_high_ratio(&self, n: usize) -> Option<Decimal> {
5886        if n == 0 || self.bars.len() < n { return None; }
5887        let start = self.bars.len() - n;
5888        let mut sum = Decimal::ZERO;
5889        let mut count = 0u32;
5890        for b in &self.bars[start..] {
5891            let range = b.range();
5892            if range.is_zero() { continue; }
5893            sum += (b.high.value() - b.close.value()) / range;
5894            count += 1;
5895        }
5896        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5897    }
5898
5899    /// Average ratio of `(close - low) / range` over the last `n` bars.
5900    ///
5901    /// Measures how far the close is from the low on average.
5902    pub fn close_to_low_ratio(&self, n: usize) -> Option<Decimal> {
5903        if n == 0 || self.bars.len() < n { return None; }
5904        let start = self.bars.len() - n;
5905        let mut sum = Decimal::ZERO;
5906        let mut count = 0u32;
5907        for b in &self.bars[start..] {
5908            let range = b.range();
5909            if range.is_zero() { continue; }
5910            sum += (b.close.value() - b.low.value()) / range;
5911            count += 1;
5912        }
5913        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5914    }
5915
5916    /// Coefficient of variation of volume over the last `n` bars: `std(vol) / mean(vol)`.
5917    ///
5918    /// Returns `None` if fewer than 2 bars or mean volume is zero.
5919    pub fn volume_coefficient_of_variation(&self, n: usize) -> Option<f64> {
5920        if n < 2 || self.bars.len() < n { return None; }
5921        let start = self.bars.len() - n;
5922        let vols: Vec<f64> = self.bars[start..]
5923            .iter()
5924            .map(|b| { use rust_decimal::prelude::ToPrimitive; b.volume.value().to_f64().unwrap_or(0.0) })
5925            .collect();
5926        let mean = vols.iter().sum::<f64>() / vols.len() as f64;
5927        if mean == 0.0 { return None; }
5928        let variance = vols.iter().map(|&v| { let d = v - mean; d * d }).sum::<f64>() / vols.len() as f64;
5929        Some(variance.sqrt() / mean)
5930    }
5931
5932    /// Average ratio of the upper wick to the total bar range over the last `n` bars.
5933    ///
5934    /// Upper wick = `high - max(open, close)`. Returns `None` if range is always zero.
5935    pub fn close_wick_ratio(&self, n: usize) -> Option<Decimal> {
5936        if n == 0 || self.bars.len() < n { return None; }
5937        let start = self.bars.len() - n;
5938        let mut sum = Decimal::ZERO;
5939        let mut count = 0u32;
5940        for b in &self.bars[start..] {
5941            let range = b.range();
5942            if range.is_zero() { continue; }
5943            let body_top = b.open.value().max(b.close.value());
5944            let upper_wick = b.high.value() - body_top;
5945            sum += upper_wick / range;
5946            count += 1;
5947        }
5948        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5949    }
5950
5951    /// Wick imbalance over last `n` bars: `(upper_wick_sum - lower_wick_sum) / range_sum`.
5952    ///
5953    /// Positive = more upper-wick pressure (bearish); Negative = more lower-wick pressure (bullish).
5954    /// Returns `None` if `n == 0`, fewer than `n` bars, or range_sum is zero.
5955    pub fn wick_imbalance(&self, n: usize) -> Option<Decimal> {
5956        if n == 0 || self.bars.len() < n { return None; }
5957        let start = self.bars.len() - n;
5958        let mut upper_sum = Decimal::ZERO;
5959        let mut lower_sum = Decimal::ZERO;
5960        let mut range_sum = Decimal::ZERO;
5961        for b in &self.bars[start..] {
5962            let range = b.range();
5963            if range.is_zero() { continue; }
5964            let body_top = b.open.value().max(b.close.value());
5965            let body_bot = b.open.value().min(b.close.value());
5966            upper_sum += b.high.value() - body_top;
5967            lower_sum += body_bot - b.low.value();
5968            range_sum += range;
5969        }
5970        if range_sum.is_zero() { return None; }
5971        Some((upper_sum - lower_sum) / range_sum)
5972    }
5973
5974    /// Average candle size (`high - low`) over the last `n` bars.
5975    ///
5976    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5977    pub fn avg_candle_size(&self, n: usize) -> Option<Decimal> {
5978        if n == 0 || self.bars.len() < n { return None; }
5979        let start = self.bars.len() - n;
5980        Some(self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>()
5981            / Decimal::from(n))
5982    }
5983
5984    /// Average body-to-range ratio for bullish bars (close > open) over the last `n` bars.
5985    ///
5986    /// Returns `None` if `n == 0`, fewer than `n` bars, or no bullish bars with range > 0.
5987    pub fn bull_strength(&self, n: usize) -> Option<Decimal> {
5988        if n == 0 || self.bars.len() < n { return None; }
5989        let start = self.bars.len() - n;
5990        let mut sum = Decimal::ZERO;
5991        let mut count = 0u32;
5992        for b in &self.bars[start..] {
5993            if b.close.value() <= b.open.value() { continue; }
5994            let range = b.range();
5995            if range.is_zero() { continue; }
5996            sum += (b.close.value() - b.open.value()) / range;
5997            count += 1;
5998        }
5999        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6000    }
6001
6002    /// Average body-to-range ratio for bearish bars (close < open) over the last `n` bars.
6003    pub fn bear_strength(&self, n: usize) -> Option<Decimal> {
6004        if n == 0 || self.bars.len() < n { return None; }
6005        let start = self.bars.len() - n;
6006        let mut sum = Decimal::ZERO;
6007        let mut count = 0u32;
6008        for b in &self.bars[start..] {
6009            if b.is_bullish() { continue; }
6010            let range = b.range();
6011            if range.is_zero() { continue; }
6012            sum += (b.open.value() - b.close.value()) / range;
6013            count += 1;
6014        }
6015        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6016    }
6017
6018    /// Open price of the most recent bar.
6019    pub fn last_open(&self) -> Option<Decimal> {
6020        self.bars.last().map(|b| b.open.value())
6021    }
6022
6023    /// High price of the most recent bar.
6024    pub fn last_high(&self) -> Option<Decimal> {
6025        self.bars.last().map(|b| b.high.value())
6026    }
6027
6028    /// Low price of the most recent bar.
6029    pub fn last_low(&self) -> Option<Decimal> {
6030        self.bars.last().map(|b| b.low.value())
6031    }
6032
6033    /// Volume of the most recent bar.
6034    pub fn last_volume(&self) -> Option<Decimal> {
6035        self.bars.last().map(|b| b.volume.value())
6036    }
6037
6038    /// Count of bars in the last `n` where the close exceeded the prior bar's high.
6039    ///
6040    /// Returns `None` if fewer than `n + 1` bars exist.
6041    pub fn close_above_prev_high(&self, n: usize) -> Option<usize> {
6042        if n == 0 || self.bars.len() <= n { return None; }
6043        let start = self.bars.len() - n;
6044        // start >= 1 since bars.len() > n
6045        let count = self.bars[start..].iter().enumerate()
6046            .filter(|(i, b)| b.close.value() > self.bars[start - 1 + i].high.value())
6047            .count();
6048        Some(count)
6049    }
6050
6051    /// Shannon entropy of close price direction over last `n` bars (in bits).
6052    ///
6053    /// Uses up/down proportions `p` and `1-p`. Returns `None` if `n < 2` or all moves same direction.
6054    pub fn price_entropy(&self, n: usize) -> Option<f64> {
6055        if n < 2 || self.bars.len() < n { return None; }
6056        let start = self.bars.len() - n;
6057        let mut ups = 0usize;
6058        for i in start + 1..self.bars.len() {
6059            if self.bars[i].close.value() > self.bars[i - 1].close.value() { ups += 1; }
6060        }
6061        let total = n - 1;
6062        if ups == 0 || ups == total { return None; }
6063        let p = ups as f64 / total as f64;
6064        let q = 1.0 - p;
6065        Some(-(p * p.log2() + q * q.log2()))
6066    }
6067
6068    /// Average intraday spread percentage `(high - low) / close * 100` over last `n` bars.
6069    ///
6070    /// Returns `None` if `n == 0`, fewer than `n` bars, or any close is zero.
6071    pub fn avg_spread_pct(&self, n: usize) -> Option<Decimal> {
6072        if n == 0 || self.bars.len() < n { return None; }
6073        let start = self.bars.len() - n;
6074        let mut sum = Decimal::ZERO;
6075        for b in &self.bars[start..] {
6076            let close = b.close.value();
6077            if close.is_zero() { return None; }
6078            sum += (b.range()) / close * Decimal::ONE_HUNDRED;
6079        }
6080        Some(sum / Decimal::from(n))
6081    }
6082
6083    /// Ratio of latest close to the close `n` bars ago.
6084    ///
6085    /// Returns `None` if fewer than `n + 1` bars or the reference close is zero.
6086    pub fn close_momentum_ratio(&self, n: usize) -> Option<Decimal> {
6087        if n == 0 || self.bars.len() <= n { return None; }
6088        let prev = self.bars[self.bars.len() - 1 - n].close.value();
6089        if prev.is_zero() { return None; }
6090        Some(self.bars.last()?.close.value() / prev)
6091    }
6092
6093    /// Change in momentum: `pct_change(fast) - pct_change(slow)`.
6094    ///
6095    /// Returns `None` if insufficient bars or a reference close is zero.
6096    pub fn price_velocity(&self, fast: usize, slow: usize) -> Option<Decimal> {
6097        let fast_chg = self.pct_change_n(fast)?;
6098        let slow_chg = self.pct_change_n(slow)?;
6099        Some(fast_chg - slow_chg)
6100    }
6101
6102    /// Longest run of consecutive bars where `close == open`.
6103    pub fn longest_flat_streak(&self) -> usize {
6104        let mut max_run = 0usize;
6105        let mut run = 0usize;
6106        for b in &self.bars {
6107            if b.close.value() == b.open.value() {
6108                run += 1;
6109                max_run = max_run.max(run);
6110            } else {
6111                run = 0;
6112            }
6113        }
6114        max_run
6115    }
6116
6117    /// Number of bars since the last new all-time high close.
6118    ///
6119    /// Returns `None` if there are no bars.
6120    pub fn bars_since_new_high(&self) -> Option<usize> {
6121        if self.bars.is_empty() { return None; }
6122        let mut last_high_idx = 0;
6123        let mut peak = self.bars[0].close.value();
6124        for (i, b) in self.bars.iter().enumerate() {
6125            if b.close.value() >= peak {
6126                peak = b.close.value();
6127                last_high_idx = i;
6128            }
6129        }
6130        Some(self.bars.len() - 1 - last_high_idx)
6131    }
6132
6133    /// Drawdown from the n-bar peak: `(current_close - n_bar_high) / n_bar_high * 100`.
6134    ///
6135    /// Negative values indicate drawdown; zero means at the n-bar high.
6136    /// Returns `None` if `n == 0`, fewer than `n` bars, or n-bar high is zero.
6137    pub fn drawdown_from_peak(&self, n: usize) -> Option<Decimal> {
6138        if n == 0 || self.bars.len() < n { return None; }
6139        let start = self.bars.len() - n;
6140        let peak = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6141        if peak.is_zero() { return None; }
6142        let current = self.bars.last()?.close.value();
6143        Some((current - peak) / peak * Decimal::ONE_HUNDRED)
6144    }
6145
6146    /// Percentage price oscillator: `(fast_sma - slow_sma) / slow_sma * 100`.
6147    ///
6148    /// Returns `None` if `fast == 0`, `slow == 0`, `fast >= slow`, fewer than `slow` bars,
6149    /// or the slow SMA is zero.
6150    pub fn price_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
6151        if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow { return None; }
6152        let n = self.bars.len();
6153        let fast_start = n - fast;
6154        let slow_start = n - slow;
6155        let fast_sma = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6156            / Decimal::from(fast);
6157        let slow_sma = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6158            / Decimal::from(slow);
6159        if slow_sma.is_zero() { return None; }
6160        Some((fast_sma - slow_sma) / slow_sma * Decimal::ONE_HUNDRED)
6161    }
6162
6163    /// Count of bars in the last `n` where the close was below the prior bar's low.
6164    ///
6165    /// Returns `None` if fewer than `n + 1` bars exist.
6166    pub fn close_below_prev_low(&self, n: usize) -> Option<usize> {
6167        if n == 0 || self.bars.len() <= n { return None; }
6168        let start = self.bars.len() - n;
6169        let count = self.bars[start..].iter().enumerate()
6170            .filter(|(i, b)| b.close.value() < self.bars[start - 1 + i].low.value())
6171            .count();
6172        Some(count)
6173    }
6174
6175    /// Count of bars in the last `n` where the close is above the `period`-bar simple moving average.
6176    ///
6177    /// Returns `None` if `n == 0`, `period == 0`, or fewer than `max(n, period)` bars.
6178    pub fn bars_above_ma(&self, n: usize, period: usize) -> Option<usize> {
6179        if n == 0 || period == 0 || self.bars.len() < n.max(period) { return None; }
6180        let sma_start = self.bars.len() - period;
6181        let sma = self.bars[sma_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6182            / Decimal::from(period);
6183        let bar_start = self.bars.len() - n;
6184        let count = self.bars[bar_start..].iter()
6185            .filter(|b| b.close.value() > sma)
6186            .count();
6187        Some(count)
6188    }
6189
6190    /// Ratio of latest `n`-bar range to prior `n`-bar range (price contraction < 1, expansion > 1).
6191    ///
6192    /// Returns `None` if fewer than `2 * n` bars or prior range is zero.
6193    pub fn price_contraction(&self, n: usize) -> Option<Decimal> {
6194        if n == 0 || self.bars.len() < 2 * n { return None; }
6195        let len = self.bars.len();
6196        let recent_high = self.bars[len - n..].iter().map(|b| b.high.value()).max()?;
6197        let recent_low = self.bars[len - n..].iter().map(|b| b.low.value()).min()?;
6198        let prior_high = self.bars[len - 2 * n..len - n].iter().map(|b| b.high.value()).max()?;
6199        let prior_low = self.bars[len - 2 * n..len - n].iter().map(|b| b.low.value()).min()?;
6200        let recent_range = recent_high - recent_low;
6201        let prior_range = prior_high - prior_low;
6202        if prior_range.is_zero() { return None; }
6203        Some(recent_range / prior_range)
6204    }
6205
6206    /// Number of bars since the last new all-time low close.
6207    ///
6208    /// Returns `None` if there are no bars.
6209    pub fn bars_since_new_low(&self) -> Option<usize> {
6210        if self.bars.is_empty() { return None; }
6211        let mut last_low_idx = 0;
6212        let mut trough = self.bars[0].close.value();
6213        for (i, b) in self.bars.iter().enumerate() {
6214            if b.close.value() <= trough {
6215                trough = b.close.value();
6216                last_low_idx = i;
6217            }
6218        }
6219        Some(self.bars.len() - 1 - last_low_idx)
6220    }
6221
6222    /// Average `volume / (high - low)` over last `n` bars — liquidity density metric.
6223    ///
6224    /// Higher values mean more volume traded per unit of price range.
6225    /// Returns `None` if `n == 0`, fewer than `n` bars, or all ranges are zero.
6226    pub fn volume_per_range(&self, n: usize) -> Option<Decimal> {
6227        if n == 0 || self.bars.len() < n { return None; }
6228        let start = self.bars.len() - n;
6229        let mut sum = Decimal::ZERO;
6230        let mut count = 0u32;
6231        for b in &self.bars[start..] {
6232            let range = b.range();
6233            if range.is_zero() { continue; }
6234            sum += b.volume.value() / range;
6235            count += 1;
6236        }
6237        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6238    }
6239
6240    /// Ratio of fast vol (std dev of closes over `fast` bars) to slow vol (over `slow` bars).
6241    ///
6242    /// Values > 1 mean recent volatility is elevated vs the longer window.
6243    /// Returns `None` if `fast < 2`, `slow < 2`, `fast >= slow`, fewer than `slow` bars,
6244    /// or slow vol is zero.
6245    pub fn price_volatility_ratio(&self, fast: usize, slow: usize) -> Option<f64> {
6246        use rust_decimal::prelude::ToPrimitive;
6247        if fast < 2 || slow < 2 || fast >= slow || self.bars.len() < slow { return None; }
6248        let n = self.bars.len();
6249        let std_dev = |bars: &[crate::ohlcv::OhlcvBar]| -> Option<f64> {
6250            let m = bars.len() as f64;
6251            let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.value().to_f64()).collect();
6252            if vals.len() < 2 { return None; }
6253            let mean = vals.iter().sum::<f64>() / m;
6254            let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
6255            Some(var.sqrt())
6256        };
6257        let fast_vol = std_dev(&self.bars[n - fast..])?;
6258        let slow_vol = std_dev(&self.bars[n - slow..])?;
6259        if slow_vol == 0.0 { return None; }
6260        Some(fast_vol / slow_vol)
6261    }
6262
6263    /// Reference to the most recent bar, or `None` if the series is empty.
6264    pub fn last_bar(&self) -> Option<&OhlcvBar> {
6265        self.bars.last()
6266    }
6267
6268    /// Absolute distance from the latest close to the `n`-bar high.
6269    ///
6270    /// Returns `None` if fewer than `n` bars exist.
6271    pub fn close_distance_from_high(&self, n: usize) -> Option<Decimal> {
6272        if n == 0 || self.bars.len() < n { return None; }
6273        let start = self.bars.len() - n;
6274        let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6275        Some((max_high - self.bars.last()?.close.value()).abs())
6276    }
6277
6278    /// Current close as a percentage above the `n`-bar low.
6279    ///
6280    /// Returns `None` if fewer than `n` bars or the low is zero.
6281    pub fn pct_from_low(&self, n: usize) -> Option<Decimal> {
6282        if n == 0 || self.bars.len() < n { return None; }
6283        let start = self.bars.len() - n;
6284        let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
6285        if min_low.is_zero() { return None; }
6286        Some((self.bars.last()?.close.value() - min_low) / min_low * Decimal::ONE_HUNDRED)
6287    }
6288
6289    /// Returns `true` if the latest close exceeds the highest close of the prior `n` bars.
6290    pub fn is_breakout_up(&self, n: usize) -> bool {
6291        if n == 0 || self.bars.len() <= n { return false; }
6292        let len = self.bars.len();
6293        let prior_high = self.bars[len - 1 - n..len - 1].iter().map(|b| b.close.value()).max();
6294        match (prior_high, self.bars.last()) {
6295            (Some(ph), Some(last)) => last.close.value() > ph,
6296            _ => false,
6297        }
6298    }
6299
6300    /// Count of the most recent consecutive bars whose close is above `price`.
6301    ///
6302    /// Starts from the latest bar and counts backwards. Returns `0` if the
6303    /// latest bar's close is not above `price`, or if the series is empty.
6304    pub fn consecutive_closes_above(&self, price: Decimal) -> usize {
6305        self.bars.iter().rev().take_while(|b| b.close.value() > price).count()
6306    }
6307
6308    /// Average `(open - low) / (high - low) * 100` over the last `n` bars.
6309    ///
6310    /// Measures where the open sits within the bar's range. Returns `None` if
6311    /// fewer than `n` bars exist or `n` is zero. Bars with zero range are skipped.
6312    pub fn open_range_pct(&self, n: usize) -> Option<f64> {
6313        use rust_decimal::prelude::ToPrimitive;
6314        if n == 0 || self.bars.len() < n { return None; }
6315        let start = self.bars.len() - n;
6316        let vals: Vec<f64> = self.bars[start..].iter().filter_map(|b| {
6317            let range = b.range();
6318            if range.is_zero() { return None; }
6319            let num = (b.open.value() - b.low.value()).to_f64()?;
6320            let den = range.to_f64()?;
6321            Some(num / den * 100.0)
6322        }).collect();
6323        if vals.is_empty() { return None; }
6324        Some(vals.iter().sum::<f64>() / vals.len() as f64)
6325    }
6326
6327    /// Skewness of bar returns over the last `n` bars.
6328    ///
6329    /// Computed as the third standardised central moment of `(close[i] - close[i-1]) / close[i-1]`
6330    /// returns. Returns `None` if fewer than 3 bars are available (need at least 2 returns to
6331    /// compute a meaningful third moment) or if standard deviation is zero.
6332    ///
6333    /// Positive skewness → right-tailed distribution (occasional large gains);
6334    /// negative skewness → left-tailed (occasional large losses).
6335    pub fn skewness_of_returns(&self, n: usize) -> Option<f64> {
6336        use rust_decimal::prelude::ToPrimitive;
6337        if n < 3 || self.bars.len() < n { return None; }
6338        let start = self.bars.len() - n;
6339        let slice = &self.bars[start..];
6340        let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
6341            let prev_c = w[0].close.value().to_f64()?;
6342            if prev_c == 0.0 { return None; }
6343            let curr_c = w[1].close.value().to_f64()?;
6344            Some((curr_c - prev_c) / prev_c)
6345        }).collect();
6346        let m = returns.len();
6347        if m < 2 { return None; }
6348        let mean = returns.iter().sum::<f64>() / m as f64;
6349        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m as f64;
6350        let std_dev = variance.sqrt();
6351        if std_dev == 0.0 { return None; }
6352        let skew = returns.iter().map(|r| ((r - mean) / std_dev).powi(3)).sum::<f64>() / m as f64;
6353        Some(skew)
6354    }
6355
6356    /// Excess kurtosis of bar returns over the last `n` bars.
6357    ///
6358    /// Computed as the fourth standardised central moment minus 3 (excess kurtosis,
6359    /// so a normal distribution gives 0). Requires at least 4 bars.
6360    /// Returns `None` if fewer than `n` bars exist or standard deviation is zero.
6361    ///
6362    /// High positive kurtosis → fat tails (more extreme moves than normal);
6363    /// negative kurtosis → thin tails.
6364    pub fn kurtosis_of_returns(&self, n: usize) -> Option<f64> {
6365        use rust_decimal::prelude::ToPrimitive;
6366        if n < 4 || self.bars.len() < n { return None; }
6367        let start = self.bars.len() - n;
6368        let slice = &self.bars[start..];
6369        let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
6370            let prev_c = w[0].close.value().to_f64()?;
6371            if prev_c == 0.0 { return None; }
6372            let curr_c = w[1].close.value().to_f64()?;
6373            Some((curr_c - prev_c) / prev_c)
6374        }).collect();
6375        let m = returns.len();
6376        if m < 3 { return None; }
6377        let mean = returns.iter().sum::<f64>() / m as f64;
6378        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m as f64;
6379        let std_dev = variance.sqrt();
6380        if std_dev == 0.0 { return None; }
6381        let kurt = returns.iter().map(|r| ((r - mean) / std_dev).powi(4)).sum::<f64>() / m as f64 - 3.0;
6382        Some(kurt)
6383    }
6384
6385    /// Lag-`lag` autocorrelation of close-to-close returns over the last `n` bars.
6386    ///
6387    /// Measures the linear relationship between a return series and itself shifted
6388    /// by `lag` periods. A value near +1 indicates momentum; near -1 indicates
6389    /// mean-reversion; near 0 indicates no serial dependency.
6390    ///
6391    /// Returns `None` if `n < lag + 2`, fewer than `n` bars exist, or either
6392    /// standard deviation is zero.
6393    pub fn autocorrelation_of_returns(&self, n: usize, lag: usize) -> Option<f64> {
6394        use rust_decimal::prelude::ToPrimitive;
6395        if lag == 0 || n < lag + 2 || self.bars.len() < n { return None; }
6396        let start = self.bars.len() - n;
6397        let slice = &self.bars[start..];
6398        let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
6399            let prev_c = w[0].close.value().to_f64()?;
6400            if prev_c == 0.0 { return None; }
6401            let curr_c = w[1].close.value().to_f64()?;
6402            Some((curr_c - prev_c) / prev_c)
6403        }).collect();
6404        if returns.len() <= lag { return None; }
6405        let x = &returns[..returns.len() - lag];
6406        let y = &returns[lag..];
6407        let m = x.len();
6408        if m == 0 { return None; }
6409        let mean_x = x.iter().sum::<f64>() / m as f64;
6410        let mean_y = y.iter().sum::<f64>() / m as f64;
6411        let cov: f64 = x.iter().zip(y.iter()).map(|(a, b)| (a - mean_x) * (b - mean_y)).sum::<f64>() / m as f64;
6412        let std_x = (x.iter().map(|a| (a - mean_x).powi(2)).sum::<f64>() / m as f64).sqrt();
6413        let std_y = (y.iter().map(|b| (b - mean_y).powi(2)).sum::<f64>() / m as f64).sqrt();
6414        if std_x == 0.0 || std_y == 0.0 { return None; }
6415        Some(cov / (std_x * std_y))
6416    }
6417
6418    /// Median volume over the last `n` bars.
6419    ///
6420    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
6421    pub fn median_volume(&self, n: usize) -> Option<Decimal> {
6422        use rust_decimal::prelude::ToPrimitive;
6423        if n == 0 || self.bars.len() < n { return None; }
6424        let start = self.bars.len() - n;
6425        let mut vols: Vec<f64> = self.bars[start..]
6426            .iter()
6427            .filter_map(|b| b.volume.value().to_f64())
6428            .collect();
6429        if vols.is_empty() { return None; }
6430        vols.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
6431        let mid = vols.len() / 2;
6432        let median = if vols.len() % 2 == 0 {
6433            (vols[mid - 1] + vols[mid]) / 2.0
6434        } else {
6435            vols[mid]
6436        };
6437        Decimal::try_from(median).ok()
6438    }
6439
6440    /// Average True Range over the last `n` bars.
6441    ///
6442    /// True Range for each bar uses the previous bar's close for the gap component.
6443    /// The first bar in the window uses `high - low` (no prior bar available).
6444    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
6445    pub fn avg_true_range(&self, n: usize) -> Option<Decimal> {
6446        if n == 0 || self.bars.len() < n { return None; }
6447        let start = self.bars.len() - n;
6448        let slice = &self.bars[start..];
6449        let mut sum = Decimal::ZERO;
6450        for (i, bar) in slice.iter().enumerate() {
6451            let prev = if i == 0 { None } else { Some(&slice[i - 1]) };
6452            sum += bar.true_range(prev);
6453        }
6454        sum.checked_div(Decimal::from(n as u32))
6455    }
6456
6457    /// Omega Ratio over the last `n` bars.
6458    ///
6459    /// `Omega = E[max(R - threshold, 0)] / E[max(threshold - R, 0)]`
6460    ///
6461    /// where `R` is the close-to-close return and `threshold` is the minimum
6462    /// acceptable return (MAR). A value > 1 means gains above the threshold
6463    /// outweigh losses below it.
6464    ///
6465    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or the loss
6466    /// expectation is zero (no returns fell below `threshold`).
6467    pub fn omega_ratio(&self, n: usize, threshold: Decimal) -> Option<Decimal> {
6468        if n < 2 || self.bars.len() < n { return None; }
6469        let start = self.bars.len() - n;
6470        let slice = &self.bars[start..];
6471        let mut gain_sum = Decimal::ZERO;
6472        let mut loss_sum = Decimal::ZERO;
6473        let mut count = 0u32;
6474        for w in slice.windows(2) {
6475            let prev_c = w[0].close.value();
6476            if prev_c.is_zero() { continue; }
6477            let ret = (w[1].close.value() - prev_c) / prev_c;
6478            gain_sum += (ret - threshold).max(Decimal::ZERO);
6479            loss_sum += (threshold - ret).max(Decimal::ZERO);
6480            count += 1;
6481        }
6482        if count == 0 || loss_sum.is_zero() { return None; }
6483        gain_sum.checked_div(loss_sum)
6484    }
6485
6486    /// Kelly Criterion fraction over the last `n` bars.
6487    ///
6488    /// Estimates the optimal fraction of capital to risk per trade using the
6489    /// discrete Kelly formula: `f* = W/L - (1-W)/G` where:
6490    /// - `W` = win rate (fraction of bars with positive return)
6491    /// - `L` = average loss (absolute value of losing returns)
6492    /// - `G` = average gain of winning returns
6493    ///
6494    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or there are no
6495    /// winning or losing bars. Clamps the output to `[-1, 1]`.
6496    pub fn kelly_fraction(&self, n: usize) -> Option<Decimal> {
6497        if n < 2 || self.bars.len() < n { return None; }
6498        let start = self.bars.len() - n;
6499        let slice = &self.bars[start..];
6500        let mut gains = Vec::new();
6501        let mut losses = Vec::new();
6502        for w in slice.windows(2) {
6503            let prev_c = w[0].close.value();
6504            if prev_c.is_zero() { continue; }
6505            let ret = (w[1].close.value() - prev_c) / prev_c;
6506            if ret > Decimal::ZERO { gains.push(ret); } else if ret < Decimal::ZERO { losses.push(-ret); }
6507        }
6508        let total = gains.len() + losses.len();
6509        if gains.is_empty() || losses.is_empty() || total == 0 { return None; }
6510        #[allow(clippy::cast_possible_truncation)]
6511        let win_rate = Decimal::from(gains.len() as u32) / Decimal::from(total as u32);
6512        let avg_gain: Decimal = gains.iter().copied().sum::<Decimal>() / Decimal::from(gains.len() as u32);
6513        let avg_loss: Decimal = losses.iter().copied().sum::<Decimal>() / Decimal::from(losses.len() as u32);
6514        if avg_loss.is_zero() || avg_gain.is_zero() { return None; }
6515        let kelly = win_rate / avg_loss - (Decimal::ONE - win_rate) / avg_gain;
6516        Some(kelly.clamp(Decimal::NEGATIVE_ONE, Decimal::ONE))
6517    }
6518
6519    /// Profit Factor over the last `n` bars.
6520    ///
6521    /// `Profit Factor = gross_gain / gross_loss` where gross gain is the sum of
6522    /// all positive close-to-close returns and gross loss is the absolute sum of
6523    /// all negative returns.
6524    ///
6525    /// A value > 1 means more was gained than lost; < 1 means net losing.
6526    ///
6527    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or gross loss is zero.
6528    pub fn profit_factor(&self, n: usize) -> Option<Decimal> {
6529        if n < 2 || self.bars.len() < n { return None; }
6530        let start = self.bars.len() - n;
6531        let slice = &self.bars[start..];
6532        let mut gross_gain = Decimal::ZERO;
6533        let mut gross_loss = Decimal::ZERO;
6534        for w in slice.windows(2) {
6535            let prev_c = w[0].close.value();
6536            if prev_c.is_zero() { continue; }
6537            let ret = w[1].close.value() - prev_c;
6538            if ret > Decimal::ZERO { gross_gain += ret; } else { gross_loss += -ret; }
6539        }
6540        if gross_loss.is_zero() { return None; }
6541        gross_gain.checked_div(gross_loss)
6542    }
6543
6544    /// Recovery Factor over the last `n` bars.
6545    ///
6546    /// `Recovery Factor = net_return / max_drawdown` where net return is
6547    /// `(last_close - first_close) / first_close` and max drawdown is the maximum
6548    /// peak-to-trough close drawdown over the window.
6549    ///
6550    /// Returns `None` if `n < 2`, fewer than `n` bars exist, first close is zero,
6551    /// or max drawdown is zero (no drawdown occurred).
6552    pub fn recovery_factor(&self, n: usize) -> Option<Decimal> {
6553        if n < 2 || self.bars.len() < n { return None; }
6554        let start = self.bars.len() - n;
6555        let slice = &self.bars[start..];
6556        let first_close = slice.first()?.close.value();
6557        let last_close  = slice.last()?.close.value();
6558        if first_close.is_zero() { return None; }
6559        let net_return = (last_close - first_close) / first_close;
6560        // Max drawdown: peak-to-trough
6561        let mut peak = Decimal::MIN;
6562        let mut max_dd = Decimal::ZERO;
6563        for bar in slice {
6564            let c = bar.close.value();
6565            if c > peak { peak = c; }
6566            let dd = if peak.is_zero() { Decimal::ZERO } else { (peak - c) / peak };
6567            if dd > max_dd { max_dd = dd; }
6568        }
6569        if max_dd.is_zero() { return None; }
6570        net_return.checked_div(max_dd)
6571    }
6572
6573    /// Maximum Adverse Excursion (MAE) over the last `n` bars.
6574    ///
6575    /// MAE is the average worst intra-bar move against the open direction.
6576    /// For a bullish bar (`close >= open`): MAE per bar = `(open - low) / open`.
6577    /// For a bearish bar (`close < open`): MAE per bar = `(high - open) / open`.
6578    ///
6579    /// Returns the average MAE across bars in the window. Returns `None` if
6580    /// `n == 0`, fewer than `n` bars exist, or all opens are zero.
6581    pub fn avg_max_adverse_excursion(&self, n: usize) -> Option<Decimal> {
6582        if n == 0 || self.bars.len() < n { return None; }
6583        let start = self.bars.len() - n;
6584        let slice = &self.bars[start..];
6585        let mut sum = Decimal::ZERO;
6586        let mut count = 0u32;
6587        for bar in slice {
6588            let o = bar.open.value();
6589            if o.is_zero() { continue; }
6590            let mae = if bar.is_bullish() {
6591                (o - bar.low.value()).abs() / o
6592            } else {
6593                (bar.high.value() - o).abs() / o
6594            };
6595            sum += mae;
6596            count += 1;
6597        }
6598        if count == 0 { return None; }
6599        sum.checked_div(Decimal::from(count))
6600    }
6601
6602    /// Ornstein-Uhlenbeck half-life of mean reversion over the last `n` bars.
6603    ///
6604    /// Estimates the half-life `τ = -ln(2) / λ` where `λ` is the mean-reversion
6605    /// speed from a lag-1 AR(1) regression on close prices:
6606    ///
6607    /// ```text
6608    /// ΔP[t] = α + λ × P[t-1] + ε[t]
6609    /// ```
6610    ///
6611    /// A small positive half-life (< 10 bars) indicates fast mean reversion.
6612    /// A very large half-life (> 100 bars) suggests a near-random-walk or trend.
6613    /// Returns `None` if `n < 3`, fewer than `n` bars exist, or the regression
6614    /// is degenerate (zero denominator).
6615    pub fn half_life_of_mean_reversion(&self, n: usize) -> Option<f64> {
6616        use rust_decimal::prelude::ToPrimitive;
6617        if n < 3 || self.bars.len() < n { return None; }
6618        let start = self.bars.len() - n;
6619        let slice = &self.bars[start..];
6620        let prices: Vec<f64> = slice.iter().filter_map(|b| b.close.value().to_f64()).collect();
6621        let m = prices.len();
6622        if m < 3 { return None; }
6623        // OLS: regress delta[t] = prices[t] - prices[t-1] on prices[t-1]
6624        let lagged: Vec<f64> = prices[..m - 1].to_vec();
6625        let delta:  Vec<f64> = prices[1..].iter().zip(prices[..m-1].iter()).map(|(a, b)| a - b).collect();
6626        let n_obs = lagged.len() as f64;
6627        let mean_x = lagged.iter().sum::<f64>() / n_obs;
6628        let mean_y = delta.iter().sum::<f64>() / n_obs;
6629        let cov_xy = lagged.iter().zip(delta.iter()).map(|(x, y)| (x - mean_x) * (y - mean_y)).sum::<f64>();
6630        let var_x  = lagged.iter().map(|x| (x - mean_x).powi(2)).sum::<f64>();
6631        if var_x == 0.0 { return None; }
6632        let lambda = cov_xy / var_x;
6633        if lambda >= 0.0 { return None; } // no mean reversion
6634        Some(-std::f64::consts::LN_2 / lambda)
6635    }
6636
6637    /// Treynor Ratio over the last `n` bars relative to a `market` series.
6638    ///
6639    /// `Treynor = (R_p - R_f) / β` where `R_p` is the annualised portfolio return,
6640    /// `R_f` is the risk-free rate, and `β` is the beta of this series against `market`.
6641    ///
6642    /// Returns `None` if `n < 3`, either series has fewer than `n` bars, or `β` is zero.
6643    pub fn treynor_ratio(&self, market: &OhlcvSeries, n: usize, risk_free_rate: f64) -> Option<f64> {
6644        use rust_decimal::prelude::ToPrimitive;
6645        if n < 3 || self.bars.len() < n || market.bars.len() < n { return None; }
6646        let beta = self.beta(market, n)?;
6647        if beta == 0.0 { return None; }
6648        // Annualise using n bars
6649        let start = self.bars.len() - n;
6650        let first_c = self.bars[start].close.value().to_f64()?;
6651        let last_c  = self.bars.last()?.close.value().to_f64()?;
6652        if first_c == 0.0 { return None; }
6653        let total_return = (last_c - first_c) / first_c;
6654        Some((total_return - risk_free_rate) / beta)
6655    }
6656
6657    /// Tracking Error vs a benchmark series over the last `n` bars.
6658    ///
6659    /// Tracking error is the standard deviation of the return differences
6660    /// `R_portfolio[t] - R_benchmark[t]` for each bar in the window.
6661    ///
6662    /// Returns `None` if `n < 2`, either series has fewer than `n` bars.
6663    pub fn tracking_error(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
6664        use rust_decimal::prelude::ToPrimitive;
6665        if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
6666        let p_start = self.bars.len() - n;
6667        let b_start = benchmark.bars.len() - n;
6668        let p_slice = &self.bars[p_start..];
6669        let b_slice = &benchmark.bars[b_start..];
6670        let diffs: Vec<f64> = p_slice.windows(2).zip(b_slice.windows(2)).filter_map(|(pw, bw)| {
6671            let pc0 = pw[0].close.value().to_f64()?;
6672            let bc0 = bw[0].close.value().to_f64()?;
6673            if pc0 == 0.0 || bc0 == 0.0 { return None; }
6674            let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
6675            let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
6676            Some(pr - br)
6677        }).collect();
6678        let m = diffs.len() as f64;
6679        if m < 1.0 { return None; }
6680        let mean = diffs.iter().sum::<f64>() / m;
6681        let var  = diffs.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / m;
6682        Some(var.sqrt())
6683    }
6684
6685    /// Upside Capture Ratio vs a benchmark over the last `n` bars.
6686    ///
6687    /// The upside capture ratio measures performance relative to the benchmark
6688    /// during periods when the benchmark was up:
6689    /// `upside_capture = avg_portfolio_return_on_up_bench / avg_bench_return_on_up_bench`.
6690    ///
6691    /// Returns `None` if `n < 2`, either series is too short, or the benchmark
6692    /// had no up bars in the window.
6693    pub fn up_capture(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
6694        use rust_decimal::prelude::ToPrimitive;
6695        if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
6696        let p_start = self.bars.len() - n;
6697        let b_start = benchmark.bars.len() - n;
6698        let p_slice = &self.bars[p_start..];
6699        let b_slice = &benchmark.bars[b_start..];
6700        let mut p_up_sum = 0.0f64;
6701        let mut b_up_sum = 0.0f64;
6702        let mut count = 0u32;
6703        for (pw, bw) in p_slice.windows(2).zip(b_slice.windows(2)) {
6704            let bc0 = bw[0].close.value().to_f64()?;
6705            let pc0 = pw[0].close.value().to_f64()?;
6706            if bc0 == 0.0 || pc0 == 0.0 { continue; }
6707            let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
6708            if br <= 0.0 { continue; }
6709            let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
6710            p_up_sum += pr;
6711            b_up_sum += br;
6712            count += 1;
6713        }
6714        if count == 0 || b_up_sum == 0.0 { return None; }
6715        Some(p_up_sum / b_up_sum)
6716    }
6717
6718    /// Downside Capture Ratio vs a benchmark over the last `n` bars.
6719    ///
6720    /// The downside capture ratio measures performance relative to the benchmark
6721    /// during periods when the benchmark was down:
6722    /// `downside_capture = avg_portfolio_return_on_down_bench / avg_bench_return_on_down_bench`.
6723    ///
6724    /// A value < 1 means the portfolio fell less than the benchmark (desirable).
6725    ///
6726    /// Returns `None` if `n < 2`, either series is too short, or the benchmark
6727    /// had no down bars in the window.
6728    pub fn down_capture(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
6729        use rust_decimal::prelude::ToPrimitive;
6730        if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
6731        let p_start = self.bars.len() - n;
6732        let b_start = benchmark.bars.len() - n;
6733        let p_slice = &self.bars[p_start..];
6734        let b_slice = &benchmark.bars[b_start..];
6735        let mut p_dn_sum = 0.0f64;
6736        let mut b_dn_sum = 0.0f64;
6737        let mut count = 0u32;
6738        for (pw, bw) in p_slice.windows(2).zip(b_slice.windows(2)) {
6739            let bc0 = bw[0].close.value().to_f64()?;
6740            let pc0 = pw[0].close.value().to_f64()?;
6741            if bc0 == 0.0 || pc0 == 0.0 { continue; }
6742            let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
6743            if br >= 0.0 { continue; }
6744            let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
6745            p_dn_sum += pr;
6746            b_dn_sum += br;
6747            count += 1;
6748        }
6749        if count == 0 || b_dn_sum == 0.0 { return None; }
6750        Some(p_dn_sum / b_dn_sum)
6751    }
6752
6753    /// Payoff Ratio over the last `n` bars.
6754    ///
6755    /// `Payoff Ratio = average_win / average_loss` where wins and losses are
6756    /// defined by positive and negative close-to-close returns respectively.
6757    ///
6758    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or there are no
6759    /// winning or losing bars.
6760    pub fn payoff_ratio(&self, n: usize) -> Option<Decimal> {
6761        if n < 2 || self.bars.len() < n { return None; }
6762        let start = self.bars.len() - n;
6763        let slice = &self.bars[start..];
6764        let mut win_sum  = Decimal::ZERO;
6765        let mut loss_sum = Decimal::ZERO;
6766        let mut win_cnt  = 0u32;
6767        let mut loss_cnt = 0u32;
6768        for w in slice.windows(2) {
6769            let pc = w[0].close.value();
6770            if pc.is_zero() { continue; }
6771            let ret = (w[1].close.value() - pc) / pc;
6772            if ret > Decimal::ZERO { win_sum  += ret; win_cnt  += 1; }
6773            else if ret < Decimal::ZERO { loss_sum += -ret; loss_cnt += 1; }
6774        }
6775        if win_cnt == 0 || loss_cnt == 0 { return None; }
6776        let avg_win  = win_sum  / Decimal::from(win_cnt);
6777        let avg_loss = loss_sum / Decimal::from(loss_cnt);
6778        avg_win.checked_div(avg_loss)
6779    }
6780
6781    /// Expected Value (EV) of a trade over the last `n` bars.
6782    ///
6783    /// `EV = win_rate × avg_win - loss_rate × avg_loss`
6784    ///
6785    /// where returns are computed bar-to-bar (close-to-close). A positive EV
6786    /// indicates the strategy has a statistical edge over the sample.
6787    ///
6788    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
6789    pub fn expected_value(&self, n: usize) -> Option<Decimal> {
6790        if n < 2 || self.bars.len() < n { return None; }
6791        let start = self.bars.len() - n;
6792        let slice = &self.bars[start..];
6793        let mut wins  = Vec::new();
6794        let mut losses = Vec::new();
6795        for w in slice.windows(2) {
6796            let pc = w[0].close.value();
6797            if pc.is_zero() { continue; }
6798            let ret = (w[1].close.value() - pc) / pc;
6799            if ret > Decimal::ZERO { wins.push(ret); }
6800            else if ret < Decimal::ZERO { losses.push(-ret); }
6801        }
6802        let total = wins.len() + losses.len();
6803        if total == 0 { return None; }
6804        #[allow(clippy::cast_possible_truncation)]
6805        let total_d = Decimal::from(total as u32);
6806        let win_rate  = Decimal::from(wins.len() as u32) / total_d;
6807        let loss_rate = Decimal::ONE - win_rate;
6808        let avg_win  = if wins.is_empty()   { Decimal::ZERO } else { wins.iter().copied().sum::<Decimal>()   / Decimal::from(wins.len() as u32) };
6809        let avg_loss = if losses.is_empty() { Decimal::ZERO } else { losses.iter().copied().sum::<Decimal>() / Decimal::from(losses.len() as u32) };
6810        Some(win_rate * avg_win - loss_rate * avg_loss)
6811    }
6812
6813    /// Count of bars within the last `n` bars where the close broke above the
6814    /// rolling `lookback`-bar high (i.e. `close > max(close[-lookback..-1])`).
6815    ///
6816    /// Useful for measuring breakout frequency. Returns `None` if `n == 0`,
6817    /// `lookback == 0`, or there are fewer than `n + lookback` bars.
6818    pub fn breakout_count(&self, n: usize, lookback: usize) -> Option<usize> {
6819        if n == 0 || lookback == 0 { return None; }
6820        let required = n + lookback;
6821        if self.bars.len() < required { return None; }
6822        let slice = &self.bars[self.bars.len() - required..];
6823        let count = (lookback..slice.len()).filter(|&i| {
6824            let current_close = slice[i].close.value();
6825            let prior_high = slice[i - lookback..i].iter().map(|b| b.close.value()).fold(Decimal::MIN, Decimal::max);
6826            current_close > prior_high
6827        }).count();
6828        Some(count)
6829    }
6830
6831    /// Percentage of bars in the last `n` where close is above its `period`-bar EMA.
6832    ///
6833    /// Computes a rolling EMA with the standard `2/(period+1)` factor across the
6834    /// window and counts how many of the last `n` closes lie above it.
6835    ///
6836    /// Returns `None` if `n == 0`, `period == 0`, or fewer than `n + period` bars exist.
6837    pub fn pct_close_above_ema(&self, n: usize, period: usize) -> Option<Decimal> {
6838        if n == 0 || period == 0 { return None; }
6839        let required = n + period - 1;
6840        if self.bars.len() < required { return None; }
6841        let slice = &self.bars[self.bars.len() - required..];
6842        // Seed EMA with first `period` bars
6843        #[allow(clippy::cast_possible_truncation)]
6844        let k = Decimal::TWO / Decimal::from((period + 1) as u32);
6845        let seed_sum: Decimal = slice[..period].iter().map(|b| b.close.value()).sum();
6846        let seed_avg = seed_sum / Decimal::from(period as u32);
6847        let mut ema = seed_avg;
6848        let mut above = 0u32;
6849        for bar in &slice[period..] {
6850            let c = bar.close.value();
6851            ema = c * k + ema * (Decimal::ONE - k);
6852            if c > ema { above += 1; }
6853        }
6854        let n_d = Decimal::from(n as u32);
6855        Some(Decimal::from(above) / n_d * Decimal::ONE_HUNDRED)
6856    }
6857
6858    /// Average volume imbalance over the last `n` bars.
6859    ///
6860    /// Volume imbalance per bar is estimated from the Close Location Value (CLV):
6861    /// `CLV = ((close - low) - (high - close)) / (high - low)`
6862    ///
6863    /// Multiplied by volume, this approximates the net buying/selling pressure:
6864    /// `imbalance = CLV × volume`.
6865    ///
6866    /// Returns the average across bars in the window. Returns `None` if `n == 0`
6867    /// or fewer than `n` bars exist. Bars with zero range or volume are skipped.
6868    pub fn avg_volume_imbalance(&self, n: usize) -> Option<Decimal> {
6869        if n == 0 || self.bars.len() < n { return None; }
6870        let start = self.bars.len() - n;
6871        let mut sum = Decimal::ZERO;
6872        let mut count = 0u32;
6873        for bar in &self.bars[start..] {
6874            let h = bar.high.value();
6875            let l = bar.low.value();
6876            let c = bar.close.value();
6877            let range = h - l;
6878            if range.is_zero() { continue; }
6879            let clv = ((c - l) - (h - c)) / range;
6880            sum += clv * bar.volume.value();
6881            count += 1;
6882        }
6883        if count == 0 { return None; }
6884        sum.checked_div(Decimal::from(count))
6885    }
6886
6887    /// Average Close Location Value (CLV) over the last `n` bars.
6888    ///
6889    /// `CLV = ((close - low) - (high - close)) / (high - low)`
6890    ///
6891    /// CLV ranges from -1 (close at low) to +1 (close at high). A positive
6892    /// average indicates persistent buying pressure; negative indicates selling.
6893    ///
6894    /// Bars with zero range are skipped. Returns `None` if `n == 0` or fewer
6895    /// than `n` bars exist.
6896    pub fn avg_clv(&self, n: usize) -> Option<Decimal> {
6897        if n == 0 || self.bars.len() < n { return None; }
6898        let start = self.bars.len() - n;
6899        let mut sum = Decimal::ZERO;
6900        let mut count = 0u32;
6901        for bar in &self.bars[start..] {
6902            let h = bar.high.value();
6903            let l = bar.low.value();
6904            let c = bar.close.value();
6905            let range = h - l;
6906            if range.is_zero() { continue; }
6907            sum += ((c - l) - (h - c)) / range;
6908            count += 1;
6909        }
6910        if count == 0 { return None; }
6911        sum.checked_div(Decimal::from(count))
6912    }
6913
6914    /// Count of bars in the last `n` where close is below the rolling `n`-bar
6915    /// closing high up to that point (i.e. the bar is "underwater").
6916    ///
6917    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
6918    pub fn bars_in_drawdown(&self, n: usize) -> Option<usize> {
6919        if n == 0 || self.bars.len() < n { return None; }
6920        let start = self.bars.len() - n;
6921        let slice = &self.bars[start..];
6922        let mut peak = Decimal::MIN;
6923        let mut count = 0usize;
6924        for bar in slice {
6925            let c = bar.close.value();
6926            if c > peak { peak = c; } else { count += 1; }
6927        }
6928        Some(count)
6929    }
6930
6931    /// Percentage of bars in the last `n` whose close exceeded the prior
6932    /// `lookback`-bar closing high (i.e. confirmed resistance breakouts).
6933    ///
6934    /// Returns `None` if `n == 0`, `lookback == 0`, or fewer than `n + lookback` bars exist.
6935    pub fn resistance_breakout_pct(&self, n: usize, lookback: usize) -> Option<Decimal> {
6936        if n == 0 || lookback == 0 { return None; }
6937        let count = self.breakout_count(n, lookback)?;
6938        #[allow(clippy::cast_possible_truncation)]
6939        Some(Decimal::from(count as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
6940    }
6941
6942    /// Average absolute open gap over the last `n` bars (in price points).
6943    ///
6944    /// The open gap for bar `i` is `|open[i] - close[i-1]|`. The first bar in
6945    /// the window uses the bar before the window for the prior close. Returns
6946    /// `None` if `n == 0` or fewer than `n + 1` bars exist.
6947    pub fn avg_abs_open_gap(&self, n: usize) -> Option<Decimal> {
6948        if n == 0 || self.bars.len() < n + 1 { return None; }
6949        let start = self.bars.len() - n;
6950        let slice = &self.bars[start - 1..]; // include prior bar for first gap
6951        let mut sum = Decimal::ZERO;
6952        for w in slice.windows(2) {
6953            let gap = (w[1].open.value() - w[0].close.value()).abs();
6954            sum += gap;
6955        }
6956        sum.checked_div(Decimal::from(n as u32))
6957    }
6958
6959    /// Average ratio of total shadow to body over the last `n` bars.
6960    ///
6961    /// Per bar: `(upper_shadow + lower_shadow) / body_size`.
6962    /// Bars with zero body are skipped (doji). Returns `None` if `n == 0`,
6963    /// fewer than `n` bars exist, or all bars are doji.
6964    pub fn avg_wicks_to_body(&self, n: usize) -> Option<Decimal> {
6965        if n == 0 || self.bars.len() < n { return None; }
6966        let start = self.bars.len() - n;
6967        let mut sum = Decimal::ZERO;
6968        let mut count = 0u32;
6969        for bar in &self.bars[start..] {
6970            let body = bar.body_size();
6971            if body.is_zero() { continue; }
6972            sum += (bar.upper_shadow() + bar.lower_shadow()) / body;
6973            count += 1;
6974        }
6975        if count == 0 { return None; }
6976        sum.checked_div(Decimal::from(count))
6977    }
6978
6979    /// Volume-trend correlation over the last `n` bars.
6980    ///
6981    /// Pearson correlation between bar volume and price direction (`+1` for up
6982    /// bars, `-1` for down bars, `0` for flat). A positive correlation indicates
6983    /// volume is heavier on up bars (bullish confirmation); negative indicates
6984    /// heavier volume on down bars.
6985    ///
6986    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or either series
6987    /// has zero standard deviation.
6988    pub fn volume_trend_correlation(&self, n: usize) -> Option<f64> {
6989        use rust_decimal::prelude::ToPrimitive;
6990        if n < 2 || self.bars.len() < n { return None; }
6991        let start = self.bars.len() - n;
6992        let slice = &self.bars[start..];
6993        let vols: Vec<f64> = slice.iter().filter_map(|b| b.volume.value().to_f64()).collect();
6994        let dirs: Vec<f64> = slice.iter().map(|b| {
6995            if b.is_bullish() { 1.0 } else if b.is_bearish() { -1.0 } else { 0.0 }
6996        }).collect();
6997        let m = vols.len().min(dirs.len()) as f64;
6998        if m < 2.0 { return None; }
6999        let mv = vols.iter().sum::<f64>() / m;
7000        let md = dirs.iter().sum::<f64>() / m;
7001        let cov  = vols.iter().zip(dirs.iter()).map(|(v, d)| (v - mv) * (d - md)).sum::<f64>() / m;
7002        let sv = (vols.iter().map(|v| (v - mv).powi(2)).sum::<f64>() / m).sqrt();
7003        let sd = (dirs.iter().map(|d| (d - md).powi(2)).sum::<f64>() / m).sqrt();
7004        if sv == 0.0 || sd == 0.0 { return None; }
7005        Some(cov / (sv * sd))
7006    }
7007
7008    /// Candle consistency over the last `n` bars.
7009    ///
7010    /// Measures the percentage of bars where the close direction (up/down)
7011    /// matches the previous bar's close direction. A high value (near 100%)
7012    /// indicates persistent trending; a low value indicates choppiness.
7013    ///
7014    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
7015    pub fn candle_consistency(&self, n: usize) -> Option<Decimal> {
7016        if n < 2 || self.bars.len() < n { return None; }
7017        let start = self.bars.len() - n;
7018        let slice = &self.bars[start..];
7019        let dirs: Vec<i8> = slice.iter().map(|b| {
7020            if b.is_bullish() { 1 } else if b.is_bearish() { -1 } else { 0 }
7021        }).collect();
7022        let consistent = dirs.windows(2).filter(|w| w[0] != 0 && w[1] != 0 && w[0] == w[1]).count();
7023        let total = dirs.windows(2).filter(|w| w[0] != 0 && w[1] != 0).count();
7024        if total == 0 { return None; }
7025        #[allow(clippy::cast_possible_truncation)]
7026        Some(Decimal::from(consistent as u32) / Decimal::from(total as u32) * Decimal::ONE_HUNDRED)
7027    }
7028
7029    /// Average absolute open-to-close spread over the last `n` bars.
7030    ///
7031    /// Per bar: `|close - open|`. This measures the average bar body size in
7032    /// absolute price terms, indicating intraday momentum strength.
7033    ///
7034    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
7035    pub fn avg_open_close_spread(&self, n: usize) -> Option<Decimal> {
7036        if n == 0 || self.bars.len() < n { return None; }
7037        let start = self.bars.len() - n;
7038        let sum: Decimal = self.bars[start..].iter()
7039            .map(|b| b.body_size())
7040            .sum();
7041        sum.checked_div(Decimal::from(n as u32))
7042    }
7043
7044    /// High-frequency volatility ratio over the last `n` bars.
7045    ///
7046    /// Per bar: `range / prev_close` where `range = high - low`. This measures
7047    /// the intraday price swing as a fraction of the prior close, combining
7048    /// elements of ATR and volatility. Returns the average over `n` bars.
7049    ///
7050    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
7051    pub fn avg_range_to_prev_close(&self, n: usize) -> Option<Decimal> {
7052        if n == 0 || self.bars.len() < n + 1 { return None; }
7053        let start = self.bars.len() - n - 1;
7054        let slice = &self.bars[start..];
7055        let mut sum = Decimal::ZERO;
7056        let mut count = 0u32;
7057        for w in slice.windows(2) {
7058            let pc = w[0].close.value();
7059            if pc.is_zero() { continue; }
7060            let range = w[1].range();
7061            sum += range / pc;
7062            count += 1;
7063        }
7064        if count == 0 { return None; }
7065        sum.checked_div(Decimal::from(count))
7066    }
7067    /// Returns the volume-weighted standard deviation of close prices over the
7068    /// last `n` bars.
7069    ///
7070    /// Returns `None` if fewer than `n` bars are available or total volume is zero.
7071    pub fn volume_weighted_std_dev(&self, n: usize) -> Option<Decimal> {
7072        use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
7073        if n == 0 || self.bars.len() < n { return None; }
7074        let slice = &self.bars[self.bars.len() - n..];
7075        let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
7076        if total_vol.is_zero() { return None; }
7077        let vwap: Decimal = slice.iter()
7078            .map(|b| b.close.value() * b.volume.value())
7079            .sum::<Decimal>() / total_vol;
7080        let vw_var: Decimal = slice.iter()
7081            .map(|b| { let d = b.close.value() - vwap; b.volume.value() * d * d })
7082            .sum::<Decimal>() / total_vol;
7083        let vf = vw_var.to_f64()?;
7084        Decimal::from_f64(vf.sqrt())
7085    }
7086
7087    /// Returns the fraction of bars in the last `n` bars that are inside bars
7088    /// (high strictly below previous high AND low strictly above previous low).
7089    ///
7090    /// Returns `None` if fewer than `n + 1` bars are available.
7091    pub fn pct_inside_bars(&self, n: usize) -> Option<Decimal> {
7092        if n == 0 || self.bars.len() < n + 1 { return None; }
7093        let slice = &self.bars[self.bars.len() - n - 1..];
7094        let count = slice.windows(2)
7095            .filter(|w| w[1].high.value() < w[0].high.value() && w[1].low.value() > w[0].low.value())
7096            .count();
7097        Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
7098    }
7099
7100    /// Returns the average bar polarity over the last `n` bars.
7101    ///
7102    /// Each bar contributes +1 if close > open, -1 if close < open, 0 if equal.
7103    ///
7104    /// Returns `None` if fewer than `n` bars are available.
7105    pub fn avg_bar_polarity(&self, n: usize) -> Option<Decimal> {
7106        if n == 0 || self.bars.len() < n { return None; }
7107        let slice = &self.bars[self.bars.len() - n..];
7108        let sum: Decimal = slice.iter().map(|b| {
7109            let c = b.close.value();
7110            let o = b.open.value();
7111            if c > o { Decimal::ONE } else if c < o { Decimal::NEGATIVE_ONE } else { Decimal::ZERO }
7112        }).sum();
7113        sum.checked_div(Decimal::from(n as u32))
7114    }
7115
7116    /// Returns the return tail ratio over the last `n` bars: 95th-percentile return
7117    /// divided by the absolute value of the 5th-percentile return.
7118    ///
7119    /// A value > 1 indicates a fatter right tail (positive return extremes dominate).
7120    /// Returns `None` if fewer than `n + 1` bars are available or the lower
7121    /// percentile return is zero.
7122    pub fn return_tail_ratio(&self, n: usize) -> Option<Decimal> {
7123        if n < 2 || self.bars.len() < n + 1 { return None; }
7124        let slice = &self.bars[self.bars.len() - n - 1..];
7125        let mut returns: Vec<Decimal> = slice.windows(2)
7126            .filter_map(|w| {
7127                let pc = w[0].close.value();
7128                if pc.is_zero() { return None; }
7129                Some((w[1].close.value() - pc) / pc)
7130            })
7131            .collect();
7132        if returns.is_empty() { return None; }
7133        returns.sort();
7134        let len = returns.len();
7135        let p95_idx = ((len as f64 * 0.95) as usize).min(len - 1);
7136        let p05_idx = ((len as f64 * 0.05) as usize).min(len - 1);
7137        let p95 = returns[p95_idx];
7138        let p05 = returns[p05_idx].abs();
7139        if p05.is_zero() { return None; }
7140        p95.checked_div(p05)
7141    }
7142
7143    /// Returns the rolling sum of overnight gaps (`open - prev_close`) over the
7144    /// last `n` bars (requires `n + 1` bars).
7145    ///
7146    /// Positive = net upward gap bias; negative = net downward gap bias.
7147    /// Returns `None` if fewer than `n + 1` bars are available.
7148    pub fn signed_gap_sum(&self, n: usize) -> Option<Decimal> {
7149        if n == 0 || self.bars.len() < n + 1 { return None; }
7150        let slice = &self.bars[self.bars.len() - n - 1..];
7151        let sum: Decimal = slice.windows(2)
7152            .map(|w| w[1].open.value() - w[0].close.value())
7153            .sum();
7154        Some(sum)
7155    }
7156
7157    /// Returns the fraction of the last `n` bars where `close > open` (bullish candles).
7158    ///
7159    /// Returns `None` if fewer than `n` bars are available.
7160    pub fn bull_bar_fraction(&self, n: usize) -> Option<Decimal> {
7161        if n == 0 || self.bars.len() < n { return None; }
7162        let slice = &self.bars[self.bars.len() - n..];
7163        let count = slice.iter().filter(|b| b.is_bullish()).count();
7164        #[allow(clippy::cast_possible_truncation)]
7165        Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
7166    }
7167
7168    /// Returns the rolling sum of bar deltas (`close - open`) over the last `n` bars.
7169    ///
7170    /// Positive = net buying pressure; negative = net selling pressure.
7171    /// Returns `None` if fewer than `n` bars are available.
7172    pub fn cumulative_delta(&self, n: usize) -> Option<Decimal> {
7173        if n == 0 || self.bars.len() < n { return None; }
7174        let sum: Decimal = self.bars[self.bars.len() - n..]
7175            .iter()
7176            .map(|b| b.close.value() - b.open.value())
7177            .sum();
7178        Some(sum)
7179    }
7180
7181    /// Returns the average body-to-ATR ratio over the last `n` bars.
7182    ///
7183    /// Measures whether recent candles are large or small relative to their
7184    /// volatility context. Requires `n + 1` bars (ATR uses prior close).
7185    /// Returns `None` if fewer than `n + 1` bars or ATR is zero.
7186    pub fn avg_body_to_atr(&self, n: usize) -> Option<Decimal> {
7187        if n == 0 || self.bars.len() < n + 1 { return None; }
7188        let slice = &self.bars[self.bars.len() - n - 1..];
7189        let trs: Vec<Decimal> = slice.windows(2).map(|w| {
7190            let h = w[1].high.value();
7191            let l = w[1].low.value();
7192            let pc = w[0].close.value();
7193            (h - l).max((h - pc).abs()).max((l - pc).abs())
7194        }).collect();
7195        let atr: Decimal = trs.iter().sum::<Decimal>();
7196        if atr.is_zero() { return None; }
7197        #[allow(clippy::cast_possible_truncation)]
7198        let n_d = Decimal::from(n as u32);
7199        let avg_atr = atr / n_d;
7200        let avg_body: Decimal = slice[1..].iter()
7201            .map(|b| b.body_size())
7202            .sum::<Decimal>() / n_d;
7203        avg_body.checked_div(avg_atr)
7204    }
7205
7206    /// Returns the number of direction-changes in `close - open` sign over the
7207    /// last `n` bars (excluding doji bars).
7208    ///
7209    /// High values indicate choppy price action; low values suggest trending.
7210    /// Returns `None` if fewer than `n` bars are available.
7211    pub fn candle_direction_changes(&self, n: usize) -> Option<usize> {
7212        if n < 2 || self.bars.len() < n { return None; }
7213        let slice = &self.bars[self.bars.len() - n..];
7214        let directions: Vec<i32> = slice.iter()
7215            .map(|b| {
7216                let d = b.close.value() - b.open.value();
7217                if d > Decimal::ZERO { 1 } else if d < Decimal::ZERO { -1 } else { 0 }
7218            })
7219            .filter(|d| *d != 0)
7220            .collect();
7221        let changes = directions.windows(2).filter(|w| w[0] != w[1]).count();
7222        Some(changes)
7223    }
7224
7225    /// Returns the fraction of the last `n` bars with a positive close-to-close return.
7226    ///
7227    /// Returns `None` if fewer than `n + 1` bars are available.
7228    pub fn win_rate(&self, n: usize) -> Option<Decimal> {
7229        if n == 0 || self.bars.len() < n + 1 { return None; }
7230        let slice = &self.bars[self.bars.len() - n - 1..];
7231        let wins = slice.windows(2)
7232            .filter(|w| w[1].close.value() > w[0].close.value())
7233            .count();
7234        #[allow(clippy::cast_possible_truncation)]
7235        Decimal::from(wins as u32).checked_div(Decimal::from(n as u32))
7236    }
7237
7238    /// Returns the best (maximum) single-bar close-to-close return over the last `n` bars.
7239    ///
7240    /// Returns `None` if fewer than `n + 1` bars are available or all prior closes are zero.
7241    pub fn best_return(&self, n: usize) -> Option<Decimal> {
7242        if n == 0 || self.bars.len() < n + 1 { return None; }
7243        let slice = &self.bars[self.bars.len() - n - 1..];
7244        slice.windows(2).filter_map(|w| {
7245            let pc = w[0].close.value();
7246            if pc.is_zero() { return None; }
7247            Some((w[1].close.value() - pc) / pc)
7248        }).reduce(|a, b| if a > b { a } else { b })
7249    }
7250
7251    /// Returns the worst (minimum) single-bar close-to-close return over the last `n` bars.
7252    ///
7253    /// Returns `None` if fewer than `n + 1` bars are available or all prior closes are zero.
7254    pub fn worst_return(&self, n: usize) -> Option<Decimal> {
7255        if n == 0 || self.bars.len() < n + 1 { return None; }
7256        let slice = &self.bars[self.bars.len() - n - 1..];
7257        slice.windows(2).filter_map(|w| {
7258            let pc = w[0].close.value();
7259            if pc.is_zero() { return None; }
7260            Some((w[1].close.value() - pc) / pc)
7261        }).reduce(|a, b| if a < b { a } else { b })
7262    }
7263
7264    /// Returns the median close-to-close return over the last `n` bars.
7265    ///
7266    /// Returns `None` if fewer than `n + 1` bars are available.
7267    pub fn median_return(&self, n: usize) -> Option<Decimal> {
7268        if n == 0 || self.bars.len() < n + 1 { return None; }
7269        let slice = &self.bars[self.bars.len() - n - 1..];
7270        let mut returns: Vec<Decimal> = slice.windows(2).filter_map(|w| {
7271            let pc = w[0].close.value();
7272            if pc.is_zero() { return None; }
7273            Some((w[1].close.value() - pc) / pc)
7274        }).collect();
7275        if returns.is_empty() { return None; }
7276        returns.sort();
7277        let m = returns.len();
7278        if m % 2 == 1 { Some(returns[m / 2]) }
7279        else { (returns[m / 2 - 1] + returns[m / 2]).checked_div(Decimal::TWO) }
7280    }
7281
7282    /// Returns `(current_close - N-bar_median_close) / N-bar_median_close × 100`.
7283    ///
7284    /// Measures where the current price sits relative to the robust central tendency
7285    /// of recent prices. Returns `None` if fewer than `n` bars are available or the
7286    /// median is zero.
7287    pub fn price_vs_median(&self, n: usize) -> Option<Decimal> {
7288        if n == 0 || self.bars.len() < n { return None; }
7289        let slice = &self.bars[self.bars.len() - n..];
7290        let mut closes: Vec<Decimal> = slice.iter().map(|b| b.close.value()).collect();
7291        closes.sort();
7292        let m = closes.len();
7293        let median = if m % 2 == 1 { closes[m / 2] }
7294            else { (closes[m / 2 - 1] + closes[m / 2]) / Decimal::TWO };
7295        if median.is_zero() { return None; }
7296        let current = self.bars.last()?.close.value();
7297        (current - median).checked_div(median).map(|r| r * Decimal::ONE_HUNDRED)
7298    }
7299
7300    /// Returns the fraction of last `n + 1` bar-pairs where close > prev_close (win rate).
7301    ///
7302    /// Alias for [`win_rate`] using close-to-close comparisons.
7303    /// Returns `None` if fewer than `n + 1` bars are available.
7304    pub fn close_win_rate(&self, n: usize) -> Option<Decimal> {
7305        if n == 0 || self.bars.len() < n + 1 { return None; }
7306        let slice = &self.bars[self.bars.len() - n - 1..];
7307        let wins = slice.windows(2).filter(|w| w[1].close.value() > w[0].close.value()).count();
7308        #[allow(clippy::cast_possible_truncation)]
7309        Decimal::from(wins as u32).checked_div(Decimal::from(n as u32))
7310    }
7311
7312    /// Returns the rolling N-bar VWAP (Volume-Weighted Average Price).
7313    ///
7314    /// Returns `None` if fewer than `n` bars are available or total volume is zero.
7315    pub fn rolling_vwap(&self, n: usize) -> Option<Decimal> {
7316        if n == 0 || self.bars.len() < n { return None; }
7317        let slice = &self.bars[self.bars.len() - n..];
7318        let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
7319        if total_vol.is_zero() { return None; }
7320        let vwap = slice.iter().map(|b| b.close.value() * b.volume.value()).sum::<Decimal>() / total_vol;
7321        Some(vwap)
7322    }
7323
7324    /// Returns the count of engulfing bars (bullish or bearish) in the last `n + 1` bars.
7325    ///
7326    /// A bar is engulfing if its body completely contains the prior bar's body.
7327    /// Returns `None` if fewer than `n + 1` bars are available.
7328    pub fn engulfing_count(&self, n: usize) -> Option<usize> {
7329        if n == 0 || self.bars.len() < n + 1 { return None; }
7330        let slice = &self.bars[self.bars.len() - n - 1..];
7331        let count = slice.windows(2).filter(|w| {
7332            let (po, pc) = (w[0].open.value(), w[0].close.value());
7333            let (co, cc) = (w[1].open.value(), w[1].close.value());
7334            let prev_lo = po.min(pc);
7335            let prev_hi = po.max(pc);
7336            let curr_lo = co.min(cc);
7337            let curr_hi = co.max(cc);
7338            curr_lo <= prev_lo && curr_hi >= prev_hi && prev_lo != prev_hi
7339        }).count();
7340        Some(count)
7341    }
7342
7343    /// Returns the rolling N-bar velocity: `close[last] - close[last - n]`.
7344    ///
7345    /// Returns `None` if fewer than `n + 1` bars are available.
7346    pub fn rolling_velocity(&self, n: usize) -> Option<Decimal> {
7347        let len = self.bars.len();
7348        if n == 0 || len < n + 1 { return None; }
7349        Some(self.bars[len - 1].close.value() - self.bars[len - 1 - n].close.value())
7350    }
7351
7352    /// Returns the average consolidation ratio over the last `n` bars.
7353    ///
7354    /// Each bar contributes `(close - open).abs() / (high - low)` (body-to-range ratio).
7355    /// Bars with zero range are excluded.
7356    /// Returns `None` if fewer than `n` bars are available or no bar has a non-zero range.
7357    pub fn avg_body_ratio(&self, n: usize) -> Option<Decimal> {
7358        if n == 0 || self.bars.len() < n { return None; }
7359        let slice = &self.bars[self.bars.len() - n..];
7360        let (sum, count) = slice.iter().fold((Decimal::ZERO, 0u32), |(s, c), b| {
7361            let range = b.range();
7362            if range.is_zero() { (s, c) }
7363            else { (s + b.body_size() / range, c + 1) }
7364        });
7365        if count == 0 { return None; }
7366        sum.checked_div(Decimal::from(count))
7367    }
7368
7369    /// Average upper shadow fraction — mean of  over  bars.
7370    /// Returns  if  or fewer than  bars are available.
7371    pub fn avg_upper_shadow_fraction(&self, n: usize) -> Option<Decimal> {
7372        if n == 0 || self.bars.len() < n { return None; }
7373        let bars = &self.bars[self.bars.len() - n..];
7374        let sum: Decimal = bars.iter().map(|b| {
7375            let range = b.range();
7376            if range.is_zero() { Decimal::ZERO }
7377            else {
7378                let body_hi = b.close.value().max(b.open.value());
7379                (b.high.value() - body_hi) / range
7380            }
7381        }).sum();
7382        Some(sum / Decimal::from(n as u32))
7383    }
7384
7385    /// Average lower shadow fraction — mean of  over  bars.
7386    /// Returns  if  or fewer than  bars are available.
7387    pub fn avg_lower_shadow_fraction(&self, n: usize) -> Option<Decimal> {
7388        if n == 0 || self.bars.len() < n { return None; }
7389        let bars = &self.bars[self.bars.len() - n..];
7390        let sum: Decimal = bars.iter().map(|b| {
7391            let range = b.range();
7392            if range.is_zero() { Decimal::ZERO }
7393            else {
7394                let body_lo = b.close.value().min(b.open.value());
7395                (body_lo - b.low.value()) / range
7396            }
7397        }).sum();
7398        Some(sum / Decimal::from(n as u32))
7399    }
7400
7401    /// Average intrabar return — mean of  over  bars.
7402    /// Bars with zero open are excluded. Returns  if none qualify.
7403    pub fn avg_intrabar_return(&self, n: usize) -> Option<Decimal> {
7404        if n == 0 || self.bars.len() < n { return None; }
7405        let bars = &self.bars[self.bars.len() - n..];
7406        let vals: Vec<Decimal> = bars.iter().filter_map(|b| {
7407            if b.open.value().is_zero() { None }
7408            else {
7409                Some((b.close.value() - b.open.value()) / b.open.value() * Decimal::ONE_HUNDRED)
7410            }
7411        }).collect();
7412        if vals.is_empty() { return None; }
7413        let sum: Decimal = vals.iter().sum();
7414        Some(sum / Decimal::from(vals.len() as u32))
7415    }
7416
7417    /// Average close position in range — mean of  over  bars.
7418    /// Bars with zero range use 0.5. Returns  if fewer than  bars available.
7419    pub fn avg_close_position(&self, n: usize) -> Option<Decimal> {
7420        if n == 0 || self.bars.len() < n { return None; }
7421        let bars = &self.bars[self.bars.len() - n..];
7422        let half = Decimal::new(5, 1);
7423        let sum: Decimal = bars.iter().map(|b| {
7424            let range = b.range();
7425            if range.is_zero() { half }
7426            else { (b.close.value() - b.low.value()) / range }
7427        }).sum();
7428        Some(sum / Decimal::from(n as u32))
7429    }
7430
7431    /// Shadow imbalance average — mean of  over  bars.
7432    /// Bars with zero range contribute 0. Returns  if fewer than  bars available.
7433    pub fn avg_shadow_imbalance(&self, n: usize) -> Option<Decimal> {
7434        if n == 0 || self.bars.len() < n { return None; }
7435        let bars = &self.bars[self.bars.len() - n..];
7436        let sum: Decimal = bars.iter().map(|b| {
7437            let range = b.range();
7438            if range.is_zero() { Decimal::ZERO }
7439            else {
7440                let body_hi = b.close.value().max(b.open.value());
7441                let body_lo = b.close.value().min(b.open.value());
7442                let upper = b.high.value() - body_hi;
7443                let lower = body_lo - b.low.value();
7444                (upper - lower) / range
7445            }
7446        }).sum();
7447        Some(sum / Decimal::from(n as u32))
7448    }
7449
7450    /// Average normalized range — mean of  over  bars.
7451    /// Bars with zero close are excluded. Returns  if none qualify.
7452    pub fn avg_normalized_range(&self, n: usize) -> Option<Decimal> {
7453        if n == 0 || self.bars.len() < n { return None; }
7454        let bars = &self.bars[self.bars.len() - n..];
7455        let vals: Vec<Decimal> = bars.iter().filter_map(|b| {
7456            if b.close.value().is_zero() { None }
7457            else { Some((b.range()) / b.close.value()) }
7458        }).collect();
7459        if vals.is_empty() { return None; }
7460        let sum: Decimal = vals.iter().sum();
7461        Some(sum / Decimal::from(vals.len() as u32))
7462    }
7463
7464}
7465
7466#[cfg(test)]
7467mod tests {
7468    use super::*;
7469    use crate::types::Side;
7470    use rust_decimal_macros::dec;
7471
7472    fn make_price(s: &str) -> Price {
7473        Price::new(s.parse().unwrap()).unwrap()
7474    }
7475
7476    fn make_qty(s: &str) -> Quantity {
7477        Quantity::new(s.parse().unwrap()).unwrap()
7478    }
7479
7480    fn make_bar(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
7481        OhlcvBar {
7482            symbol: Symbol::new("X").unwrap(),
7483            open: make_price(o),
7484            high: make_price(h),
7485            low: make_price(l),
7486            close: make_price(c),
7487            volume: make_qty("100"),
7488            ts_open: NanoTimestamp::new(0),
7489            ts_close: NanoTimestamp::new(1),
7490            tick_count: 1,
7491        }
7492    }
7493
7494    /// Convenience helper: create a bar where O=H=L=C = `close`.
7495    fn bar(close: &str) -> OhlcvBar {
7496        make_bar(close, close, close, close)
7497    }
7498
7499    fn make_tick(sym: &str, price: &str, qty: &str, ts: i64) -> Tick {
7500        Tick::new(
7501            Symbol::new(sym).unwrap(),
7502            make_price(price),
7503            make_qty(qty),
7504            Side::Ask,
7505            NanoTimestamp::new(ts),
7506        )
7507    }
7508
7509    // --- OhlcvBar ---
7510
7511    #[test]
7512    fn test_ohlcv_bar_validate_ok() {
7513        let bar = make_bar("100", "110", "90", "105");
7514        assert!(bar.validate().is_ok());
7515    }
7516
7517    #[test]
7518    fn test_ohlcv_bar_validate_high_less_than_close_fails() {
7519        let bar = make_bar("100", "104", "90", "110");
7520        assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
7521    }
7522
7523    #[test]
7524    fn test_ohlcv_bar_validate_low_greater_than_open_fails() {
7525        let bar = make_bar("80", "110", "90", "105");
7526        assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
7527    }
7528
7529    #[test]
7530    fn test_ohlcv_bar_validate_high_less_than_open_fails() {
7531        let bar = make_bar("115", "110", "90", "105");
7532        assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
7533    }
7534
7535    #[test]
7536    fn test_ohlcv_bar_typical_price() {
7537        let bar = make_bar("100", "120", "80", "110");
7538        let expected = dec!(310) / Decimal::from(3u32);
7539        assert_eq!(bar.typical_price(), expected);
7540    }
7541
7542    #[test]
7543    fn test_ohlcv_bar_range() {
7544        let bar = make_bar("100", "120", "80", "110");
7545        assert_eq!(bar.range(), dec!(40));
7546    }
7547
7548    #[test]
7549    fn test_ohlcv_bar_is_bullish_true() {
7550        let bar = make_bar("100", "110", "95", "105");
7551        assert!(bar.is_bullish());
7552    }
7553
7554    #[test]
7555    fn test_ohlcv_bar_is_bullish_false() {
7556        let bar = make_bar("105", "110", "95", "100");
7557        assert!(!bar.is_bullish());
7558    }
7559
7560    #[test]
7561    fn test_ohlcv_bar_midpoint() {
7562        let bar = make_bar("100", "120", "80", "110");
7563        assert_eq!(bar.midpoint(), dec!(100)); // (120 + 80) / 2
7564    }
7565
7566    #[test]
7567    fn test_ohlcv_bar_body_size_bullish() {
7568        let bar = make_bar("100", "120", "80", "110");
7569        assert_eq!(bar.body_size(), dec!(10)); // |110 - 100|
7570    }
7571
7572    #[test]
7573    fn test_ohlcv_bar_body_size_bearish() {
7574        let bar = make_bar("110", "120", "80", "100");
7575        assert_eq!(bar.body_size(), dec!(10)); // |100 - 110|
7576    }
7577
7578    #[test]
7579    fn test_ohlcv_bar_is_long_candle_flat() {
7580        // range == 0 → always false
7581        let bar = make_bar("100", "100", "100", "100");
7582        assert!(!bar.is_long_candle(dec!(0.7)));
7583    }
7584
7585    #[test]
7586    fn test_ohlcv_bar_is_long_candle_true() {
7587        // open=100, close=110, high=112, low=98 → body=10, range=14 → 10/14 ≈ 0.714 >= 0.7
7588        let bar = make_bar("100", "112", "98", "110");
7589        assert!(bar.is_long_candle(dec!(0.7)));
7590    }
7591
7592    #[test]
7593    fn test_ohlcv_bar_is_long_candle_false() {
7594        // open=100, close=101, high=110, low=90 → body=1, range=20 → 0.05 < 0.7
7595        let bar = make_bar("100", "110", "90", "101");
7596        assert!(!bar.is_long_candle(dec!(0.7)));
7597    }
7598
7599    #[test]
7600    fn test_ohlcv_bar_is_doji_flat_range() {
7601        let bar = make_bar("100", "100", "100", "100");
7602        assert!(bar.is_doji(dec!(0.1)));
7603        assert!(!bar.is_doji(dec!(0)));
7604    }
7605
7606    #[test]
7607    fn test_ohlcv_bar_is_doji_small_body() {
7608        // range = 20, body = 1 → body/range = 0.05 < 0.1 threshold
7609        let bar = make_bar("100", "110", "90", "101");
7610        assert!(bar.is_doji(dec!(0.1)));
7611        assert!(!bar.is_doji(dec!(0.04)));
7612    }
7613
7614    #[test]
7615    fn test_ohlcv_bar_partial_eq() {
7616        let a = make_bar("100", "110", "90", "105");
7617        let b = make_bar("100", "110", "90", "105");
7618        assert_eq!(a, b);
7619        let c = make_bar("100", "110", "90", "106");
7620        assert_ne!(a, c);
7621    }
7622
7623    // --- Timeframe ---
7624
7625    #[test]
7626    fn test_timeframe_seconds_to_nanos() {
7627        let tf = Timeframe::Seconds(5);
7628        assert_eq!(tf.to_nanos().unwrap(), 5_000_000_000);
7629    }
7630
7631    #[test]
7632    fn test_timeframe_minutes_to_nanos() {
7633        let tf = Timeframe::Minutes(1);
7634        assert_eq!(tf.to_nanos().unwrap(), 60_000_000_000);
7635    }
7636
7637    #[test]
7638    fn test_timeframe_zero_seconds_fails() {
7639        let tf = Timeframe::Seconds(0);
7640        assert!(matches!(tf.to_nanos(), Err(FinError::InvalidTimeframe)));
7641    }
7642
7643    #[test]
7644    fn test_timeframe_weeks_to_nanos() {
7645        let tf = Timeframe::Weeks(1);
7646        assert_eq!(tf.to_nanos().unwrap(), 7 * 86_400 * 1_000_000_000_i64);
7647    }
7648
7649    #[test]
7650    fn test_timeframe_bucket_start() {
7651        let tf = Timeframe::Seconds(60);
7652        let nanos_per_min = 60_000_000_000_i64;
7653        let ts = NanoTimestamp::new(nanos_per_min + 500_000_000);
7654        let bucket = tf.bucket_start(ts).unwrap();
7655        assert_eq!(bucket.nanos(), nanos_per_min);
7656    }
7657
7658    // --- OhlcvAggregator ---
7659
7660    #[test]
7661    fn test_ohlcv_aggregator_new_invalid_timeframe_fails() {
7662        let sym = Symbol::new("X").unwrap();
7663        let result = OhlcvAggregator::new(sym, Timeframe::Seconds(0));
7664        assert!(matches!(result, Err(FinError::InvalidTimeframe)));
7665    }
7666
7667    #[test]
7668    fn test_ohlcv_aggregator_completes_bar_on_boundary() {
7669        let sym = Symbol::new("X").unwrap();
7670        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7671        let nanos_per_min = 60_000_000_000_i64;
7672
7673        let t1 = make_tick("X", "100", "1", 0);
7674        let t2 = make_tick("X", "105", "2", nanos_per_min / 2);
7675        let t3 = make_tick("X", "110", "1", nanos_per_min + 1);
7676
7677        let r1 = agg.push_tick(&t1).unwrap();
7678        assert!(r1.is_empty());
7679        let r2 = agg.push_tick(&t2).unwrap();
7680        assert!(r2.is_empty());
7681        let r3 = agg.push_tick(&t3).unwrap();
7682        assert_eq!(r3.len(), 1);
7683        let bar = &r3[0];
7684        assert_eq!(bar.open.value(), dec!(100));
7685        assert_eq!(bar.high.value(), dec!(105));
7686        assert_eq!(bar.close.value(), dec!(105));
7687        assert_eq!(bar.tick_count, 2);
7688    }
7689
7690    #[test]
7691    fn test_ohlcv_aggregator_gap_fills_empty_buckets() {
7692        let sym = Symbol::new("X").unwrap();
7693        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7694        let nanos_per_min = 60_000_000_000_i64;
7695
7696        // First bar in minute 0.
7697        agg.push_tick(&make_tick("X", "100", "1", 0)).unwrap();
7698        // Tick jumps 3 minutes ahead: should emit bar for min 0 + gap bars for min 1, min 2.
7699        let out = agg
7700            .push_tick(&make_tick("X", "200", "1", 3 * nanos_per_min + 1))
7701            .unwrap();
7702        // 1 completed bar + 2 gap bars
7703        assert_eq!(out.len(), 3, "expected 1 completed + 2 gap bars, got {}", out.len());
7704        // Completed bar has real data.
7705        assert_eq!(out[0].tick_count, 1);
7706        // Gap bars have zero volume and tick_count.
7707        assert_eq!(out[1].tick_count, 0);
7708        assert_eq!(out[1].volume.value(), dec!(0));
7709        assert_eq!(out[2].tick_count, 0);
7710        // Gap bars use the last close.
7711        assert_eq!(out[1].close, out[0].close);
7712    }
7713
7714    #[test]
7715    fn test_ohlcv_aggregator_flush_returns_partial() {
7716        let sym = Symbol::new("X").unwrap();
7717        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7718        let t1 = make_tick("X", "100", "1", 0);
7719        agg.push_tick(&t1).unwrap();
7720        let bar = agg.flush().unwrap();
7721        assert_eq!(bar.open.value(), dec!(100));
7722        assert!(agg.flush().is_none());
7723    }
7724
7725    #[test]
7726    fn test_ohlcv_aggregator_symbol_getter() {
7727        let sym = Symbol::new("BTC").unwrap();
7728        let agg = OhlcvAggregator::new(sym.clone(), Timeframe::Seconds(60)).unwrap();
7729        assert_eq!(agg.symbol(), &sym);
7730    }
7731
7732    #[test]
7733    fn test_ohlcv_aggregator_ignores_different_symbol() {
7734        let sym = Symbol::new("X").unwrap();
7735        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
7736        let t = make_tick("Y", "100", "1", 0);
7737        let result = agg.push_tick(&t).unwrap();
7738        assert!(result.is_empty());
7739        assert!(agg.current_bar().is_none());
7740    }
7741
7742    // --- OhlcvSeries ---
7743
7744    #[test]
7745    fn test_ohlcv_series_push_valid() {
7746        let mut series = OhlcvSeries::new();
7747        let bar = make_bar("100", "110", "90", "105");
7748        assert!(series.push(bar).is_ok());
7749        assert_eq!(series.len(), 1);
7750    }
7751
7752    #[test]
7753    fn test_ohlcv_series_push_invalid_fails() {
7754        let mut series = OhlcvSeries::new();
7755        let bar = make_bar("100", "95", "90", "105");
7756        assert!(matches!(series.push(bar), Err(FinError::BarInvariant(_))));
7757    }
7758
7759    #[test]
7760    fn test_ohlcv_series_window_returns_last_n() {
7761        let mut series = OhlcvSeries::new();
7762        for i in 1u32..=5 {
7763            let p = format!("{}", 100 + i);
7764            let h = format!("{}", 110 + i);
7765            let l = format!("{}", 90 + i);
7766            let c = format!("{}", 105 + i);
7767            series.push(make_bar(&p, &h, &l, &c)).unwrap();
7768        }
7769        let w = series.window(3);
7770        assert_eq!(w.len(), 3);
7771        assert_eq!(w[0].open.value(), dec!(103));
7772    }
7773
7774    #[test]
7775    fn test_ohlcv_series_window_larger_than_len() {
7776        let mut series = OhlcvSeries::new();
7777        series.push(make_bar("100", "110", "90", "105")).unwrap();
7778        let w = series.window(10);
7779        assert_eq!(w.len(), 1);
7780    }
7781
7782    #[test]
7783    fn test_ohlcv_series_opens() {
7784        let mut series = OhlcvSeries::new();
7785        series.push(make_bar("100", "110", "90", "105")).unwrap();
7786        series.push(make_bar("105", "115", "95", "110")).unwrap();
7787        assert_eq!(series.opens(), vec![dec!(100), dec!(105)]);
7788    }
7789
7790    #[test]
7791    fn test_ohlcv_series_highs() {
7792        let mut series = OhlcvSeries::new();
7793        series.push(make_bar("100", "110", "90", "105")).unwrap();
7794        series.push(make_bar("105", "115", "95", "110")).unwrap();
7795        assert_eq!(series.highs(), vec![dec!(110), dec!(115)]);
7796    }
7797
7798    #[test]
7799    fn test_ohlcv_series_lows() {
7800        let mut series = OhlcvSeries::new();
7801        series.push(make_bar("100", "110", "90", "105")).unwrap();
7802        series.push(make_bar("105", "115", "95", "110")).unwrap();
7803        assert_eq!(series.lows(), vec![dec!(90), dec!(95)]);
7804    }
7805
7806    #[test]
7807    fn test_ohlcv_series_closes() {
7808        let mut series = OhlcvSeries::new();
7809        series.push(make_bar("100", "110", "90", "105")).unwrap();
7810        series.push(make_bar("105", "115", "95", "110")).unwrap();
7811        let closes = series.closes();
7812        assert_eq!(closes, vec![dec!(105), dec!(110)]);
7813    }
7814
7815    #[test]
7816    fn test_ohlcv_series_is_empty() {
7817        let series = OhlcvSeries::new();
7818        assert!(series.is_empty());
7819    }
7820
7821    #[test]
7822    fn test_ohlcv_series_into_iterator() {
7823        let mut series = OhlcvSeries::new();
7824        series.push(make_bar("100", "110", "90", "105")).unwrap();
7825        series.push(make_bar("105", "115", "95", "110")).unwrap();
7826        let count = (&series).into_iter().count();
7827        assert_eq!(count, 2);
7828    }
7829
7830    #[test]
7831    fn test_ohlcv_series_iter() {
7832        let mut series = OhlcvSeries::new();
7833        series.push(make_bar("100", "110", "90", "105")).unwrap();
7834        let bar = series.iter().next().unwrap();
7835        assert_eq!(bar.open.value(), dec!(100));
7836    }
7837
7838    #[test]
7839    fn test_ohlcv_bar_upper_shadow() {
7840        // bullish: open=100, close=108, high=115 → upper = 115-108 = 7
7841        let b = make_bar("100", "115", "90", "108");
7842        assert_eq!(b.upper_shadow(), dec!(7));
7843    }
7844
7845    #[test]
7846    fn test_ohlcv_bar_lower_shadow() {
7847        // bullish: open=100, close=108, low=90 → lower = 100-90 = 10
7848        let b = make_bar("100", "115", "90", "108");
7849        assert_eq!(b.lower_shadow(), dec!(10));
7850    }
7851
7852    #[test]
7853    fn test_ohlcv_bar_from_tick() {
7854        let tick = make_tick("AAPL", "150", "5", 1_000);
7855        let bar = OhlcvBar::from_tick(&tick);
7856        assert_eq!(bar.open.value(), dec!(150));
7857        assert_eq!(bar.high.value(), dec!(150));
7858        assert_eq!(bar.low.value(), dec!(150));
7859        assert_eq!(bar.close.value(), dec!(150));
7860        assert_eq!(bar.volume.value(), dec!(5));
7861        assert_eq!(bar.tick_count, 1);
7862        assert_eq!(bar.ts_open.nanos(), 1_000);
7863    }
7864
7865    #[test]
7866    fn test_ohlcv_series_bars_slice() {
7867        let mut series = OhlcvSeries::new();
7868        series.push(make_bar("100", "110", "90", "105")).unwrap();
7869        series.push(make_bar("105", "115", "95", "110")).unwrap();
7870        assert_eq!(series.bars().len(), 2);
7871    }
7872
7873    #[test]
7874    fn test_ohlcv_series_max_high_min_low() {
7875        let mut series = OhlcvSeries::new();
7876        series.push(make_bar("100", "110", "90", "105")).unwrap();
7877        series.push(make_bar("105", "120", "85", "110")).unwrap();
7878        assert_eq!(series.max_high().unwrap(), dec!(120));
7879        assert_eq!(series.min_low().unwrap(), dec!(85));
7880    }
7881
7882    #[test]
7883    fn test_ohlcv_series_max_high_empty() {
7884        let series = OhlcvSeries::new();
7885        assert!(series.max_high().is_none());
7886        assert!(series.min_low().is_none());
7887    }
7888
7889    #[test]
7890    fn test_ohlcv_series_slice() {
7891        let mut series = OhlcvSeries::new();
7892        series.push(make_bar("100", "110", "90", "105")).unwrap();
7893        series.push(make_bar("105", "115", "95", "110")).unwrap();
7894        series.push(make_bar("110", "120", "100", "115")).unwrap();
7895        let s = series.slice(1, 3).unwrap();
7896        assert_eq!(s.len(), 2);
7897        assert_eq!(s[0].open.value(), dec!(105));
7898    }
7899
7900    #[test]
7901    fn test_ohlcv_series_slice_out_of_bounds() {
7902        let series = OhlcvSeries::new();
7903        assert!(series.slice(0, 1).is_none());
7904    }
7905
7906    #[test]
7907    fn test_ohlcv_series_truncate_keeps_last_n() {
7908        let mut series = OhlcvSeries::new();
7909        for _ in 0..5 {
7910            series.push(make_bar("100", "110", "90", "105")).unwrap();
7911        }
7912        series.truncate(3);
7913        assert_eq!(series.len(), 3);
7914    }
7915
7916    #[test]
7917    fn test_ohlcv_series_truncate_noop_when_n_ge_len() {
7918        let mut series = OhlcvSeries::new();
7919        series.push(make_bar("100", "110", "90", "105")).unwrap();
7920        series.push(make_bar("105", "115", "95", "110")).unwrap();
7921        series.truncate(5);
7922        assert_eq!(series.len(), 2);
7923    }
7924
7925    #[test]
7926    fn test_ohlcv_series_truncate_to_zero() {
7927        let mut series = OhlcvSeries::new();
7928        series.push(make_bar("100", "110", "90", "105")).unwrap();
7929        series.push(make_bar("105", "115", "95", "110")).unwrap();
7930        series.truncate(0);
7931        assert!(series.is_empty());
7932    }
7933
7934    #[test]
7935    fn test_ohlcv_bar_serde_roundtrip() {
7936        let bar = make_bar("100", "110", "90", "105");
7937        let json = serde_json::to_string(&bar).unwrap();
7938        let back: OhlcvBar = serde_json::from_str(&json).unwrap();
7939        assert_eq!(back.open, bar.open);
7940        assert_eq!(back.high, bar.high);
7941        assert_eq!(back.low, bar.low);
7942        assert_eq!(back.close, bar.close);
7943        assert_eq!(back.tick_count, bar.tick_count);
7944    }
7945
7946    #[test]
7947    fn test_ohlcv_bar_duration_nanos() {
7948        let mut bar = make_bar("100", "110", "90", "105");
7949        bar.ts_open = NanoTimestamp::new(1_000_000_000);
7950        bar.ts_close = NanoTimestamp::new(1_060_000_000_000);
7951        assert_eq!(bar.duration_nanos(), 1_059_000_000_000);
7952    }
7953
7954    #[test]
7955    fn test_ohlcv_bar_duration_nanos_same_timestamps() {
7956        let mut bar = make_bar("100", "110", "90", "100");
7957        bar.ts_open = NanoTimestamp::new(5_000);
7958        bar.ts_close = NanoTimestamp::new(5_000);
7959        assert_eq!(bar.duration_nanos(), 0);
7960    }
7961
7962    #[test]
7963    fn test_ohlcv_series_extend_valid() {
7964        let mut series = OhlcvSeries::new();
7965        let bars = vec![
7966            make_bar("100", "110", "90", "105"),
7967            make_bar("105", "115", "95", "110"),
7968        ];
7969        series.extend(bars).unwrap();
7970        assert_eq!(series.len(), 2);
7971    }
7972
7973    #[test]
7974    fn test_ohlcv_series_extend_stops_on_invalid_bar() {
7975        let mut series = OhlcvSeries::new();
7976        let valid = make_bar("100", "110", "90", "105");
7977        let mut invalid = make_bar("100", "110", "90", "105");
7978        // Make bar invalid: high < low
7979        invalid.high = make_price("80");
7980        invalid.low = make_price("110");
7981        let result = series.extend([valid, invalid]);
7982        assert!(result.is_err());
7983        assert_eq!(series.len(), 1, "valid bar added before error");
7984    }
7985
7986    #[test]
7987    fn test_ohlcv_bar_to_bar_input_fields_match() {
7988        let bar = make_bar("100", "110", "90", "105");
7989        let input = bar.to_bar_input();
7990        assert_eq!(input.open, bar.open.value());
7991        assert_eq!(input.high, bar.high.value());
7992        assert_eq!(input.low, bar.low.value());
7993        assert_eq!(input.close, bar.close.value());
7994        assert_eq!(input.volume, bar.volume.value());
7995    }
7996
7997    #[test]
7998    fn test_ohlcv_series_retain_removes_gap_fills() {
7999        let mut series = OhlcvSeries::new();
8000        series.push(make_bar("100", "110", "90", "105")).unwrap();
8001        // add a gap-fill bar (tick_count == 0)
8002        let mut gap = make_bar("105", "105", "105", "105");
8003        gap.tick_count = 0;
8004        series.push(gap).unwrap();
8005        series.push(make_bar("105", "115", "95", "110")).unwrap();
8006        series.retain(|b| !b.is_gap_fill());
8007        assert_eq!(series.len(), 2);
8008    }
8009
8010    #[test]
8011    fn test_ohlcv_series_retain_keeps_all() {
8012        let mut series = OhlcvSeries::new();
8013        series.push(make_bar("100", "110", "90", "105")).unwrap();
8014        series.push(make_bar("105", "115", "95", "110")).unwrap();
8015        series.retain(|_| true);
8016        assert_eq!(series.len(), 2);
8017    }
8018
8019    #[test]
8020    fn test_ohlcv_bar_is_bearish() {
8021        let bar = make_bar("110", "115", "95", "100");
8022        assert!(bar.is_bearish());
8023        assert!(!bar.is_bullish());
8024    }
8025
8026    #[test]
8027    fn test_ohlcv_bar_is_hammer() {
8028        // body = 5 (100→105), lower shadow = 20 (80→100), upper shadow = 6 (105→111) → NOT hammer (upper > body)
8029        let not_hammer = make_bar("100", "111", "80", "105");
8030        assert!(!not_hammer.is_hammer());
8031        // body = 5, lower shadow = 20 (75→95), upper shadow = 0 → IS hammer
8032        let hammer = make_bar("95", "100", "75", "100");
8033        assert!(hammer.is_hammer());
8034    }
8035
8036    #[test]
8037    fn test_ohlcv_bar_is_shooting_star() {
8038        // body = 5, upper shadow = 20, lower shadow = 0 → IS shooting star
8039        let star = make_bar("100", "125", "100", "105");
8040        assert!(star.is_shooting_star());
8041        // body = 5, upper shadow = 5, lower shadow = 20 → NOT shooting star
8042        let not_star = make_bar("100", "110", "80", "105");
8043        assert!(!not_star.is_shooting_star());
8044    }
8045
8046    #[test]
8047    fn test_ohlcv_bar_bar_return_positive() {
8048        let bar = make_bar("100", "110", "90", "110");
8049        assert_eq!(bar.bar_return().unwrap(), dec!(10));
8050    }
8051
8052    #[test]
8053    fn test_ohlcv_bar_bar_return_negative() {
8054        let bar = make_bar("100", "105", "85", "90");
8055        assert_eq!(bar.bar_return().unwrap(), dec!(-10));
8056    }
8057
8058    #[test]
8059    fn test_ohlcv_series_highest_high() {
8060        let mut series = OhlcvSeries::new();
8061        series.push(make_bar("100", "150", "90", "105")).unwrap();
8062        series.push(make_bar("105", "130", "95", "110")).unwrap();
8063        series.push(make_bar("110", "120", "100", "115")).unwrap();
8064        assert_eq!(series.highest_high(2).unwrap(), dec!(130));
8065        assert_eq!(series.highest_high(10).unwrap(), dec!(150));
8066    }
8067
8068    #[test]
8069    fn test_ohlcv_series_lowest_low() {
8070        let mut series = OhlcvSeries::new();
8071        series.push(make_bar("100", "110", "70", "105")).unwrap();
8072        series.push(make_bar("105", "115", "85", "110")).unwrap();
8073        series.push(make_bar("110", "120", "90", "115")).unwrap();
8074        assert_eq!(series.lowest_low(2).unwrap(), dec!(85));
8075        assert_eq!(series.lowest_low(10).unwrap(), dec!(70));
8076    }
8077
8078    #[test]
8079    fn test_ohlcv_series_extend_from_series() {
8080        let mut a = OhlcvSeries::new();
8081        a.push(make_bar("100", "110", "90", "105")).unwrap();
8082        let mut b = OhlcvSeries::new();
8083        b.push(make_bar("105", "115", "95", "110")).unwrap();
8084        b.push(make_bar("110", "120", "100", "115")).unwrap();
8085        a.extend_from_series(&b).unwrap();
8086        assert_eq!(a.len(), 3);
8087    }
8088
8089    #[test]
8090    fn test_ohlcv_aggregator_bar_count() {
8091        let sym = Symbol::new("AAPL").unwrap();
8092        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(1)).unwrap();
8093        assert_eq!(agg.bar_count(), 0);
8094        agg.push_tick(&make_tick("AAPL", "100", "1", 0)).unwrap();
8095        // t=2s lands in bucket [2s,3s): completes [0s,1s) + gap fills [1s,2s) = 2 bars emitted
8096        agg.push_tick(&make_tick("AAPL", "101", "1", 2_000_000_000))
8097            .unwrap();
8098        assert_eq!(agg.bar_count(), 2);
8099        agg.flush();
8100        assert_eq!(agg.bar_count(), 3);
8101        agg.reset();
8102        assert_eq!(agg.bar_count(), 0);
8103    }
8104
8105    #[test]
8106    fn test_ohlcv_series_vwap_empty_returns_none() {
8107        assert!(OhlcvSeries::new().vwap().is_none());
8108    }
8109
8110    #[test]
8111    fn test_ohlcv_series_vwap_zero_volume_returns_none() {
8112        let mut series = OhlcvSeries::new();
8113        let mut bar = make_bar("100", "110", "90", "100");
8114        bar.volume = Quantity::zero();
8115        series.push(bar).unwrap();
8116        assert!(series.vwap().is_none());
8117    }
8118
8119    #[test]
8120    fn test_ohlcv_series_vwap_constant_price() {
8121        let mut series = OhlcvSeries::new();
8122        series.push(make_bar("100", "100", "100", "100")).unwrap();
8123        series.push(make_bar("100", "100", "100", "100")).unwrap();
8124        assert_eq!(series.vwap().unwrap(), dec!(100));
8125    }
8126
8127    #[test]
8128    fn test_ohlcv_series_sum_volume_empty() {
8129        assert_eq!(OhlcvSeries::new().sum_volume(), dec!(0));
8130    }
8131
8132    #[test]
8133    fn test_ohlcv_series_sum_volume_multiple_bars() {
8134        let mut series = OhlcvSeries::new();
8135        series.push(make_bar("100", "110", "90", "105")).unwrap();
8136        series.push(make_bar("105", "115", "95", "110")).unwrap();
8137        series.push(make_bar("110", "120", "100", "115")).unwrap();
8138        // make_bar sets volume = 100 per bar
8139        assert_eq!(series.sum_volume(), dec!(300));
8140    }
8141
8142    #[test]
8143    fn test_ohlcv_series_avg_volume_none_when_empty() {
8144        assert!(OhlcvSeries::new().avg_volume(3).is_none());
8145    }
8146
8147    #[test]
8148    fn test_ohlcv_series_avg_volume_none_when_n_zero() {
8149        let mut series = OhlcvSeries::new();
8150        series.push(make_bar("100", "110", "90", "105")).unwrap();
8151        assert!(series.avg_volume(0).is_none());
8152    }
8153
8154    #[test]
8155    fn test_ohlcv_series_avg_volume_correct() {
8156        // make_bar sets volume = 100 per bar
8157        let mut series = OhlcvSeries::new();
8158        series.push(make_bar("100", "110", "90", "105")).unwrap();
8159        series.push(make_bar("105", "115", "95", "110")).unwrap();
8160        series.push(make_bar("110", "120", "100", "115")).unwrap();
8161        // avg over 3 bars: (100+100+100)/3 = 100
8162        assert_eq!(series.avg_volume(3).unwrap(), dec!(100));
8163    }
8164
8165    #[test]
8166    fn test_ohlcv_series_avg_volume_partial_window() {
8167        // n=5 but only 3 bars → None
8168        let mut series = OhlcvSeries::new();
8169        series.push(make_bar("100", "110", "90", "105")).unwrap();
8170        series.push(make_bar("105", "115", "95", "110")).unwrap();
8171        assert!(series.avg_volume(5).is_none());
8172    }
8173
8174    #[test]
8175    fn test_ohlcv_series_price_range_none_when_insufficient() {
8176        let mut series = OhlcvSeries::new();
8177        series.push(make_bar("100", "110", "90", "105")).unwrap();
8178        assert!(series.price_range(0).is_none());
8179        assert!(series.price_range(2).is_none());
8180    }
8181
8182    #[test]
8183    fn test_ohlcv_series_price_range_correct() {
8184        // bar1: high=110 low=90; bar2: high=120 low=80 → range = 120-80 = 40
8185        let mut series = OhlcvSeries::new();
8186        series.push(make_bar("100", "110", "90", "100")).unwrap();
8187        series.push(make_bar("100", "120", "80", "100")).unwrap();
8188        assert_eq!(series.price_range(2).unwrap(), dec!(40));
8189    }
8190
8191    #[test]
8192    fn test_ohlcv_series_above_ema_false_when_insufficient() {
8193        assert!(!OhlcvSeries::new().above_ema(3));
8194    }
8195
8196    #[test]
8197    fn test_ohlcv_series_above_ema_rising_close() {
8198        let mut series = OhlcvSeries::new();
8199        for c in ["100", "100", "100", "100", "200"] {
8200            series.push(make_bar(c, "210", "90", c)).unwrap();
8201        }
8202        assert!(series.above_ema(3));
8203    }
8204
8205    #[test]
8206    fn test_ohlcv_series_bullish_engulfing_count_zero_when_short() {
8207        assert_eq!(OhlcvSeries::new().bullish_engulfing_count(5), 0);
8208    }
8209
8210    #[test]
8211    fn test_ohlcv_series_bullish_engulfing_count_detects_pattern() {
8212        let mut series = OhlcvSeries::new();
8213        // bar1: bearish (open=105, close=95)
8214        series.push(make_bar("105", "110", "90", "95")).unwrap();
8215        // bar2: bullish engulfing: open < prev_close(95), close > prev_open(105)
8216        series.push(make_bar("90", "120", "88", "110")).unwrap();
8217        assert_eq!(series.bullish_engulfing_count(2), 1);
8218    }
8219
8220    #[test]
8221    fn test_ohlcv_series_range_expansion_none_when_insufficient() {
8222        assert!(OhlcvSeries::new().range_expansion(3).is_none());
8223    }
8224
8225    #[test]
8226    fn test_ohlcv_series_range_expansion_constant_returns_one() {
8227        let mut series = OhlcvSeries::new();
8228        for _ in 0..5 {
8229            series.push(make_bar("100", "110", "90", "100")).unwrap();
8230        }
8231        // all bars identical range=20 → current/avg = 1
8232        assert_eq!(series.range_expansion(5).unwrap(), dec!(1));
8233    }
8234
8235    #[test]
8236    fn test_ohlcv_series_bearish_engulfing_count_zero_when_short() {
8237        assert_eq!(OhlcvSeries::new().bearish_engulfing_count(5), 0);
8238    }
8239
8240    #[test]
8241    fn test_ohlcv_series_bearish_engulfing_count_detects_pattern() {
8242        let mut series = OhlcvSeries::new();
8243        // bar1: bullish (open=95, close=105)
8244        series.push(make_bar("95", "110", "90", "105")).unwrap();
8245        // bar2: bearish engulfing: open > prev_close(105), close < prev_open(95)
8246        series.push(make_bar("110", "115", "88", "90")).unwrap();
8247        assert_eq!(series.bearish_engulfing_count(2), 1);
8248    }
8249
8250    #[test]
8251    fn test_ohlcv_series_trend_strength_none_when_insufficient() {
8252        let mut series = OhlcvSeries::new();
8253        series.push(make_bar("100", "110", "90", "100")).unwrap();
8254        assert!(series.trend_strength(2).is_none());
8255    }
8256
8257    #[test]
8258    fn test_ohlcv_series_trend_strength_pure_trend_is_one() {
8259        // straight up trend: each close 10 higher — net = total movement → ratio = 1
8260        let mut series = OhlcvSeries::new();
8261        for c in ["100", "110", "120", "130"] {
8262            series.push(make_bar(c, "135", "95", c)).unwrap();
8263        }
8264        assert_eq!(series.trend_strength(4).unwrap(), dec!(1));
8265    }
8266
8267    #[test]
8268    fn test_ohlcv_series_close_location_value_none_when_insufficient() {
8269        assert!(OhlcvSeries::new().close_location_value(1).is_none());
8270    }
8271
8272    #[test]
8273    fn test_ohlcv_series_close_location_value_close_at_high() {
8274        // close == high → CLV = ((h-l)-(0))/(h-l) = 1
8275        let mut series = OhlcvSeries::new();
8276        series.push(make_bar("100", "110", "90", "110")).unwrap();
8277        assert_eq!(series.close_location_value(1).unwrap(), dec!(1));
8278    }
8279
8280    #[test]
8281    fn test_ohlcv_series_close_location_value_close_at_midpoint() {
8282        // close = 100 = midpoint of [90,110] → CLV = 0
8283        let mut series = OhlcvSeries::new();
8284        series.push(make_bar("100", "110", "90", "100")).unwrap();
8285        assert_eq!(series.close_location_value(1).unwrap(), dec!(0));
8286    }
8287
8288    #[test]
8289    fn test_ohlcv_series_mean_close_empty_returns_none() {
8290        assert!(OhlcvSeries::new().mean_close(5).is_none());
8291    }
8292
8293    #[test]
8294    fn test_ohlcv_series_mean_close_equal_prices() {
8295        let mut series = OhlcvSeries::new();
8296        series.push(make_bar("100", "110", "90", "100")).unwrap();
8297        series.push(make_bar("100", "110", "90", "100")).unwrap();
8298        series.push(make_bar("100", "110", "90", "100")).unwrap();
8299        assert_eq!(series.mean_close(3).unwrap(), dec!(100));
8300    }
8301
8302    #[test]
8303    fn test_ohlcv_series_mean_close_windowed() {
8304        // 3 bars with closes 100, 110, 120 → mean of last 2 = (110+120)/2 = 115
8305        let mut series = OhlcvSeries::new();
8306        series.push(make_bar("100", "100", "100", "100")).unwrap();
8307        series.push(make_bar("110", "110", "110", "110")).unwrap();
8308        series.push(make_bar("120", "120", "120", "120")).unwrap();
8309        assert_eq!(series.mean_close(2).unwrap(), dec!(115));
8310    }
8311
8312    #[test]
8313    fn test_ohlcv_series_std_dev_less_than_two_bars_returns_none() {
8314        let mut series = OhlcvSeries::new();
8315        series.push(make_bar("100", "110", "90", "100")).unwrap();
8316        assert!(series.std_dev(5).is_none());
8317    }
8318
8319    #[test]
8320    fn test_ohlcv_series_std_dev_constant_prices_is_zero() {
8321        let mut series = OhlcvSeries::new();
8322        for _ in 0..4 {
8323            series.push(make_bar("100", "100", "100", "100")).unwrap();
8324        }
8325        assert_eq!(series.std_dev(4).unwrap(), dec!(0));
8326    }
8327
8328    #[test]
8329    fn test_ohlcv_bar_gap_pct_upward_gap() {
8330        let prev = make_bar("100", "110", "90", "100");
8331        let curr = make_bar("110", "120", "105", "115");
8332        // gap_pct = (110 - 100) / 100 * 100 = 10
8333        assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(10));
8334    }
8335
8336    #[test]
8337    fn test_ohlcv_bar_gap_pct_downward_gap() {
8338        let prev = make_bar("100", "110", "90", "100");
8339        let curr = make_bar("90", "95", "85", "92");
8340        // gap_pct = (90 - 100) / 100 * 100 = -10
8341        assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(-10));
8342    }
8343
8344    #[test]
8345    fn test_ohlcv_bar_gap_pct_no_gap() {
8346        let prev = make_bar("100", "110", "90", "100");
8347        let curr = make_bar("100", "110", "90", "105");
8348        assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(0));
8349    }
8350
8351    #[test]
8352    fn test_ohlcv_series_n_bars_ago_returns_correct_bar() {
8353        let mut series = OhlcvSeries::new();
8354        series.push(make_bar("100", "110", "90", "105")).unwrap();
8355        series.push(make_bar("105", "115", "95", "110")).unwrap();
8356        series.push(make_bar("110", "120", "100", "115")).unwrap();
8357        assert_eq!(series.n_bars_ago(0).unwrap().close.value(), dec!(115));
8358        assert_eq!(series.n_bars_ago(1).unwrap().close.value(), dec!(110));
8359        assert_eq!(series.n_bars_ago(2).unwrap().close.value(), dec!(105));
8360    }
8361
8362    #[test]
8363    fn test_ohlcv_series_n_bars_ago_out_of_bounds() {
8364        let mut series = OhlcvSeries::new();
8365        series.push(make_bar("100", "110", "90", "105")).unwrap();
8366        assert!(series.n_bars_ago(1).is_none());
8367        assert!(OhlcvSeries::new().n_bars_ago(0).is_none());
8368    }
8369
8370    #[test]
8371    fn test_ohlcv_bar_is_outside_bar_true() {
8372        let prev = make_bar("100", "110", "90", "105");
8373        let outside = make_bar("100", "120", "80", "110");
8374        assert!(outside.is_outside_bar(&prev));
8375    }
8376
8377    #[test]
8378    fn test_ohlcv_bar_is_outside_bar_false_for_inside() {
8379        let prev = make_bar("100", "120", "80", "110");
8380        let inside = make_bar("100", "110", "90", "105");
8381        assert!(!inside.is_outside_bar(&prev));
8382    }
8383
8384    #[test]
8385    fn test_ohlcv_bar_is_outside_bar_false_partial() {
8386        let prev = make_bar("100", "110", "90", "105");
8387        let partial = make_bar("100", "115", "92", "110");
8388        assert!(!partial.is_outside_bar(&prev));
8389    }
8390
8391    #[test]
8392    fn test_ohlcv_series_from_bars_valid() {
8393        let bars = vec![
8394            make_bar("100", "110", "90", "105"),
8395            make_bar("105", "115", "95", "110"),
8396        ];
8397        let series = OhlcvSeries::from_bars(bars).unwrap();
8398        assert_eq!(series.len(), 2);
8399    }
8400
8401    #[test]
8402    fn test_ohlcv_series_from_bars_empty() {
8403        let series = OhlcvSeries::from_bars(vec![]).unwrap();
8404        assert!(series.is_empty());
8405    }
8406
8407    #[test]
8408    fn test_ohlcv_series_count_bullish() {
8409        let mut series = OhlcvSeries::new();
8410        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
8411        series.push(make_bar("105", "115", "95", "100")).unwrap(); // bearish
8412        series.push(make_bar("100", "110", "90", "108")).unwrap(); // bullish
8413        assert_eq!(series.count_bullish(3), 2);
8414        assert_eq!(series.count_bullish(1), 1); // last bar only
8415    }
8416
8417    #[test]
8418    fn test_ohlcv_series_count_bearish() {
8419        let mut series = OhlcvSeries::new();
8420        series.push(make_bar("110", "115", "90", "100")).unwrap(); // bearish
8421        series.push(make_bar("105", "115", "95", "110")).unwrap(); // bullish
8422        assert_eq!(series.count_bearish(2), 1);
8423        assert_eq!(series.count_bearish(1), 0); // last bar is bullish
8424    }
8425
8426    #[test]
8427    fn test_ohlcv_series_count_bullish_exceeds_len() {
8428        let mut series = OhlcvSeries::new();
8429        series.push(make_bar("100", "110", "90", "105")).unwrap();
8430        assert_eq!(series.count_bullish(100), 1);
8431    }
8432
8433    #[test]
8434    fn test_ohlcv_series_median_close_empty() {
8435        assert!(OhlcvSeries::new().median_close(5).is_none());
8436    }
8437
8438    #[test]
8439    fn test_ohlcv_series_median_close_odd_count() {
8440        // closes: 100, 110, 120 → sorted: [100, 110, 120] → median = 110
8441        let mut series = OhlcvSeries::new();
8442        series.push(make_bar("100", "100", "100", "100")).unwrap();
8443        series.push(make_bar("110", "110", "110", "110")).unwrap();
8444        series.push(make_bar("120", "120", "120", "120")).unwrap();
8445        assert_eq!(series.median_close(3).unwrap(), dec!(110));
8446    }
8447
8448    #[test]
8449    fn test_ohlcv_series_median_close_even_count() {
8450        // closes: 100, 110 → median = (100+110)/2 = 105
8451        let mut series = OhlcvSeries::new();
8452        series.push(make_bar("100", "100", "100", "100")).unwrap();
8453        series.push(make_bar("110", "110", "110", "110")).unwrap();
8454        assert_eq!(series.median_close(2).unwrap(), dec!(105));
8455    }
8456
8457    #[test]
8458    fn test_ohlcv_series_percentile_rank_empty() {
8459        assert!(OhlcvSeries::new().percentile_rank(dec!(100), 5).is_none());
8460    }
8461
8462    #[test]
8463    fn test_ohlcv_series_percentile_rank_above_all() {
8464        // all closes = 100, value = 101 → all below → percentile = 100
8465        let mut series = OhlcvSeries::new();
8466        for _ in 0..4 {
8467            series.push(make_bar("100", "100", "100", "100")).unwrap();
8468        }
8469        assert_eq!(series.percentile_rank(dec!(101), 4).unwrap(), dec!(100));
8470    }
8471
8472    #[test]
8473    fn test_ohlcv_series_percentile_rank_below_all() {
8474        // all closes = 100, value = 99 → none below → percentile = 0
8475        let mut series = OhlcvSeries::new();
8476        for _ in 0..4 {
8477            series.push(make_bar("100", "100", "100", "100")).unwrap();
8478        }
8479        assert_eq!(series.percentile_rank(dec!(99), 4).unwrap(), dec!(0));
8480    }
8481
8482    #[test]
8483    fn test_ohlcv_series_consecutive_ups_empty() {
8484        assert_eq!(OhlcvSeries::new().consecutive_ups(), 0);
8485    }
8486
8487    #[test]
8488    fn test_ohlcv_series_consecutive_ups_all_bullish() {
8489        let mut series = OhlcvSeries::new();
8490        // bullish bar: open < close, make_bar(o, h, l, c)
8491        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
8492        series.push(make_bar("105", "115", "95", "110")).unwrap(); // bullish
8493        assert_eq!(series.consecutive_ups(), 2);
8494    }
8495
8496    #[test]
8497    fn test_ohlcv_series_consecutive_ups_broken_by_bearish() {
8498        let mut series = OhlcvSeries::new();
8499        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
8500        series.push(make_bar("110", "115", "95", "108")).unwrap(); // bearish
8501        series.push(make_bar("108", "115", "100", "112")).unwrap(); // bullish
8502        assert_eq!(series.consecutive_ups(), 1);
8503    }
8504
8505    #[test]
8506    fn test_ohlcv_series_consecutive_downs_counts_bearish_tail() {
8507        let mut series = OhlcvSeries::new();
8508        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
8509        series.push(make_bar("105", "110", "90", "100")).unwrap(); // bearish
8510        series.push(make_bar("100", "105", "85", "95")).unwrap(); // bearish
8511        assert_eq!(series.consecutive_downs(), 2);
8512        assert_eq!(series.consecutive_ups(), 0);
8513    }
8514
8515    #[test]
8516    fn test_ohlcv_bar_is_marubozu_full_body() {
8517        // open=100, high=110, low=100, close=110 → no shadows
8518        let bar = make_bar("100", "110", "100", "110");
8519        assert!(bar.is_marubozu());
8520    }
8521
8522    #[test]
8523    fn test_ohlcv_bar_is_marubozu_false_with_shadows() {
8524        let bar = make_bar("100", "115", "95", "110");
8525        assert!(!bar.is_marubozu());
8526    }
8527
8528    #[test]
8529    fn test_ohlcv_bar_is_spinning_top_true() {
8530        // range=40, body=2, upper=18, lower=20
8531        let bar = make_bar("100", "120", "80", "102");
8532        assert!(bar.is_spinning_top());
8533    }
8534
8535    #[test]
8536    fn test_ohlcv_bar_is_spinning_top_false_large_body() {
8537        // body=14, range=20 → body_ratio=0.70 > 0.30
8538        let bar = make_bar("100", "115", "95", "114");
8539        assert!(!bar.is_spinning_top());
8540    }
8541
8542    #[test]
8543    fn test_ohlcv_series_average_volume_all_same() {
8544        // make_bar always sets volume = 100
8545        let mut series = OhlcvSeries::new();
8546        series.push(make_bar("100", "110", "90", "105")).unwrap();
8547        series.push(make_bar("105", "115", "95", "110")).unwrap();
8548        assert_eq!(series.average_volume(2).unwrap(), dec!(100));
8549    }
8550
8551    #[test]
8552    fn test_ohlcv_series_average_range() {
8553        let mut series = OhlcvSeries::new();
8554        series.push(make_bar("100", "120", "80", "110")).unwrap(); // range=40
8555        series.push(make_bar("110", "125", "100", "115")).unwrap(); // range=25
8556        assert_eq!(series.average_range(2).unwrap(), dec!(32.5));
8557    }
8558
8559    #[test]
8560    fn test_ohlcv_series_average_volume_empty_returns_none() {
8561        let series = OhlcvSeries::new();
8562        assert!(series.average_volume(5).is_none());
8563    }
8564
8565    #[test]
8566    fn test_ohlcv_series_typical_price_mean_single_bar() {
8567        let mut series = OhlcvSeries::new();
8568        // typical = (120+80+110)/3 ≈ 103.333...
8569        let bar = make_bar("100", "120", "80", "110");
8570        series.push(bar).unwrap();
8571        let tp = series.typical_price_mean(1).unwrap();
8572        // (120+80+110)/3
8573        let expected = (dec!(120) + dec!(80) + dec!(110)) / dec!(3);
8574        assert_eq!(tp, expected);
8575    }
8576
8577    #[test]
8578    fn test_ohlcv_series_below_sma_zero_when_all_above() {
8579        let mut series = OhlcvSeries::new();
8580        for _ in 0..3 { series.push(make_bar("100", "110", "90", "100")).unwrap(); }
8581        // SMA(3) = 100, close=100, not strictly below → 0
8582        assert_eq!(series.below_sma(3, 3), 0);
8583    }
8584
8585    #[test]
8586    fn test_ohlcv_series_sortino_ratio_insufficient_data() {
8587        let mut series = OhlcvSeries::new();
8588        series.push(make_bar("100", "110", "90", "105")).unwrap();
8589        assert!(series.sortino_ratio(0.0, 252.0).is_none());
8590    }
8591
8592    #[test]
8593    fn test_ohlcv_bar_weighted_close_equals_hlcc4() {
8594        let bar = make_bar("100", "120", "80", "110");
8595        assert_eq!(bar.weighted_close(), bar.hlcc4());
8596    }
8597
8598    #[test]
8599    fn test_ohlcv_bar_weighted_close_value() {
8600        // (high + low + close*2) / 4 = (120 + 80 + 110 + 110) / 4 = 420/4 = 105
8601        let bar = make_bar("100", "120", "80", "110");
8602        assert_eq!(bar.weighted_close(), dec!(105));
8603    }
8604
8605    #[test]
8606    fn test_close_above_open_streak_three_bullish() {
8607        let mut series = OhlcvSeries::new();
8608        series.push(make_bar("100", "110", "90", "95")).unwrap();   // bearish
8609        series.push(make_bar("95", "110", "90", "105")).unwrap();   // bullish
8610        series.push(make_bar("105", "115", "100", "112")).unwrap(); // bullish
8611        series.push(make_bar("112", "120", "108", "118")).unwrap(); // bullish
8612        assert_eq!(series.close_above_open_streak(), 3);
8613    }
8614
8615    #[test]
8616    fn test_close_above_open_streak_last_bearish_returns_zero() {
8617        let mut series = OhlcvSeries::new();
8618        series.push(make_bar("105", "110", "100", "102")).unwrap(); // bullish
8619        series.push(make_bar("102", "108", "98", "99")).unwrap();   // bearish (close < open)
8620        assert_eq!(series.close_above_open_streak(), 0);
8621    }
8622
8623    #[test]
8624    fn test_close_above_open_streak_empty_series_returns_zero() {
8625        assert_eq!(OhlcvSeries::new().close_above_open_streak(), 0);
8626    }
8627
8628    #[test]
8629    fn test_max_drawdown_pct_declining_series() {
8630        let mut series = OhlcvSeries::new();
8631        series.push(make_bar("100", "110", "90", "100")).unwrap();
8632        series.push(make_bar("100", "105", "75", "80")).unwrap();  // 20% drawdown from 100
8633        series.push(make_bar("80", "85", "75", "84")).unwrap();
8634        let dd = series.max_drawdown_pct(10).unwrap();
8635        assert!((dd - 20.0).abs() < 1e-6, "expected ~20, got {dd}");
8636    }
8637
8638    #[test]
8639    fn test_max_drawdown_pct_flat_returns_zero() {
8640        let mut series = OhlcvSeries::new();
8641        series.push(make_bar("100", "110", "90", "100")).unwrap();
8642        series.push(make_bar("100", "110", "90", "100")).unwrap();
8643        assert_eq!(series.max_drawdown_pct(10).unwrap(), 0.0);
8644    }
8645
8646    #[test]
8647    fn test_max_drawdown_pct_single_bar_returns_none() {
8648        let mut series = OhlcvSeries::new();
8649        series.push(make_bar("100", "110", "90", "100")).unwrap();
8650        assert!(series.max_drawdown_pct(10).is_none());
8651    }
8652
8653    #[test]
8654    fn test_ohlcv_bar_gap_up_from_prev() {
8655        let prev = make_bar("100", "105", "95", "103");
8656        let curr = make_bar("107", "115", "106", "112"); // low(106) > prev.high(105)
8657        assert!(curr.gap_up_from(&prev));
8658    }
8659
8660    #[test]
8661    fn test_ohlcv_bar_no_gap_up() {
8662        let prev = make_bar("100", "110", "90", "105");
8663        let curr = make_bar("105", "112", "104", "108"); // low(104) < prev.high(110)
8664        assert!(!curr.gap_up_from(&prev));
8665    }
8666
8667    #[test]
8668    fn test_ohlcv_bar_gap_down_from_prev() {
8669        let prev = make_bar("100", "105", "95", "97");
8670        let curr = make_bar("93", "94", "88", "90"); // high(94) < prev.low(95)
8671        assert!(curr.gap_down_from(&prev));
8672    }
8673
8674    #[test]
8675    fn test_ohlcv_bar_no_gap_down() {
8676        let prev = make_bar("100", "110", "90", "95");
8677        let curr = make_bar("96", "100", "92", "98"); // high(100) > prev.low(90)
8678        assert!(!curr.gap_down_from(&prev));
8679    }
8680
8681    #[test]
8682    fn test_ohlcv_series_last_n_closes_returns_n() {
8683        let mut series = OhlcvSeries::new();
8684        for close in &["100", "102", "104", "106", "108"] {
8685            series.push(make_bar(close, "115", "95", close)).unwrap();
8686        }
8687        let closes = series.last_n_closes(3);
8688        assert_eq!(closes.len(), 3);
8689        assert_eq!(closes[2], dec!(108));
8690    }
8691
8692    #[test]
8693    fn test_ohlcv_series_last_n_closes_fewer_than_n() {
8694        let mut series = OhlcvSeries::new();
8695        series.push(make_bar("100", "110", "90", "100")).unwrap();
8696        let closes = series.last_n_closes(5);
8697        assert_eq!(closes.len(), 1);
8698    }
8699
8700    #[test]
8701    fn test_ohlcv_series_volume_spike_detects_spike() {
8702        use crate::types::{NanoTimestamp, Quantity, Symbol};
8703        let sym = Symbol::new("X").unwrap();
8704        let p = crate::types::Price::new(dec!(100)).unwrap();
8705        let mut series = OhlcvSeries::new();
8706        // Add 3 bars with low volume
8707        for _ in 0..3 {
8708            series.push(OhlcvBar {
8709                symbol: sym.clone(), open: p, high: p, low: p, close: p,
8710                volume: Quantity::new(dec!(100)).unwrap(),
8711                ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
8712            }).unwrap();
8713        }
8714        // Add a spike bar with 5× volume
8715        series.push(OhlcvBar {
8716            symbol: sym.clone(), open: p, high: p, low: p, close: p,
8717            volume: Quantity::new(dec!(500)).unwrap(),
8718            ts_open: NanoTimestamp::new(2), ts_close: NanoTimestamp::new(3), tick_count: 1,
8719        }).unwrap();
8720        assert!(series.volume_spike(3, dec!(3)));
8721    }
8722
8723    #[test]
8724    fn test_ohlcv_series_volume_spike_false_for_normal_volume() {
8725        use crate::types::{NanoTimestamp, Quantity, Symbol};
8726        let sym = Symbol::new("X").unwrap();
8727        let p = crate::types::Price::new(dec!(100)).unwrap();
8728        let mut series = OhlcvSeries::new();
8729        for _ in 0..4 {
8730            series.push(OhlcvBar {
8731                symbol: sym.clone(), open: p, high: p, low: p, close: p,
8732                volume: Quantity::new(dec!(100)).unwrap(),
8733                ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
8734            }).unwrap();
8735        }
8736        assert!(!series.volume_spike(3, dec!(3)));
8737    }
8738
8739    #[test]
8740    fn test_efficiency_ratio_trending() {
8741        let mut series = OhlcvSeries::new();
8742        // Strictly rising prices → direction == path → ER = 1
8743        for i in 0..6u32 {
8744            series.push(make_bar(&format!("{}", 100 + i), &format!("{}", 105 + i), &format!("{}", 99 + i), &format!("{}", 100 + i))).unwrap();
8745        }
8746        let er = series.efficiency_ratio(5).unwrap();
8747        assert_eq!(er, dec!(1));
8748    }
8749
8750    #[test]
8751    fn test_efficiency_ratio_none_when_not_enough_bars() {
8752        let mut series = OhlcvSeries::new();
8753        series.push(make_bar("100", "110", "90", "100")).unwrap();
8754        assert!(series.efficiency_ratio(5).is_none());
8755    }
8756
8757    #[test]
8758    fn test_efficiency_ratio_zero_period_returns_none() {
8759        let series = OhlcvSeries::new();
8760        assert!(series.efficiency_ratio(0).is_none());
8761    }
8762
8763    #[test]
8764    fn test_body_pct_series_full_body() {
8765        let mut series = OhlcvSeries::new();
8766        // Bar: open=90, close=110, high=110, low=90 → body=20, range=20 → 100%
8767        series.push(make_bar("90", "110", "90", "110")).unwrap();
8768        let v = series.body_pct_series(1);
8769        assert_eq!(v.len(), 1);
8770        assert_eq!(v[0], Some(dec!(100)));
8771    }
8772
8773    #[test]
8774    fn test_body_pct_series_zero_range_returns_none() {
8775        let mut series = OhlcvSeries::new();
8776        series.push(make_bar("100", "100", "100", "100")).unwrap();
8777        let v = series.body_pct_series(1);
8778        assert_eq!(v[0], None);
8779    }
8780
8781    #[test]
8782    fn test_candle_color_changes_alternating() {
8783        let mut series = OhlcvSeries::new();
8784        // Bullish, Bearish, Bullish → 2 changes
8785        series.push(make_bar("95", "110", "90", "105")).unwrap();  // bull
8786        series.push(make_bar("105", "115", "100", "102")).unwrap(); // bear
8787        series.push(make_bar("102", "115", "98", "110")).unwrap();  // bull
8788        assert_eq!(series.candle_color_changes(3), 2);
8789    }
8790
8791    #[test]
8792    fn test_candle_color_changes_no_changes() {
8793        let mut series = OhlcvSeries::new();
8794        // All bullish → 0 changes
8795        for _ in 0..3 {
8796            series.push(make_bar("95", "110", "90", "105")).unwrap();
8797        }
8798        assert_eq!(series.candle_color_changes(3), 0);
8799    }
8800
8801    #[test]
8802    fn test_typical_price_series_values() {
8803        let mut series = OhlcvSeries::new();
8804        // H=110, L=90, C=100 → tp = (110+90+100)/3 = 100
8805        series.push(make_bar("95", "110", "90", "100")).unwrap();
8806        let v = series.typical_price_series(1);
8807        assert_eq!(v.len(), 1);
8808        assert_eq!(v[0], dec!(100));
8809    }
8810
8811    #[test]
8812    fn test_typical_price_series_empty_series_returns_empty() {
8813        let series = OhlcvSeries::new();
8814        assert!(series.typical_price_series(3).is_empty());
8815    }
8816
8817    #[test]
8818    fn test_bar_at_index_valid() {
8819        let bars = vec![bar("100"), bar("101"), bar("102")];
8820        let series = OhlcvSeries::from_bars(bars).unwrap();
8821        assert!(series.bar_at_index(0).is_some());
8822        assert_eq!(series.bar_at_index(2).unwrap().close.value(), dec!(102));
8823    }
8824
8825    #[test]
8826    fn test_bar_at_index_out_of_bounds() {
8827        let bars = vec![bar("100")];
8828        let series = OhlcvSeries::from_bars(bars).unwrap();
8829        assert!(series.bar_at_index(5).is_none());
8830    }
8831
8832    #[test]
8833    fn test_rolling_close_std_returns_none_for_fewer_than_two() {
8834        let bars = vec![bar("100")];
8835        let series = OhlcvSeries::from_bars(bars).unwrap();
8836        assert!(series.rolling_close_std(1).is_none());
8837    }
8838
8839    #[test]
8840    fn test_rolling_close_std_constant_prices_is_zero() {
8841        let bars = vec![bar("100"), bar("100"), bar("100")];
8842        let series = OhlcvSeries::from_bars(bars).unwrap();
8843        let std = series.rolling_close_std(3).unwrap();
8844        assert_eq!(std, Decimal::ZERO);
8845    }
8846
8847    #[test]
8848    fn test_rolling_close_std_varying_prices_positive() {
8849        let bars = vec![bar("100"), bar("110"), bar("120"), bar("130")];
8850        let series = OhlcvSeries::from_bars(bars).unwrap();
8851        let std = series.rolling_close_std(4).unwrap();
8852        assert!(std > Decimal::ZERO);
8853    }
8854
8855    #[test]
8856    fn test_gap_direction_series_empty_for_single_bar() {
8857        let bars = vec![bar("100")];
8858        let series = OhlcvSeries::from_bars(bars).unwrap();
8859        assert!(series.gap_direction_series(3).is_empty());
8860    }
8861
8862    #[test]
8863    fn test_gap_direction_series_flat_on_equal_prices() {
8864        let bars = vec![bar("100"), bar("100"), bar("100")];
8865        let series = OhlcvSeries::from_bars(bars).unwrap();
8866        let gaps = series.gap_direction_series(3);
8867        assert!(gaps.iter().all(|&g| g == 0));
8868    }
8869
8870    #[test]
8871    fn test_gap_direction_series_detects_gap_up() {
8872        // bar opens 5 above prior close
8873        let p1 = Price::new(dec!(100)).unwrap();
8874        let p2 = Price::new(dec!(110)).unwrap();
8875        let b1 = OhlcvBar {
8876            symbol: Symbol::new("X").unwrap(),
8877            open: p1, high: p1, low: p1, close: p1,
8878            volume: Quantity::zero(),
8879            ts_open: NanoTimestamp::new(0),
8880            ts_close: NanoTimestamp::new(1),
8881            tick_count: 1,
8882        };
8883        let b2 = OhlcvBar {
8884            symbol: Symbol::new("X").unwrap(),
8885            open: p2, high: p2, low: p2, close: p2,
8886            volume: Quantity::zero(),
8887            ts_open: NanoTimestamp::new(2),
8888            ts_close: NanoTimestamp::new(3),
8889            tick_count: 1,
8890        };
8891        let series = OhlcvSeries::from_bars(vec![b1, b2]).unwrap();
8892        let gaps = series.gap_direction_series(2);
8893        assert_eq!(gaps, vec![1i8]);
8894    }
8895
8896    #[test]
8897    fn test_bullish_candle_pct_all_bullish() {
8898        // open < close for all bars → 100 %
8899        let bars = vec![
8900            make_bar("95", "105", "94", "100"),
8901            make_bar("99", "110", "98", "108"),
8902            make_bar("107", "115", "106", "112"),
8903        ];
8904        let series = OhlcvSeries::from_bars(bars).unwrap();
8905        assert_eq!(series.bullish_candle_pct(3).unwrap(), 1.0);
8906    }
8907
8908    #[test]
8909    fn test_bullish_candle_pct_none_for_zero_n() {
8910        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8911        assert!(series.bullish_candle_pct(0).is_none());
8912    }
8913
8914    #[test]
8915    fn test_price_above_ma_pct_all_above() {
8916        // Rising prices: every close will be above the 2-bar SMA of the prior window.
8917        let bars = vec![
8918            bar("100"), bar("102"), bar("104"), bar("106"), bar("108"),
8919        ];
8920        let series = OhlcvSeries::from_bars(bars).unwrap();
8921        // n=3, period=2 → need at least 4 bars
8922        let pct = series.price_above_ma_pct(3, 2).unwrap();
8923        assert!(pct > 0.0);
8924    }
8925
8926    #[test]
8927    fn test_price_above_ma_pct_insufficient_bars() {
8928        let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
8929        assert!(series.price_above_ma_pct(2, 3).is_none());
8930    }
8931
8932    #[test]
8933    fn test_avg_body_size_flat() {
8934        // open == close → body = 0
8935        let bars = vec![bar("100"), bar("100"), bar("100")];
8936        let series = OhlcvSeries::from_bars(bars).unwrap();
8937        assert_eq!(series.avg_body_size(3).unwrap(), dec!(0));
8938    }
8939
8940    #[test]
8941    fn test_avg_body_size_none_for_zero_n() {
8942        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8943        assert!(series.avg_body_size(0).is_none());
8944    }
8945
8946    #[test]
8947    fn test_true_range_series_flat() {
8948        let bars = vec![bar("100"), bar("100"), bar("100")];
8949        let series = OhlcvSeries::from_bars(bars).unwrap();
8950        let trs = series.true_range_series(3).unwrap();
8951        assert_eq!(trs.len(), 3);
8952        // All flat bars → true range = 0
8953        for tr in trs {
8954            assert_eq!(tr, dec!(0));
8955        }
8956    }
8957
8958    #[test]
8959    fn test_true_range_series_none_when_insufficient() {
8960        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8961        assert!(series.true_range_series(0).is_none());
8962        assert!(series.true_range_series(2).is_none());
8963    }
8964
8965    #[test]
8966    fn test_intraday_return_pct_positive() {
8967        // bar() uses same price for open and close, so use custom bars
8968        let make_bar = |o: &str, c: &str| {
8969            let op = Price::new(o.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
8970            let cl = Price::new(c.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
8971            OhlcvBar {
8972                symbol: Symbol::new("X").unwrap(),
8973                open: op, high: cl, low: op, close: cl,
8974                volume: Quantity::zero(),
8975                ts_open: NanoTimestamp::new(0),
8976                ts_close: NanoTimestamp::new(1),
8977                tick_count: 1,
8978            }
8979        };
8980        let series = OhlcvSeries::from_bars(vec![make_bar("100", "110")]).unwrap();
8981        // (110 - 100) / 100 * 100 = 10%
8982        assert_eq!(series.intraday_return_pct().unwrap(), dec!(10));
8983    }
8984
8985    #[test]
8986    fn test_intraday_return_pct_empty() {
8987        assert!(OhlcvSeries::new().intraday_return_pct().is_none());
8988    }
8989
8990    #[test]
8991    fn test_bearish_bar_count_all_flat() {
8992        let bars = vec![bar("100"), bar("100"), bar("100")];
8993        let series = OhlcvSeries::from_bars(bars).unwrap();
8994        // flat bars (open == close) are not bearish
8995        assert_eq!(series.bearish_bar_count(3).unwrap(), 0);
8996    }
8997
8998    #[test]
8999    fn test_bearish_bar_count_none_insufficient() {
9000        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9001        assert!(series.bearish_bar_count(0).is_none());
9002        assert!(series.bearish_bar_count(2).is_none());
9003    }
9004
9005    #[test]
9006    fn test_hl_midpoint_flat() {
9007        let bars = vec![bar("100"), bar("100"), bar("100")];
9008        let series = OhlcvSeries::from_bars(bars).unwrap();
9009        assert_eq!(series.hl_midpoint(3).unwrap(), dec!(100));
9010    }
9011
9012    #[test]
9013    fn test_hl_midpoint_none_when_insufficient() {
9014        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9015        assert!(series.hl_midpoint(0).is_none());
9016        assert!(series.hl_midpoint(2).is_none());
9017    }
9018
9019    #[test]
9020    fn test_up_volume_ratio_flat_bars() {
9021        // flat bars (close == open) → no up-volume → ratio = 0
9022        let bars = vec![bar("100"), bar("100"), bar("100")];
9023        let series = OhlcvSeries::from_bars(bars).unwrap();
9024        // bars have non-zero volume (make_bar uses qty 100); flat → up_vol = 0
9025        let ratio = series.up_volume_ratio(3);
9026        if let Some(r) = ratio {
9027            assert_eq!(r, dec!(0));
9028        }
9029        // None is also valid if volume were truly zero
9030    }
9031
9032    #[test]
9033    fn test_price_efficiency_trending() {
9034        // Monotonically rising prices → path equals net → efficiency = 1
9035        let bars: Vec<_> = (100..106u32).map(|i| bar(&i.to_string())).collect();
9036        let series = OhlcvSeries::from_bars(bars).unwrap();
9037        assert_eq!(series.price_efficiency(5).unwrap(), dec!(1));
9038    }
9039
9040    #[test]
9041    fn test_price_efficiency_none_insufficient() {
9042        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9043        assert!(series.price_efficiency(1).is_none());
9044        assert!(series.price_efficiency(3).is_none());
9045    }
9046
9047    #[test]
9048    fn test_avg_gap_zero_when_no_jumps() {
9049        let bars = vec![bar("100"), bar("100"), bar("100")];
9050        let series = OhlcvSeries::from_bars(bars).unwrap();
9051        assert_eq!(series.avg_gap(2).unwrap(), dec!(0));
9052    }
9053
9054    #[test]
9055    fn test_avg_gap_none_when_insufficient() {
9056        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9057        assert!(series.avg_gap(0).is_none());
9058        assert!(series.avg_gap(1).is_none());
9059    }
9060
9061    #[test]
9062    fn test_largest_gap_pct_no_gap() {
9063        // all bars open == prev close → gap = 0
9064        let bars: Vec<_> = (0..5).map(|_| bar("100")).collect();
9065        let series = OhlcvSeries::from_bars(bars).unwrap();
9066        assert_eq!(series.largest_gap_pct(4).unwrap(), dec!(0));
9067    }
9068
9069    #[test]
9070    fn test_largest_gap_pct_none_insufficient() {
9071        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9072        assert!(series.largest_gap_pct(2).is_none());
9073        assert!(series.largest_gap_pct(1).is_none());
9074    }
9075
9076    #[test]
9077    fn test_close_momentum_flat_zero() {
9078        let bars: Vec<_> = (0..6).map(|_| bar("100")).collect();
9079        let series = OhlcvSeries::from_bars(bars).unwrap();
9080        assert_eq!(series.close_momentum(3).unwrap(), dec!(0));
9081    }
9082
9083    #[test]
9084    fn test_close_momentum_none_insufficient() {
9085        // close_momentum(n) needs n+1 bars; with 2 bars, n=1 is valid but n=2 is not
9086        let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
9087        assert!(series.close_momentum(2).is_none()); // need 3 bars
9088        assert!(series.close_momentum(0).is_none());
9089    }
9090
9091    #[test]
9092    fn test_swing_high_count_none_when_insufficient() {
9093        let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
9094        assert!(series.swing_high_count(5, 1).is_none()); // fewer than n bars
9095        assert!(series.swing_high_count(0, 1).is_none()); // n=0
9096        assert!(series.swing_high_count(2, 0).is_none()); // lookback=0
9097    }
9098
9099    #[test]
9100    fn test_swing_high_count_detects_peak() {
9101        // Pattern: 100, 110, 100, 100, 100 — middle bar is a swing high with lookback=1
9102        let bars = vec![bar("100"), bar("110"), bar("100"), bar("100"), bar("100")];
9103        let series = OhlcvSeries::from_bars(bars).unwrap();
9104        let count = series.swing_high_count(5, 1).unwrap();
9105        assert_eq!(count, 1);
9106    }
9107
9108    #[test]
9109    fn test_swing_high_count_flat_no_highs() {
9110        let bars: Vec<_> = (0..7).map(|_| bar("100")).collect();
9111        let series = OhlcvSeries::from_bars(bars).unwrap();
9112        assert_eq!(series.swing_high_count(7, 1).unwrap(), 0);
9113    }
9114
9115    #[test]
9116    fn test_avg_wick_pct_none_when_zero_range() {
9117        let bars = vec![bar("100"), bar("100")];
9118        let series = OhlcvSeries::from_bars(bars).unwrap();
9119        assert!(series.avg_wick_pct(2).is_none()); // zero-range bars → None
9120    }
9121
9122    #[test]
9123    fn test_avg_wick_pct_none_insufficient() {
9124        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9125        assert!(series.avg_wick_pct(0).is_none());
9126        assert!(series.avg_wick_pct(2).is_none());
9127    }
9128
9129    #[test]
9130    fn test_trend_continuation_pct_none_insufficient() {
9131        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9132        assert!(series.trend_continuation_pct(0).is_none());
9133        assert!(series.trend_continuation_pct(1).is_none()); // need n+1=2 bars
9134    }
9135
9136    fn make_bar_vol(o: &str, h: &str, l: &str, c: &str, vol: &str) -> OhlcvBar {
9137        OhlcvBar {
9138            symbol: Symbol::new("X").unwrap(),
9139            open: make_price(o),
9140            high: make_price(h),
9141            low: make_price(l),
9142            close: make_price(c),
9143            volume: make_qty(vol),
9144            ts_open: NanoTimestamp::new(0),
9145            ts_close: NanoTimestamp::new(1),
9146            tick_count: 1,
9147        }
9148    }
9149
9150    #[test]
9151    fn test_close_to_open_ratio_bullish() {
9152        // close > open → ratio > 1
9153        let bars = vec![
9154            make_bar_vol("100", "110", "95", "110", "1000"),  // close/open = 1.1
9155            make_bar_vol("105", "115", "100", "115", "1000"), // close/open ≈ 1.095
9156        ];
9157        let series = OhlcvSeries::from_bars(bars).unwrap();
9158        let ratio = series.close_to_open_ratio(2).unwrap();
9159        assert!(ratio > dec!(1), "bullish bars: ratio > 1, got {ratio}");
9160    }
9161
9162    #[test]
9163    fn test_close_to_open_ratio_none_zero_n() {
9164        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9165        assert!(series.close_to_open_ratio(0).is_none());
9166    }
9167
9168    #[test]
9169    fn test_volume_trend_rising() {
9170        let bars: Vec<OhlcvBar> = (1..=5u32).map(|i| {
9171            make_bar_vol("100", "100", "100", "100", &(i * 100).to_string())
9172        }).collect();
9173        let series = OhlcvSeries::from_bars(bars).unwrap();
9174        let slope = series.volume_trend(5).unwrap();
9175        assert!(slope > 0.0_f64, "rising volume: positive slope, got {slope}");
9176    }
9177
9178    #[test]
9179    fn test_volume_trend_none_insufficient() {
9180        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9181        assert!(series.volume_trend(0).is_none());
9182        assert!(series.volume_trend(2).is_none()); // only 1 bar, need >= 2
9183    }
9184
9185    #[test]
9186    fn test_high_volume_price_returns_close_of_max_vol_bar() {
9187        let bars = vec![
9188            make_bar_vol("100", "100", "100", "100", "500"),
9189            make_bar_vol("200", "200", "200", "200", "1000"), // highest volume
9190            make_bar_vol("150", "150", "150", "150", "300"),
9191        ];
9192        let series = OhlcvSeries::from_bars(bars).unwrap();
9193        assert_eq!(series.high_volume_price(3), Some(dec!(200)));
9194    }
9195
9196    #[test]
9197    fn test_high_volume_price_none_zero_n() {
9198        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9199        assert!(series.high_volume_price(0).is_none());
9200    }
9201
9202    #[test]
9203    fn test_avg_close_minus_open_bullish() {
9204        let bars = vec![
9205            make_bar_vol("100", "110", "95", "105", "1000"), // +5
9206            make_bar_vol("105", "115", "100", "108", "1000"), // +3
9207        ];
9208        let series = OhlcvSeries::from_bars(bars).unwrap();
9209        let avg = series.avg_close_minus_open(2).unwrap();
9210        assert_eq!(avg, dec!(4)); // (5 + 3) / 2 = 4
9211    }
9212
9213    #[test]
9214    fn test_avg_close_minus_open_none_zero_n() {
9215        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
9216        assert!(series.avg_close_minus_open(0).is_none());
9217    }
9218}