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::from_str_exact("0.30").unwrap_or(Decimal::ZERO);
276        let threshold_20 = Decimal::from_str_exact("0.20").unwrap_or(Decimal::ZERO);
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(|g| g.abs() >= pct_threshold)
463            .unwrap_or(false)
464    }
465
466    /// Creates a single-tick OHLCV bar from a `Tick`.
467    ///
468    /// All price fields are set to the tick's price, volume to the tick's quantity,
469    /// and both timestamps to the tick's timestamp.
470    pub fn from_tick(tick: &Tick) -> Self {
471        Self {
472            symbol: tick.symbol.clone(),
473            open: tick.price,
474            high: tick.price,
475            low: tick.price,
476            close: tick.price,
477            volume: tick.quantity,
478            ts_open: tick.timestamp,
479            ts_close: tick.timestamp,
480            tick_count: 1,
481        }
482    }
483
484    /// Merges `other` into `self`, producing a combined bar spanning both time ranges.
485    ///
486    /// - `open` comes from `self` (the earlier bar)
487    /// - `close` comes from `other` (the later bar)
488    /// - `high` / `low` are the extremes across both bars
489    /// - `volume` and `tick_count` are summed
490    /// - `ts_open` from `self`, `ts_close` from `other`
491    ///
492    /// # Errors
493    /// Returns [`FinError::BarInvariant`] if the merged bar fails invariant checks (should not
494    /// occur for well-formed inputs but is checked defensively).
495    pub fn merge(&self, other: &OhlcvBar) -> Result<OhlcvBar, FinError> {
496        let high = self.high.value().max(other.high.value());
497        let low = self.low.value().min(other.low.value());
498        let volume_sum = self.volume.value() + other.volume.value();
499        let bar = OhlcvBar {
500            symbol: self.symbol.clone(),
501            open: self.open,
502            high: Price::new(high)?,
503            low: Price::new(low)?,
504            close: other.close,
505            volume: Quantity::new(volume_sum)?,
506            ts_open: self.ts_open,
507            ts_close: other.ts_close,
508            tick_count: self.tick_count + other.tick_count,
509        };
510        bar.validate()?;
511        Ok(bar)
512    }
513
514    /// Returns `true` if this bar is a bullish engulfing of `prev`.
515    ///
516    /// Conditions:
517    /// - `prev` is bearish (`open > close`)
518    /// - `self` is bullish (`close > open`)
519    /// - `self.open <= prev.close` (opens at or below prev close)
520    /// - `self.close >= prev.open` (closes at or above prev open)
521    pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
522        let prev_bearish = prev.open.value() > prev.close.value();
523        let self_bullish = self.close.value() > self.open.value();
524        prev_bearish
525            && self_bullish
526            && self.open.value() <= prev.close.value()
527            && self.close.value() >= prev.open.value()
528    }
529
530    /// Returns `true` if this bar is a bearish engulfing of `prev`.
531    ///
532    /// Conditions:
533    /// - `prev` is bullish (`close > open`)
534    /// - `self` is bearish (`open > close`)
535    /// - `self.open >= prev.close` (opens at or above prev close)
536    /// - `self.close <= prev.open` (closes at or below prev open)
537    pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
538        let prev_bullish = prev.close.value() > prev.open.value();
539        let self_bearish = self.open.value() > self.close.value();
540        prev_bullish
541            && self_bearish
542            && self.open.value() >= prev.close.value()
543            && self.close.value() <= prev.open.value()
544    }
545
546}
547
548/// A timeframe for bar aggregation.
549#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
550pub enum Timeframe {
551    /// Aggregation by N seconds.
552    Seconds(u32),
553    /// Aggregation by N minutes.
554    Minutes(u32),
555    /// Aggregation by N hours.
556    Hours(u32),
557    /// Aggregation by N days.
558    Days(u32),
559    /// Aggregation by N weeks (7-day periods).
560    Weeks(u32),
561}
562
563impl Timeframe {
564    /// Returns the timeframe duration in nanoseconds.
565    ///
566    /// # Errors
567    /// Returns [`FinError::InvalidTimeframe`] if the duration is zero.
568    pub fn to_nanos(&self) -> Result<i64, FinError> {
569        let secs: u64 = match self {
570            Timeframe::Seconds(n) => u64::from(*n),
571            Timeframe::Minutes(n) => u64::from(*n) * 60,
572            Timeframe::Hours(n) => u64::from(*n) * 3_600,
573            Timeframe::Days(n) => u64::from(*n) * 86_400,
574            Timeframe::Weeks(n) => u64::from(*n) * 7 * 86_400,
575        };
576        if secs == 0 {
577            return Err(FinError::InvalidTimeframe);
578        }
579        #[allow(clippy::cast_possible_wrap)]
580        Ok((secs * 1_000_000_000) as i64)
581    }
582
583    /// Returns the bucket start timestamp for a given tick timestamp.
584    ///
585    /// # Errors
586    /// Returns [`FinError::InvalidTimeframe`] if the timeframe duration is zero.
587    pub fn bucket_start(&self, ts: NanoTimestamp) -> Result<NanoTimestamp, FinError> {
588        let nanos = self.to_nanos()?;
589        let bucket = (ts.nanos() / nanos) * nanos;
590        Ok(NanoTimestamp::new(bucket))
591    }
592}
593
594/// Aggregates ticks into OHLCV bars according to a fixed timeframe.
595///
596/// `push_tick` returns a `Vec<OhlcvBar>` — normally empty (tick absorbed into current
597/// bar) or a single element (bar completed). When a tick arrives several buckets ahead
598/// of the current one, gap-fill bars are emitted for the empty intervening buckets,
599/// using the last bar's close for OHLC and zero volume.
600pub struct OhlcvAggregator {
601    symbol: Symbol,
602    timeframe: Timeframe,
603    current_bar: Option<OhlcvBar>,
604    current_bucket_start: Option<NanoTimestamp>,
605    /// Close price of the most recently completed bar, used for gap-filling.
606    last_close: Option<Price>,
607    /// Count of fully completed bars emitted (via push_tick or flush).
608    bars_emitted: usize,
609}
610
611impl OhlcvAggregator {
612    /// Constructs a new `OhlcvAggregator`.
613    ///
614    /// # Errors
615    /// Returns [`FinError::InvalidTimeframe`] if the timeframe is zero-duration.
616    pub fn new(symbol: Symbol, timeframe: Timeframe) -> Result<Self, FinError> {
617        timeframe.to_nanos()?;
618        Ok(Self {
619            symbol,
620            timeframe,
621            current_bar: None,
622            current_bucket_start: None,
623            last_close: None,
624            bars_emitted: 0,
625        })
626    }
627
628    /// Processes a single tick, returning all completed bars.
629    ///
630    /// # Returns
631    /// - `Ok(vec![])`: tick was absorbed into the current bar (same bucket)
632    /// - `Ok(vec![bar])`: one bar completed (tick starts the next bucket)
633    /// - `Ok(vec![bar, gap1, gap2, ..., gap_n])`: the completed bar followed by
634    ///   gap-fill bars for any empty intervening buckets
635    ///
636    /// Ticks for a different symbol are silently ignored and return `Ok(vec![])`.
637    ///
638    /// # Errors
639    /// Returns [`FinError::InvalidTimeframe`] if `timeframe.bucket_start` fails.
640    pub fn push_tick(&mut self, tick: &Tick) -> Result<Vec<OhlcvBar>, FinError> {
641        if tick.symbol != self.symbol {
642            return Ok(vec![]);
643        }
644        let bucket = self.timeframe.bucket_start(tick.timestamp)?;
645        match self.current_bucket_start {
646            None => {
647                self.current_bucket_start = Some(bucket);
648                self.current_bar = Some(self.new_bar(tick));
649                Ok(vec![])
650            }
651            Some(current_bucket) if bucket == current_bucket => {
652                self.update_bar(tick);
653                Ok(vec![])
654            }
655            Some(_) => {
656                let completed = self.current_bar.take().expect("current bar must be Some here");
657                self.last_close = Some(completed.close);
658
659                // Emit gap-fill bars for any buckets between the completed bar and the new one.
660                let mut out = vec![completed];
661                let nanos = self.timeframe.to_nanos()?;
662                let prev_bucket = self.current_bucket_start.expect("set above");
663                let mut gap_bucket = NanoTimestamp::new(prev_bucket.nanos() + nanos);
664                while gap_bucket < bucket {
665                    if let Some(close) = self.last_close {
666                        out.push(OhlcvBar {
667                            symbol: self.symbol.clone(),
668                            open: close,
669                            high: close,
670                            low: close,
671                            close,
672                            volume: Quantity::zero(),
673                            ts_open: gap_bucket,
674                            ts_close: gap_bucket,
675                            tick_count: 0,
676                        });
677                    }
678                    gap_bucket = NanoTimestamp::new(gap_bucket.nanos() + nanos);
679                }
680
681                self.bars_emitted += out.len();
682                self.current_bucket_start = Some(bucket);
683                self.current_bar = Some(self.new_bar(tick));
684                Ok(out)
685            }
686        }
687    }
688
689    /// Flushes the current partial bar, returning it if one exists.
690    pub fn flush(&mut self) -> Option<OhlcvBar> {
691        self.current_bucket_start = None;
692        let bar = self.current_bar.take();
693        if let Some(ref b) = bar {
694            self.last_close = Some(b.close);
695            self.bars_emitted += 1;
696        }
697        bar
698    }
699
700    /// Returns the symbol this aggregator is tracking.
701    pub fn symbol(&self) -> &Symbol {
702        &self.symbol
703    }
704
705    /// Returns the timeframe this aggregator is configured for.
706    pub fn timeframe(&self) -> Timeframe {
707        self.timeframe
708    }
709
710    /// Resets the aggregator, discarding any partial bar and last-close state.
711    ///
712    /// After calling `reset()` the aggregator behaves as if freshly constructed.
713    /// Useful for walk-forward backtesting without recreating the aggregator.
714    pub fn reset(&mut self) {
715        self.current_bar = None;
716        self.current_bucket_start = None;
717        self.last_close = None;
718        self.bars_emitted = 0;
719    }
720
721    /// Returns the number of fully completed bars emitted so far (via `push_tick` or `flush`).
722    pub fn bar_count(&self) -> usize {
723        self.bars_emitted
724    }
725
726    /// Returns a reference to the current (incomplete) bar, if any.
727    pub fn current_bar(&self) -> Option<&OhlcvBar> {
728        self.current_bar.as_ref()
729    }
730
731    /// Returns the bucket-start timestamp of the current open bar, or `None` if no bar is open.
732    ///
733    /// This is the lower boundary of the current timeframe bucket, not the timestamp of the
734    /// first tick received in the bar.
735    pub fn current_bar_open_ts(&self) -> Option<NanoTimestamp> {
736        self.current_bucket_start
737    }
738
739    fn new_bar(&self, tick: &Tick) -> OhlcvBar {
740        OhlcvBar {
741            symbol: self.symbol.clone(),
742            open: tick.price,
743            high: tick.price,
744            low: tick.price,
745            close: tick.price,
746            volume: tick.quantity,
747            ts_open: tick.timestamp,
748            ts_close: tick.timestamp,
749            tick_count: 1,
750        }
751    }
752
753    fn update_bar(&mut self, tick: &Tick) {
754        if let Some(ref mut bar) = self.current_bar {
755            if tick.price > bar.high {
756                bar.high = tick.price;
757            }
758            if tick.price < bar.low {
759                bar.low = tick.price;
760            }
761            bar.close = tick.price;
762            bar.volume =
763                Quantity::new(bar.volume.value() + tick.quantity.value()).unwrap_or(bar.volume);
764            bar.ts_close = tick.timestamp;
765            bar.tick_count += 1;
766        }
767    }
768}
769
770/// An ordered collection of completed OHLCV bars.
771pub struct OhlcvSeries {
772    bars: Vec<OhlcvBar>,
773}
774
775impl OhlcvSeries {
776    /// Creates an empty `OhlcvSeries`.
777    pub fn new() -> Self {
778        Self { bars: Vec::new() }
779    }
780
781    /// Constructs an `OhlcvSeries` from a `Vec<OhlcvBar>`, validating each bar.
782    ///
783    /// # Errors
784    /// Returns [`FinError::BarInvariant`] on the first bar that fails validation.
785    pub fn from_bars(bars: Vec<OhlcvBar>) -> Result<Self, FinError> {
786        for bar in &bars {
787            bar.validate()?;
788        }
789        Ok(Self { bars })
790    }
791
792    /// Creates an empty `OhlcvSeries` with a pre-allocated capacity.
793    ///
794    /// Avoids reallocations when the approximate number of bars is known in advance.
795    pub fn with_capacity(capacity: usize) -> Self {
796        Self {
797            bars: Vec::with_capacity(capacity),
798        }
799    }
800
801    /// Appends a bar to the series after validating its invariants.
802    ///
803    /// # Errors
804    /// Returns [`FinError::BarInvariant`] if `bar.validate()` fails.
805    pub fn push(&mut self, bar: OhlcvBar) -> Result<(), FinError> {
806        bar.validate()?;
807        self.bars.push(bar);
808        Ok(())
809    }
810
811    /// Returns the number of bars in the series.
812    pub fn len(&self) -> usize {
813        self.bars.len()
814    }
815
816    /// Returns `true` if there are no bars.
817    pub fn is_empty(&self) -> bool {
818        self.bars.is_empty()
819    }
820
821    /// Removes all bars from the series, retaining allocated capacity.
822    pub fn clear(&mut self) {
823        self.bars.clear();
824    }
825
826    /// Retains only the bars for which `predicate` returns `true`, removing the rest in-place.
827    ///
828    /// Order is preserved. Useful for filtering out gap-fill bars or bars outside a time range.
829    pub fn retain(&mut self, mut predicate: impl FnMut(&OhlcvBar) -> bool) {
830        self.bars.retain(|b| predicate(b));
831    }
832
833    /// Returns the bar at `index`, or `None` if out of bounds.
834    pub fn get(&self, index: usize) -> Option<&OhlcvBar> {
835        self.bars.get(index)
836    }
837
838    /// Returns the oldest (first inserted) bar, or `None` if empty.
839    pub fn first(&self) -> Option<&OhlcvBar> {
840        self.bars.first()
841    }
842
843    /// Returns the most recent bar, or `None` if empty.
844    pub fn last(&self) -> Option<&OhlcvBar> {
845        self.bars.last()
846    }
847
848    /// Returns the bar `n` positions from the end (0 = most recent), or `None` if out of bounds.
849    ///
850    /// `n_bars_ago(0)` is equivalent to `last()`. Useful in signal logic where
851    /// you need to compare the current bar against bars 1, 2, or 3 periods back.
852    pub fn n_bars_ago(&self, n: usize) -> Option<&OhlcvBar> {
853        let len = self.bars.len();
854        if n >= len {
855            return None;
856        }
857        self.bars.get(len - 1 - n)
858    }
859
860    /// Returns the last `n` bars as a slice (fewer if series has fewer than `n`).
861    pub fn window(&self, n: usize) -> &[OhlcvBar] {
862        let len = self.bars.len();
863        if n >= len {
864            &self.bars
865        } else {
866            &self.bars[len - n..]
867        }
868    }
869
870    /// Returns an iterator over the bars in insertion order.
871    pub fn iter(&self) -> std::slice::Iter<'_, OhlcvBar> {
872        self.bars.iter()
873    }
874
875    /// Returns the count of consecutive bullish bars at the tail of the series.
876    ///
877    /// A bar is bullish when `close >= open`. Returns 0 for an empty series.
878    pub fn consecutive_ups(&self) -> usize {
879        self.bars
880            .iter()
881            .rev()
882            .take_while(|b| b.close.value() >= b.open.value())
883            .count()
884    }
885
886    /// Returns the count of consecutive bearish bars at the tail of the series.
887    ///
888    /// A bar is bearish when `close < open`. Returns 0 for an empty series.
889    pub fn consecutive_downs(&self) -> usize {
890        self.bars
891            .iter()
892            .rev()
893            .take_while(|b| b.close.value() < b.open.value())
894            .count()
895    }
896
897    /// Returns a `Vec` of open prices in series order.
898    pub fn opens(&self) -> Vec<Decimal> {
899        self.bars.iter().map(|b| b.open.value()).collect()
900    }
901
902    /// Returns a `Vec` of high prices in series order.
903    pub fn highs(&self) -> Vec<Decimal> {
904        self.bars.iter().map(|b| b.high.value()).collect()
905    }
906
907    /// Returns a `Vec` of low prices in series order.
908    pub fn lows(&self) -> Vec<Decimal> {
909        self.bars.iter().map(|b| b.low.value()).collect()
910    }
911
912    /// Returns a `Vec` of close prices in series order.
913    pub fn closes(&self) -> Vec<Decimal> {
914        self.bars.iter().map(|b| b.close.value()).collect()
915    }
916
917    /// Returns a `Vec` of volumes in series order.
918    pub fn volumes(&self) -> Vec<Decimal> {
919        self.bars.iter().map(|b| b.volume.value()).collect()
920    }
921
922    /// Returns a `Vec` of typical prices `(high + low + close) / 3` in series order.
923    pub fn typical_prices(&self) -> Vec<Decimal> {
924        self.bars.iter().map(|b| b.typical_price()).collect()
925    }
926
927    /// Returns a direct slice of all bars in insertion order.
928    pub fn bars(&self) -> &[OhlcvBar] {
929        &self.bars
930    }
931
932    /// Returns the maximum high price across all bars, or `None` if empty.
933    pub fn max_high(&self) -> Option<Decimal> {
934        self.bars.iter().map(|b| b.high.value()).reduce(Decimal::max)
935    }
936
937    /// Returns the minimum low price across all bars, or `None` if empty.
938    pub fn min_low(&self) -> Option<Decimal> {
939        self.bars.iter().map(|b| b.low.value()).reduce(Decimal::min)
940    }
941
942    /// Returns the highest high price among the last `n` bars, or `None` if empty.
943    ///
944    /// If `n > self.len()`, considers all bars.
945    pub fn highest_high(&self, n: usize) -> Option<Decimal> {
946        let start = self.bars.len().saturating_sub(n);
947        self.bars[start..].iter().map(|b| b.high.value()).reduce(Decimal::max)
948    }
949
950    /// Returns the lowest low price among the last `n` bars, or `None` if empty.
951    ///
952    /// If `n > self.len()`, considers all bars.
953    pub fn lowest_low(&self, n: usize) -> Option<Decimal> {
954        let start = self.bars.len().saturating_sub(n);
955        self.bars[start..].iter().map(|b| b.low.value()).reduce(Decimal::min)
956    }
957
958    /// Returns the volume-weighted average price (VWAP) across all bars, or `None` if empty
959    /// or if total volume is zero.
960    ///
961    /// `VWAP = Σ(typical_price × volume) / Σ(volume)`
962    pub fn vwap(&self) -> Option<Decimal> {
963        if self.bars.is_empty() {
964            return None;
965        }
966        let total_vol: Decimal = self.bars.iter().map(|b| b.volume.value()).sum();
967        if total_vol == Decimal::ZERO {
968            return None;
969        }
970        let weighted_sum: Decimal = self
971            .bars
972            .iter()
973            .map(|b| b.typical_price() * b.volume.value())
974            .sum();
975        Some(weighted_sum / total_vol)
976    }
977
978    /// Returns the total traded volume across all bars in the series.
979    pub fn sum_volume(&self) -> Decimal {
980        self.bars.iter().map(|b| b.volume.value()).sum()
981    }
982
983    /// Returns the average volume over the last `n` bars, or `None` if fewer than `n` bars exist.
984    pub fn avg_volume(&self, n: usize) -> Option<Decimal> {
985        if n == 0 || self.bars.len() < n {
986            return None;
987        }
988        let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.volume.value()).sum();
989        #[allow(clippy::cast_possible_truncation)]
990        Some(sum / Decimal::from(n as u32))
991    }
992
993    /// Returns `highest_high(n) - lowest_low(n)` over the last `n` bars, or `None` if
994    /// fewer than `n` bars exist or `n == 0`.
995    pub fn price_range(&self, n: usize) -> Option<Decimal> {
996        if n == 0 || self.bars.len() < n {
997            return None;
998        }
999        let hh = self.highest_high(n)?;
1000        let ll = self.lowest_low(n)?;
1001        Some(hh - ll)
1002    }
1003
1004    /// Returns the average Close Location Value over the last `n` bars, or `None` if
1005    /// fewer than `n` bars exist or `n == 0`.
1006    ///
1007    /// `CLV = ((close - low) - (high - close)) / (high - low)`
1008    ///
1009    /// Each bar's CLV is in `[-1, 1]`; bars with zero range contribute `0`.
1010    pub fn close_location_value(&self, n: usize) -> Option<Decimal> {
1011        if n == 0 || self.bars.len() < n {
1012            return None;
1013        }
1014        let start = self.bars.len() - n;
1015        let sum: Decimal = self.bars[start..].iter().map(|b| {
1016            let h = b.high.value();
1017            let l = b.low.value();
1018            let c = b.close.value();
1019            let range = h - l;
1020            if range == Decimal::ZERO { Decimal::ZERO } else { ((c - l) - (h - c)) / range }
1021        }).sum();
1022        #[allow(clippy::cast_possible_truncation)]
1023        Some(sum / Decimal::from(n as u32))
1024    }
1025
1026    /// Returns the average dollar volume over the last `n` bars.
1027    ///
1028    /// `avg_dollar_volume = Σ(typical_price × volume) / n` for the last `n` bars.
1029    ///
1030    /// Returns `None` when `n == 0` or the series has fewer than `n` bars.
1031    pub fn avg_dollar_volume(&self, n: usize) -> Option<Decimal> {
1032        if n == 0 || self.bars.len() < n {
1033            return None;
1034        }
1035        let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.dollar_volume()).sum();
1036        Some(sum / Decimal::from(n as u64))
1037    }
1038
1039    /// Returns a sub-slice `bars[from..to]`, or `None` if the range is out of bounds.
1040    pub fn slice(&self, from: usize, to: usize) -> Option<&[OhlcvBar]> {
1041        if from > to || to > self.bars.len() {
1042            return None;
1043        }
1044        Some(&self.bars[from..to])
1045    }
1046
1047    /// Retains only the last `n` bars, dropping older ones.
1048    ///
1049    /// If `n >= self.len()`, this is a no-op.
1050    pub fn truncate(&mut self, n: usize) {
1051        let len = self.bars.len();
1052        if n < len {
1053            self.bars.drain(0..len - n);
1054        }
1055    }
1056
1057    /// Pushes multiple bars into the series, validating each one.
1058    ///
1059    /// Stops and returns the first error encountered; bars added before the error remain.
1060    ///
1061    /// # Errors
1062    /// Returns [`FinError::BarInvariant`] if any bar fails OHLCV invariant checks.
1063    pub fn extend(&mut self, bars: impl IntoIterator<Item = OhlcvBar>) -> Result<(), FinError> {
1064        for bar in bars {
1065            self.push(bar)?;
1066        }
1067        Ok(())
1068    }
1069
1070    /// Appends all bars from `other` into this series, validating each one.
1071    ///
1072    /// # Errors
1073    /// Returns [`FinError::BarInvariant`] if any bar from `other` fails validation.
1074    pub fn extend_from_series(&mut self, other: &OhlcvSeries) -> Result<(), FinError> {
1075        for bar in &other.bars {
1076            self.push(bar.clone())?;
1077        }
1078        Ok(())
1079    }
1080
1081    /// Converts the series into a `Vec<BarInput>` for batch signal processing.
1082    ///
1083    /// Allows feeding an entire historical series into indicators without manually
1084    /// iterating and converting each bar.
1085    pub fn to_bar_inputs(&self) -> Vec<crate::signals::BarInput> {
1086        self.bars
1087            .iter()
1088            .map(crate::signals::BarInput::from)
1089            .collect()
1090    }
1091
1092    /// Feeds every bar in the series into `signal` and collects the results.
1093    ///
1094    /// Errors from individual bars are propagated immediately (fail-fast).
1095    /// Use this for batch back-testing where you want to apply one signal to
1096    /// an entire historical dataset in one call.
1097    ///
1098    /// # Errors
1099    /// Returns [`FinError`] if any call to `signal.update_bar()` fails.
1100    pub fn apply_signal(
1101        &self,
1102        signal: &mut dyn crate::signals::Signal,
1103    ) -> Result<Vec<crate::signals::SignalValue>, FinError> {
1104        self.bars.iter().map(|b| signal.update_bar(b)).collect()
1105    }
1106
1107    /// Returns close-to-close percentage returns: `(close[i] - close[i-1]) / close[i-1]`.
1108    ///
1109    /// Returns an empty `Vec` when the series has fewer than 2 bars.
1110    /// Skips any bar where `close[i-1]` is zero to avoid division by zero.
1111    pub fn returns(&self) -> Vec<Decimal> {
1112        if self.bars.len() < 2 {
1113            return Vec::new();
1114        }
1115        self.bars
1116            .windows(2)
1117            .filter_map(|w| {
1118                let prev = w[0].close.value();
1119                if prev.is_zero() {
1120                    return None;
1121                }
1122                Some((w[1].close.value() - prev) / prev)
1123            })
1124            .collect()
1125    }
1126
1127    /// Returns the highest close price among the last `n` bars, or `None` if empty.
1128    ///
1129    /// If `n > self.len()`, considers all bars.
1130    pub fn highest_close(&self, n: usize) -> Option<Decimal> {
1131        let start = self.bars.len().saturating_sub(n);
1132        self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::max)
1133    }
1134
1135    /// Returns the lowest close price among the last `n` bars, or `None` if empty.
1136    ///
1137    /// If `n > self.len()`, considers all bars.
1138    pub fn lowest_close(&self, n: usize) -> Option<Decimal> {
1139        let start = self.bars.len().saturating_sub(n);
1140        self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::min)
1141    }
1142
1143    /// Returns the mean (average) close price of the last `n` bars, or `None` if empty.
1144    ///
1145    /// If `n > self.len()`, all bars are used.
1146    pub fn mean_close(&self, n: usize) -> Option<Decimal> {
1147        let start = self.bars.len().saturating_sub(n);
1148        let slice = &self.bars[start..];
1149        if slice.is_empty() {
1150            return None;
1151        }
1152        let sum: Decimal = slice.iter().map(|b| b.close.value()).sum();
1153        Some(sum / Decimal::from(slice.len() as u64))
1154    }
1155
1156    /// Returns the population standard deviation of close prices over the last `n` bars.
1157    ///
1158    /// Returns `None` if fewer than 2 bars are in the window.
1159    /// If `n > self.len()`, all bars are used.
1160    pub fn std_dev(&self, n: usize) -> Option<Decimal> {
1161        let start = self.bars.len().saturating_sub(n);
1162        let slice = &self.bars[start..];
1163        if slice.len() < 2 {
1164            return None;
1165        }
1166        let n_dec = Decimal::from(slice.len() as u64);
1167        let mean: Decimal = slice.iter().map(|b| b.close.value()).sum::<Decimal>() / n_dec;
1168        let variance: Decimal = slice
1169            .iter()
1170            .map(|b| { let d = b.close.value() - mean; d * d })
1171            .sum::<Decimal>()
1172            / n_dec;
1173        decimal_sqrt(variance).ok()
1174    }
1175
1176    /// Returns the median close price of the last `n` bars, or `None` if empty.
1177    ///
1178    /// If `n > self.len()`, all bars are used. For an even number of bars the
1179    /// median is the average of the two middle values.
1180    pub fn median_close(&self, n: usize) -> Option<Decimal> {
1181        let start = self.bars.len().saturating_sub(n);
1182        let mut closes: Vec<Decimal> =
1183            self.bars[start..].iter().map(|b| b.close.value()).collect();
1184        if closes.is_empty() {
1185            return None;
1186        }
1187        closes.sort();
1188        let mid = closes.len() / 2;
1189        if closes.len() % 2 == 1 {
1190            Some(closes[mid])
1191        } else {
1192            Some((closes[mid - 1] + closes[mid]) / Decimal::TWO)
1193        }
1194    }
1195
1196    /// Returns what percentile `value` is among the last `n` close prices (0–100).
1197    ///
1198    /// Counts the fraction of bars in the window whose close is strictly less than `value`,
1199    /// then multiplies by 100. Returns `None` if the window is empty.
1200    /// If `n > self.len()`, all bars are used.
1201    pub fn percentile_rank(&self, value: Decimal, n: usize) -> Option<Decimal> {
1202        let start = self.bars.len().saturating_sub(n);
1203        let slice = &self.bars[start..];
1204        if slice.is_empty() {
1205            return None;
1206        }
1207        let below = slice.iter().filter(|b| b.close.value() < value).count();
1208        Some(Decimal::from(below as u64) / Decimal::from(slice.len() as u64) * Decimal::ONE_HUNDRED)
1209    }
1210
1211    /// Computes Pearson correlation between this series' close prices and `other`'s.
1212    ///
1213    /// Uses only the overlapping suffix: `min(self.len(), other.len())` bars from the end.
1214    /// Returns `None` when fewer than 2 overlapping bars exist or standard deviation is zero.
1215    pub fn correlation(&self, other: &OhlcvSeries) -> Option<Decimal> {
1216        let n = self.bars.len().min(other.bars.len());
1217        if n < 2 {
1218            return None;
1219        }
1220        let xs: Vec<Decimal> = self.bars[self.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1221        let ys: Vec<Decimal> = other.bars[other.bars.len() - n..].iter().map(|b| b.close.value()).collect();
1222        let n_dec = Decimal::from(n);
1223        let mean_x: Decimal = xs.iter().copied().sum::<Decimal>() / n_dec;
1224        let mean_y: Decimal = ys.iter().copied().sum::<Decimal>() / n_dec;
1225        let cov: Decimal = xs.iter().zip(ys.iter())
1226            .map(|(x, y)| (*x - mean_x) * (*y - mean_y))
1227            .sum::<Decimal>() / n_dec;
1228        let var_x: Decimal = xs.iter().map(|x| (*x - mean_x) * (*x - mean_x)).sum::<Decimal>() / n_dec;
1229        let var_y: Decimal = ys.iter().map(|y| (*y - mean_y) * (*y - mean_y)).sum::<Decimal>() / n_dec;
1230        if var_x.is_zero() || var_y.is_zero() {
1231            return None;
1232        }
1233        // sqrt via Newton-Raphson (same approach as BollingerB)
1234        let std_x = decimal_sqrt(var_x).ok()?;
1235        let std_y = decimal_sqrt(var_y).ok()?;
1236        Some(cov / (std_x * std_y))
1237    }
1238
1239    /// Returns rolling SMA of close prices with the given `period`.
1240    ///
1241    /// The output `Vec` has the same length as the series. Positions where fewer than
1242    /// `period` bars have been seen contain `None`; the rest contain `Some(sma)`.
1243    pub fn rolling_sma(&self, period: usize) -> Vec<Option<Decimal>> {
1244        if period == 0 {
1245            return self.bars.iter().map(|_| None).collect();
1246        }
1247        let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1248        closes
1249            .windows(period)
1250            .enumerate()
1251            .fold(vec![None; closes.len()], |mut acc, (i, window)| {
1252                let sum: Decimal = window.iter().copied().sum();
1253                acc[i + period - 1] = Some(sum / Decimal::from(period as u64));
1254                acc
1255            })
1256    }
1257
1258    /// Returns rolling z-score of close prices using a window of `period` bars.
1259    ///
1260    /// `z = (close - SMA) / stddev`. Positions with insufficient data or zero stddev
1261    /// yield `None`.
1262    pub fn zscore(&self, period: usize) -> Vec<Option<Decimal>> {
1263        if period < 2 {
1264            return self.bars.iter().map(|_| None).collect();
1265        }
1266        let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1267        let n = closes.len();
1268        let mut result = vec![None; n];
1269        let period_dec = Decimal::from(period as u64);
1270        for i in (period - 1)..n {
1271            let window = &closes[(i + 1 - period)..=i];
1272            let mean: Decimal = window.iter().copied().sum::<Decimal>() / period_dec;
1273            let variance: Decimal = window
1274                .iter()
1275                .map(|x| (*x - mean) * (*x - mean))
1276                .sum::<Decimal>()
1277                / period_dec;
1278            if let Ok(std_dev) = decimal_sqrt(variance) {
1279                if !std_dev.is_zero() {
1280                    result[i] = Some((closes[i] - mean) / std_dev);
1281                }
1282            }
1283        }
1284        result
1285    }
1286
1287    /// Returns log returns: `ln(close[i] / close[i-1])` for each consecutive bar pair.
1288    ///
1289    /// Returns an empty `Vec` when fewer than 2 bars are present.
1290    /// Bars where `close[i-1]` is zero are skipped (yielding no entry at that position).
1291    ///
1292    /// Uses `f64` arithmetic since `rust_decimal` does not provide a logarithm function.
1293    #[allow(clippy::cast_precision_loss)]
1294    pub fn log_returns(&self) -> Vec<f64> {
1295        if self.bars.len() < 2 {
1296            return Vec::new();
1297        }
1298        self.bars
1299            .windows(2)
1300            .filter_map(|w| {
1301                let prev = w[0].close.value();
1302                if prev.is_zero() {
1303                    return None;
1304                }
1305                let ratio = w[1].close.value().checked_div(prev)?;
1306                use rust_decimal::prelude::ToPrimitive;
1307                let ratio_f64 = ratio.to_f64()?;
1308                if ratio_f64 > 0.0 {
1309                    Some(ratio_f64.ln())
1310                } else {
1311                    None
1312                }
1313            })
1314            .collect()
1315    }
1316
1317    /// Returns compounded cumulative returns relative to the first bar's close.
1318    ///
1319    /// `cumret[i] = (close[i] / close[0]) - 1`
1320    ///
1321    /// Returns an empty `Vec` when the series is empty or the first close is zero.
1322    pub fn cumulative_returns(&self) -> Vec<Decimal> {
1323        let first = match self.bars.first() {
1324            Some(b) => b.close.value(),
1325            None => return Vec::new(),
1326        };
1327        if first.is_zero() {
1328            return Vec::new();
1329        }
1330        self.bars
1331            .iter()
1332            .map(|b| b.close.value() / first - Decimal::ONE)
1333            .collect()
1334    }
1335
1336    /// Resamples the series by merging every `n` consecutive bars into one.
1337    ///
1338    /// Trailing bars that don't fill a full group of `n` are merged into the last output bar.
1339    /// Returns an empty `Vec` when `n == 0` or the series is empty.
1340    ///
1341    /// # Errors
1342    /// Returns [`FinError::BarInvariant`] if any merged bar fails invariant checks.
1343    pub fn resample(&self, n: usize) -> Result<Vec<OhlcvBar>, FinError> {
1344        if n == 0 || self.bars.is_empty() {
1345            return Ok(Vec::new());
1346        }
1347        let mut result = Vec::new();
1348        let mut chunks = self.bars.chunks(n);
1349        for chunk in &mut chunks {
1350            let mut merged = chunk[0].clone();
1351            for b in &chunk[1..] {
1352                merged = merged.merge(b)?;
1353            }
1354            result.push(merged);
1355        }
1356        Ok(result)
1357    }
1358
1359    /// Returns the maximum peak-to-trough drawdown on close prices.
1360    ///
1361    /// Iterates through close prices, tracking the running peak and computing
1362    /// the largest percentage decline from any peak to any subsequent trough.
1363    ///
1364    /// Returns `None` when the series is empty. Returns `0` when no decline occurs.
1365    pub fn max_drawdown(&self) -> Option<Decimal> {
1366        let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
1367        if closes.is_empty() {
1368            return None;
1369        }
1370        let mut peak = closes[0];
1371        let mut max_dd = Decimal::ZERO;
1372        for &c in &closes[1..] {
1373            if c > peak {
1374                peak = c;
1375            } else if !peak.is_zero() {
1376                let dd = (peak - c) / peak;
1377                if dd > max_dd {
1378                    max_dd = dd;
1379                }
1380            }
1381        }
1382        Some(max_dd)
1383    }
1384
1385    /// Computes the annualized Sharpe ratio from log returns.
1386    ///
1387    /// `Sharpe = (mean_log_return - risk_free_rate_per_bar) / stddev_log_return * sqrt(bars_per_year)`
1388    ///
1389    /// `bars_per_year` defaults to 252 (US equity trading days). Pass `0.0` for `risk_free_rate`
1390    /// when working with intraday or crypto series where a risk-free benchmark is not applicable.
1391    ///
1392    /// Returns `None` when fewer than 2 bars exist or if log-return standard deviation is zero.
1393    pub fn sharpe_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1394        let lr = self.log_returns();
1395        if lr.len() < 2 {
1396            return None;
1397        }
1398        let n = lr.len() as f64;
1399        let mean = lr.iter().sum::<f64>() / n;
1400        let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
1401        let std_dev = variance.sqrt();
1402        if std_dev == 0.0 {
1403            return None;
1404        }
1405        let bars_per_year = if bars_per_year <= 0.0 { 252.0 } else { bars_per_year };
1406        Some((mean - risk_free_rate) / std_dev * bars_per_year.sqrt())
1407    }
1408
1409    /// Returns the percentage price change from `n` bars ago to the latest close.
1410    ///
1411    /// `(last_close - close[len-1-n]) / close[len-1-n] * 100`
1412    ///
1413    /// Returns `None` when the series has fewer than `n + 1` bars or the reference
1414    /// close is zero.
1415    pub fn price_change_pct(&self, n: usize) -> Option<Decimal> {
1416        let len = self.bars.len();
1417        if len < n + 1 {
1418            return None;
1419        }
1420        let ref_close = self.bars[len - 1 - n].close.value();
1421        if ref_close.is_zero() {
1422            return None;
1423        }
1424        let last_close = self.bars[len - 1].close.value();
1425        Some((last_close - ref_close) / ref_close * Decimal::ONE_HUNDRED)
1426    }
1427
1428    /// Returns the count of bullish bars in the last `n` bars.
1429    ///
1430    /// A bar is bullish when `close >= open`. If `n` exceeds the series length,
1431    /// all bars are counted.
1432    pub fn count_bullish(&self, n: usize) -> usize {
1433        let start = self.bars.len().saturating_sub(n);
1434        self.bars[start..].iter().filter(|b| b.is_bullish()).count()
1435    }
1436
1437    /// Returns the count of bearish bars in the last `n` bars.
1438    ///
1439    /// A bar is bearish when `close < open`. If `n` exceeds the series length,
1440    /// all bars are counted.
1441    pub fn count_bearish(&self, n: usize) -> usize {
1442        let start = self.bars.len().saturating_sub(n);
1443        self.bars[start..].iter().filter(|b| b.is_bearish()).count()
1444    }
1445
1446    /// Returns the count of inside bars in the entire series.
1447    ///
1448    /// An inside bar has a lower high and higher low than the previous bar,
1449    /// indicating consolidation. The first bar is never counted (no prior bar).
1450    pub fn count_inside_bars(&self) -> usize {
1451        self.bars
1452            .windows(2)
1453            .filter(|w| w[1].is_inside_bar(&w[0]))
1454            .count()
1455    }
1456
1457    /// Returns the count of outside bars in the entire series.
1458    ///
1459    /// An outside bar completely contains the prior bar's range.
1460    /// The first bar is never counted (no prior bar).
1461    pub fn count_outside_bars(&self) -> usize {
1462        self.bars
1463            .windows(2)
1464            .filter(|w| w[1].is_outside_bar(&w[0]))
1465            .count()
1466    }
1467
1468    /// Returns the indices of pivot highs — bars whose high is strictly greater than
1469    /// the `n` bars on each side.
1470    ///
1471    /// A pivot high at index `i` satisfies:
1472    /// `bars[i].high > bars[i-j].high` and `bars[i].high > bars[i+j].high` for all `j` in `1..=n`.
1473    ///
1474    /// Bars within `n` of either end of the series are excluded.
1475    pub fn pivot_highs(&self, n: usize) -> Vec<usize> {
1476        if n == 0 || self.bars.len() < 2 * n + 1 {
1477            return vec![];
1478        }
1479        let mut pivots = Vec::new();
1480        for i in n..self.bars.len() - n {
1481            let h = self.bars[i].high.value();
1482            let is_pivot = (1..=n).all(|j| {
1483                h > self.bars[i - j].high.value() && h > self.bars[i + j].high.value()
1484            });
1485            if is_pivot {
1486                pivots.push(i);
1487            }
1488        }
1489        pivots
1490    }
1491
1492    /// Returns the indices of pivot lows — bars whose low is strictly less than
1493    /// the `n` bars on each side.
1494    ///
1495    /// A pivot low at index `i` satisfies:
1496    /// `bars[i].low < bars[i-j].low` and `bars[i].low < bars[i+j].low` for all `j` in `1..=n`.
1497    ///
1498    /// Bars within `n` of either end of the series are excluded.
1499    pub fn pivot_lows(&self, n: usize) -> Vec<usize> {
1500        if n == 0 || self.bars.len() < 2 * n + 1 {
1501            return vec![];
1502        }
1503        let mut pivots = Vec::new();
1504        for i in n..self.bars.len() - n {
1505            let l = self.bars[i].low.value();
1506            let is_pivot = (1..=n).all(|j| {
1507                l < self.bars[i - j].low.value() && l < self.bars[i + j].low.value()
1508            });
1509            if is_pivot {
1510                pivots.push(i);
1511            }
1512        }
1513        pivots
1514    }
1515
1516    /// Returns the count of bars (in the last `n`) where `close > SMA(close, period)`.
1517    ///
1518    /// If `n` exceeds the series length, all eligible bars are considered.
1519    /// Returns `0` if there are fewer than `period` bars (SMA cannot be computed).
1520    #[allow(clippy::cast_possible_truncation)]
1521    pub fn above_sma(&self, period: usize, n: usize) -> usize {
1522        if self.bars.len() < period || period == 0 {
1523            return 0;
1524        }
1525        let start = self.bars.len().saturating_sub(n);
1526        let window_start = start.saturating_sub(period - 1);
1527        let mut count = 0usize;
1528        for i in start..self.bars.len() {
1529            if i + 1 < period {
1530                continue;
1531            }
1532            let sma_start = i + 1 - period;
1533            let sma: Decimal = self.bars[sma_start..=i]
1534                .iter()
1535                .map(|b| b.close.value())
1536                .sum::<Decimal>()
1537                / Decimal::from(period as u32);
1538            if self.bars[i].close.value() > sma {
1539                count += 1;
1540            }
1541        }
1542        let _ = window_start; // used indirectly via sma_start logic
1543        count
1544    }
1545
1546    /// Returns the count of bars (in the last `n`) where `close < SMA(close, period)`.
1547    ///
1548    /// Mirrors [`OhlcvSeries::above_sma`] for the bearish side.
1549    #[allow(clippy::cast_possible_truncation)]
1550    pub fn below_sma(&self, period: usize, n: usize) -> usize {
1551        if self.bars.len() < period || period == 0 {
1552            return 0;
1553        }
1554        let start = self.bars.len().saturating_sub(n);
1555        let mut count = 0usize;
1556        for i in start..self.bars.len() {
1557            if i + 1 < period {
1558                continue;
1559            }
1560            let sma_start = i + 1 - period;
1561            let sma: Decimal = self.bars[sma_start..=i]
1562                .iter()
1563                .map(|b| b.close.value())
1564                .sum::<Decimal>()
1565                / Decimal::from(period as u32);
1566            if self.bars[i].close.value() < sma {
1567                count += 1;
1568            }
1569        }
1570        count
1571    }
1572
1573    /// Returns `true` if the latest close is above the EMA(period) of closes.
1574    ///
1575    /// Returns `false` if there are fewer than `period` bars or `period == 0`.
1576    #[allow(clippy::cast_possible_truncation)]
1577    pub fn above_ema(&self, period: usize) -> bool {
1578        if period == 0 || self.bars.len() < period {
1579            return false;
1580        }
1581        let k = Decimal::TWO / Decimal::from((period + 1) as u32);
1582        let seed: Decimal = self.bars[..period].iter().map(|b| b.close.value()).sum::<Decimal>()
1583            / Decimal::from(period as u32);
1584        let mut ema = seed;
1585        for bar in &self.bars[period..] {
1586            ema = bar.close.value() * k + ema * (Decimal::ONE - k);
1587        }
1588        self.bars.last().map_or(false, |b| b.close.value() > ema)
1589    }
1590
1591    /// Returns the count of bullish engulfing patterns in the last `n` bars.
1592    ///
1593    /// A bullish engulfing occurs when a bar's body fully engulfs the previous bar's
1594    /// body and the bar closes higher than it opens.
1595    pub fn bullish_engulfing_count(&self, n: usize) -> usize {
1596        if self.bars.len() < 2 {
1597            return 0;
1598        }
1599        let start = self.bars.len().saturating_sub(n).max(1);
1600        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1601            let prev = &self.bars[start + i - 1];
1602            bar.is_bullish_engulfing(prev)
1603        }).count()
1604    }
1605
1606    /// Returns the ratio of the current bar's range to the average range over the last `n` bars.
1607    ///
1608    /// Values > 1 indicate range expansion; < 1 indicate contraction.
1609    /// Returns `None` if fewer than `n` bars exist, `n == 0`, or average range is zero.
1610    pub fn range_expansion(&self, n: usize) -> Option<Decimal> {
1611        let last = self.bars.last()?;
1612        if n == 0 || self.bars.len() < n {
1613            return None;
1614        }
1615        let start = self.bars.len() - n;
1616        let avg_range: Decimal = self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>();
1617        #[allow(clippy::cast_possible_truncation)]
1618        let avg_range = avg_range / Decimal::from(n as u32);
1619        if avg_range == Decimal::ZERO {
1620            return None;
1621        }
1622        Some(last.range() / avg_range)
1623    }
1624
1625    /// Returns the count of bearish engulfing patterns in the last `n` bars.
1626    ///
1627    /// A bearish engulfing bar opens above the previous close and closes below the previous open.
1628    pub fn bearish_engulfing_count(&self, n: usize) -> usize {
1629        if self.bars.len() < 2 {
1630            return 0;
1631        }
1632        let start = self.bars.len().saturating_sub(n).max(1);
1633        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1634            let prev = &self.bars[start + i - 1];
1635            // bearish: prev bullish, current opens above prev close, closes below prev open
1636            let p_o = prev.open.value();
1637            let p_c = prev.close.value();
1638            let s_o = bar.open.value();
1639            let s_c = bar.close.value();
1640            p_c > p_o && s_c < s_o && s_o >= p_c && s_c <= p_o
1641        }).count()
1642    }
1643
1644    /// Returns a trend-strength ratio over the last `n` bars.
1645    ///
1646    /// `trend_strength = |close[last] - close[first]| / Σ|close[i] - close[i-1]|`
1647    ///
1648    /// Values near 1 indicate a clean directional trend; near 0 indicate chop.
1649    /// Returns `None` if fewer than 2 bars exist in the window or total movement is zero.
1650    pub fn trend_strength(&self, n: usize) -> Option<Decimal> {
1651        if n < 2 || self.bars.len() < n {
1652            return None;
1653        }
1654        let start = self.bars.len() - n;
1655        let window = &self.bars[start..];
1656        let net = (window.last()?.close.value() - window[0].close.value()).abs();
1657        let total: Decimal = window.windows(2)
1658            .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1659            .sum();
1660        if total == Decimal::ZERO {
1661            return None;
1662        }
1663        Some(net / total)
1664    }
1665
1666    /// Returns the average volume over the last `n` bars, or `None` if the series is empty.
1667    ///
1668    /// Returns the average `(close - open) / open` per bar over the last `n` bars.
1669    ///
1670    /// Returns `None` if fewer than `n` bars exist, `n == 0`, or any open is zero.
1671    pub fn open_to_close_return(&self, n: usize) -> Option<Decimal> {
1672        if n == 0 || self.bars.len() < n {
1673            return None;
1674        }
1675        let start = self.bars.len() - n;
1676        let mut sum = Decimal::ZERO;
1677        for b in &self.bars[start..] {
1678            let o = b.open.value();
1679            if o == Decimal::ZERO {
1680                return None;
1681            }
1682            sum += (b.close.value() - o) / o;
1683        }
1684        #[allow(clippy::cast_possible_truncation)]
1685        Some(sum / Decimal::from(n as u32))
1686    }
1687
1688    /// Returns the count of bars in the last `n` where `open > prev_close` (gap up).
1689    pub fn gap_up_count(&self, n: usize) -> usize {
1690        if self.bars.len() < 2 {
1691            return 0;
1692        }
1693        let start = self.bars.len().saturating_sub(n).max(1);
1694        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1695            bar.open.value() > self.bars[start + i - 1].close.value()
1696        }).count()
1697    }
1698
1699    /// Returns the count of bars in the last `n` where `open < prev_close` (gap down).
1700    pub fn gap_down_count(&self, n: usize) -> usize {
1701        if self.bars.len() < 2 {
1702            return 0;
1703        }
1704        let start = self.bars.len().saturating_sub(n).max(1);
1705        self.bars[start..].iter().enumerate().filter(|(i, bar)| {
1706            bar.open.value() < self.bars[start + i - 1].close.value()
1707        }).count()
1708    }
1709
1710    /// Returns the average overnight gap percentage over the last `n` bars.
1711    ///
1712    /// `overnight_gap_pct = (open - prev_close) / prev_close × 100`
1713    ///
1714    /// Returns `None` if fewer than 2 bars in window, `n == 0`, or any prev_close is zero.
1715    pub fn overnight_gap_pct(&self, n: usize) -> Option<Decimal> {
1716        if n == 0 || self.bars.len() < 2 {
1717            return None;
1718        }
1719        let start = self.bars.len().saturating_sub(n).max(1);
1720        let window_len = self.bars.len() - start;
1721        if window_len == 0 {
1722            return None;
1723        }
1724        let mut sum = Decimal::ZERO;
1725        for i in start..self.bars.len() {
1726            let pc = self.bars[i - 1].close.value();
1727            if pc == Decimal::ZERO {
1728                return None;
1729            }
1730            sum += (self.bars[i].open.value() - pc) / pc * Decimal::ONE_HUNDRED;
1731        }
1732        #[allow(clippy::cast_possible_truncation)]
1733        Some(sum / Decimal::from(window_len as u32))
1734    }
1735
1736    /// Returns the percentile rank (0–100) of the latest close within the last `n` closes.
1737    ///
1738    /// `close_rank = count(closes < current) / (n-1) × 100`
1739    ///
1740    /// Returns `None` if fewer than 2 bars in window or `n == 0`.
1741    pub fn close_rank(&self, n: usize) -> Option<Decimal> {
1742        if n < 2 || self.bars.len() < n {
1743            return None;
1744        }
1745        let start = self.bars.len() - n;
1746        let current = self.bars.last()?.close.value();
1747        let below = self.bars[start..self.bars.len() - 1]
1748            .iter()
1749            .filter(|b| b.close.value() < current)
1750            .count();
1751        #[allow(clippy::cast_possible_truncation)]
1752        Some(Decimal::from(below as u32) / Decimal::from((n - 1) as u32) * Decimal::ONE_HUNDRED)
1753    }
1754
1755    /// Returns `highest_high(n) / lowest_low(n)` over the last `n` bars.
1756    ///
1757    /// Returns `None` if fewer than `n` bars, `n == 0`, or lowest_low is zero.
1758    pub fn high_low_ratio(&self, n: usize) -> Option<Decimal> {
1759        if n == 0 || self.bars.len() < n {
1760            return None;
1761        }
1762        let hh = self.highest_high(n)?;
1763        let ll = self.lowest_low(n)?;
1764        if ll == Decimal::ZERO {
1765            return None;
1766        }
1767        Some(hh / ll)
1768    }
1769
1770    /// If `n` exceeds the series length, all bars are included.
1771    #[allow(clippy::cast_possible_truncation)]
1772    pub fn average_volume(&self, n: usize) -> Option<Decimal> {
1773        let start = self.bars.len().saturating_sub(n);
1774        let slice = &self.bars[start..];
1775        if slice.is_empty() {
1776            return None;
1777        }
1778        let sum: Decimal = slice.iter().map(|b| b.volume.value()).sum();
1779        Some(sum / Decimal::from(slice.len() as u32))
1780    }
1781
1782    /// Returns the close prices for the last `n` bars in chronological order.
1783    ///
1784    /// Returns fewer than `n` values if the series is shorter.
1785    pub fn last_n_closes(&self, n: usize) -> Vec<Decimal> {
1786        let start = self.bars.len().saturating_sub(n);
1787        self.bars[start..].iter().map(|b| b.close.value()).collect()
1788    }
1789
1790    /// Returns `true` if the last bar's volume exceeds the average of the prior `n` bars
1791    /// multiplied by `multiplier`.
1792    ///
1793    /// Returns `false` if there are fewer than 2 bars or `multiplier` is zero.
1794    pub fn volume_spike(&self, n: usize, multiplier: Decimal) -> bool {
1795        if self.bars.len() < 2 || multiplier.is_zero() {
1796            return false;
1797        }
1798        let last_vol = self.bars.last().unwrap().volume.value();
1799        // average of all bars except the last one (up to n bars)
1800        let prior_count = self.bars.len() - 1;
1801        let start = prior_count.saturating_sub(n);
1802        let prior = &self.bars[start..prior_count];
1803        if prior.is_empty() {
1804            return false;
1805        }
1806        let avg: Decimal = prior.iter().map(|b| b.volume.value()).sum::<Decimal>()
1807            / Decimal::from(prior.len() as u32);
1808        last_vol > avg * multiplier
1809    }
1810
1811    /// Returns the average bar range (high − low) over the last `n` bars, or `None` if empty.
1812    ///
1813    /// If `n` exceeds the series length, all bars are included.
1814    #[allow(clippy::cast_possible_truncation)]
1815    pub fn average_range(&self, n: usize) -> Option<Decimal> {
1816        let start = self.bars.len().saturating_sub(n);
1817        let slice = &self.bars[start..];
1818        if slice.is_empty() {
1819            return None;
1820        }
1821        let sum: Decimal = slice.iter().map(|b| b.range()).sum();
1822        Some(sum / Decimal::from(slice.len() as u32))
1823    }
1824
1825    /// Returns the mean of typical prices `(high + low + close) / 3` over the last `n` bars.
1826    ///
1827    /// Returns `None` if the series is empty.
1828    #[allow(clippy::cast_possible_truncation)]
1829    pub fn typical_price_mean(&self, n: usize) -> Option<Decimal> {
1830        let start = self.bars.len().saturating_sub(n);
1831        let slice = &self.bars[start..];
1832        if slice.is_empty() {
1833            return None;
1834        }
1835        let sum: Decimal = slice.iter().map(|b| b.typical_price()).sum();
1836        Some(sum / Decimal::from(slice.len() as u32))
1837    }
1838
1839    /// Returns the Sortino ratio using bar log-returns.
1840    ///
1841    /// Only negative returns contribute to the downside deviation denominator.
1842    /// Returns `None` if there are fewer than 2 bars or if downside deviation is zero.
1843    pub fn sortino_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
1844        let log_rets = self.log_returns();
1845        if log_rets.len() < 2 {
1846            return None;
1847        }
1848        let mean_ret = log_rets.iter().copied().sum::<f64>() / log_rets.len() as f64;
1849        let downside: Vec<f64> = log_rets.iter().map(|&r| if r < 0.0 { r * r } else { 0.0 }).collect();
1850        let downside_var = downside.iter().copied().sum::<f64>() / downside.len() as f64;
1851        let downside_dev = downside_var.sqrt();
1852        if downside_dev == 0.0 {
1853            return None;
1854        }
1855        let rf_per_bar = risk_free_rate / bars_per_year;
1856        Some((mean_ret - rf_per_bar) / downside_dev * bars_per_year.sqrt())
1857    }
1858
1859    /// Returns the number of consecutive bullish bars (close > open) counting from the end.
1860    ///
1861    /// Returns 0 if the series is empty or the last bar is not bullish.
1862    pub fn close_above_open_streak(&self) -> usize {
1863        self.bars
1864            .iter()
1865            .rev()
1866            .take_while(|b| b.close.value() > b.open.value())
1867            .count()
1868    }
1869
1870    /// Returns the maximum peak-to-trough drawdown percentage over the last `n` bars.
1871    ///
1872    /// Computed on close prices: scans for the largest `(peak - trough) / peak * 100`.
1873    /// Returns `None` if fewer than 2 bars are available in the window.
1874    pub fn max_drawdown_pct(&self, n: usize) -> Option<f64> {
1875        let window: Vec<f64> = self
1876            .bars
1877            .iter()
1878            .rev()
1879            .take(n)
1880            .map(|b| b.close.value().to_string().parse::<f64>().unwrap_or(0.0))
1881            .collect::<Vec<_>>()
1882            .into_iter()
1883            .rev()
1884            .collect();
1885        if window.len() < 2 {
1886            return None;
1887        }
1888        let mut max_dd = 0.0f64;
1889        let mut peak = window[0];
1890        for &price in &window[1..] {
1891            if price > peak {
1892                peak = price;
1893            }
1894            if peak > 0.0 {
1895                let dd = (peak - price) / peak * 100.0;
1896                if dd > max_dd {
1897                    max_dd = dd;
1898                }
1899            }
1900        }
1901        Some(max_dd)
1902    }
1903
1904    /// Returns the Average True Range for each bar as `Vec<Option<Decimal>>`.
1905    ///
1906    /// Uses a simple rolling average of True Range over `period` bars.
1907    /// The first `period - 1` entries are `None`; the rest are `Some(atr)`.
1908    #[allow(clippy::cast_possible_truncation)]
1909    pub fn atr_series(&self, period: usize) -> Vec<Option<Decimal>> {
1910        let n = self.bars.len();
1911        let mut result = vec![None; n];
1912        if period == 0 || n == 0 {
1913            return result;
1914        }
1915        let trs: Vec<Decimal> = self
1916            .bars
1917            .iter()
1918            .enumerate()
1919            .map(|(i, b)| {
1920                let prev = if i == 0 { None } else { Some(&self.bars[i - 1]) };
1921                b.true_range(prev)
1922            })
1923            .collect();
1924        for i in (period - 1)..n {
1925            let sum: Decimal = trs[i + 1 - period..=i].iter().copied().sum();
1926            result[i] = Some(sum / Decimal::from(period as u32));
1927        }
1928        result
1929    }
1930
1931    /// Returns the count of bars (in the last `n`) where `close > prev_close`.
1932    ///
1933    /// If `n` exceeds the series length, all eligible bars are counted.
1934    /// The first bar in the series is never an "up day" (no prior bar).
1935    pub fn up_days(&self, n: usize) -> usize {
1936        if self.bars.len() < 2 {
1937            return 0;
1938        }
1939        let start = self.bars.len().saturating_sub(n).max(1);
1940        self.bars[start..]
1941            .iter()
1942            .enumerate()
1943            .filter(|(i, b)| b.close.value() > self.bars[start + i - 1].close.value())
1944            .count()
1945    }
1946
1947    /// Returns the count of bars (in the last `n`) where `close < prev_close`.
1948    ///
1949    /// Mirrors [`OhlcvSeries::up_days`] for the downside.
1950    pub fn down_days(&self, n: usize) -> usize {
1951        if self.bars.len() < 2 {
1952            return 0;
1953        }
1954        let start = self.bars.len().saturating_sub(n).max(1);
1955        self.bars[start..]
1956            .iter()
1957            .enumerate()
1958            .filter(|(i, b)| b.close.value() < self.bars[start + i - 1].close.value())
1959            .count()
1960    }
1961
1962    /// Returns the per-bar range (`high - low`) as a `Vec<Decimal>`.
1963    ///
1964    /// One value per bar; empty if the series is empty.
1965    pub fn range_series(&self) -> Vec<Decimal> {
1966        self.bars.iter().map(|b| b.range()).collect()
1967    }
1968
1969    /// Returns absolute close-to-close changes: `|close[i] - close[i-1]|` for each bar.
1970    ///
1971    /// The result has `len() - 1` entries (first bar has no previous bar).
1972    /// Empty when the series has fewer than 2 bars.
1973    pub fn close_to_close_changes(&self) -> Vec<Decimal> {
1974        if self.bars.len() < 2 {
1975            return vec![];
1976        }
1977        self.bars
1978            .windows(2)
1979            .map(|w| (w[1].close.value() - w[0].close.value()).abs())
1980            .collect()
1981    }
1982
1983    /// Returns the ratio of short-period ATR to long-period ATR.
1984    ///
1985    /// A ratio > 1 means recent volatility is higher than the longer baseline;
1986    /// < 1 means it is lower. Returns `None` if either ATR value is unavailable
1987    /// (series too short) or if the long-period ATR is zero.
1988    pub fn volatility_ratio(&self, short: usize, long: usize) -> Option<Decimal> {
1989        let n = self.bars.len();
1990        if short == 0 || long == 0 || n == 0 {
1991            return None;
1992        }
1993        let short_atr = *self.atr_series(short).last()?;
1994        let long_atr = *self.atr_series(long).last()?;
1995        let s = short_atr?;
1996        let l = long_atr?;
1997        if l.is_zero() {
1998            return None;
1999        }
2000        Some(s / l)
2001    }
2002
2003    /// Returns the length of the current consecutive close-to-close streak.
2004    ///
2005    /// A positive value means the last N closes were each higher than the prior close
2006    /// (bullish streak). A negative value means consecutive lower closes (bearish streak).
2007    /// Returns `0` when the series has fewer than 2 bars.
2008    ///
2009    /// # Example
2010    /// ```
2011    /// # use fin_primitives::ohlcv::OhlcvSeries;
2012    /// # use fin_primitives::types::{Price, Quantity, Symbol, NanoTimestamp};
2013    /// # use fin_primitives::ohlcv::OhlcvBar;
2014    /// # fn bar(close: f64) -> OhlcvBar {
2015    /// #     let p = Price::new(close.to_string().parse().unwrap()).unwrap();
2016    /// #     let q = Quantity::new(rust_decimal::Decimal::ONE).unwrap();
2017    /// #     OhlcvBar { symbol: Symbol::new("X").unwrap(), open: p, high: p, low: p, close: p,
2018    /// #                volume: q, ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1 }
2019    /// # }
2020    /// let mut s = OhlcvSeries::new();
2021    /// s.push(bar(10.0)); s.push(bar(11.0)); s.push(bar(12.0));
2022    /// assert_eq!(s.streak(), 2);
2023    /// ```
2024    pub fn streak(&self) -> i32 {
2025        let n = self.bars.len();
2026        if n < 2 {
2027            return 0;
2028        }
2029        let mut count: i32 = 0;
2030        for i in (1..n).rev() {
2031            let prev = self.bars[i - 1].close.value();
2032            let curr = self.bars[i].close.value();
2033            if curr > prev {
2034                if count < 0 {
2035                    break;
2036                }
2037                count += 1;
2038            } else if curr < prev {
2039                if count > 0 {
2040                    break;
2041                }
2042                count -= 1;
2043            } else {
2044                break;
2045            }
2046        }
2047        count
2048    }
2049
2050    /// Returns the Calmar ratio: annualised return divided by maximum drawdown.
2051    ///
2052    /// Annualised return is computed as `mean_log_return * bars_per_year`.
2053    /// Requires at least 2 bars and a non-zero `max_drawdown`.
2054    ///
2055    /// Returns `None` when there is insufficient data or the max drawdown is zero.
2056    pub fn calmar_ratio(&self, bars_per_year: f64) -> Option<f64> {
2057        let lr = self.log_returns();
2058        if lr.len() < 2 {
2059            return None;
2060        }
2061        let ann_return = (lr.iter().sum::<f64>() / lr.len() as f64) * bars_per_year;
2062        let dd = self.max_drawdown()?;
2063        use rust_decimal::prelude::ToPrimitive;
2064        let dd_f64 = dd.to_f64()?;
2065        if dd_f64 == 0.0_f64 {
2066            return None;
2067        }
2068        Some(ann_return / dd_f64)
2069    }
2070
2071    /// Returns `(highest_high, lowest_low)` over the last `n` bars, or `None` if empty.
2072    ///
2073    /// If `n` exceeds the series length, all bars are considered. Provides a convenient
2074    /// way to get both extremes in one call without scanning the series twice.
2075    pub fn session_high_low(&self, n: usize) -> Option<(Decimal, Decimal)> {
2076        let start = self.bars.len().saturating_sub(n);
2077        let slice = &self.bars[start..];
2078        if slice.is_empty() {
2079            return None;
2080        }
2081        let h = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
2082        let l = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
2083        Some((h, l))
2084    }
2085
2086    /// Returns bar-to-bar percentage changes: `(close[i] - close[i-1]) / close[i-1] * 100`.
2087    ///
2088    /// The result has `len() - 1` entries. Returns an empty vec when the series
2089    /// has fewer than 2 bars or when a previous close is zero.
2090    pub fn percentage_change_series(&self) -> Vec<Option<Decimal>> {
2091        if self.bars.len() < 2 {
2092            return vec![];
2093        }
2094        self.bars
2095            .windows(2)
2096            .map(|w| {
2097                let prev_c = w[0].close.value();
2098                if prev_c.is_zero() {
2099                    None
2100                } else {
2101                    Some((w[1].close.value() - prev_c) / prev_c * Decimal::ONE_HUNDRED)
2102                }
2103            })
2104            .collect()
2105    }
2106
2107    /// Realized volatility: standard deviation of log returns over the last `n` bars,
2108    /// annualised by multiplying by `sqrt(bars_per_year)`.
2109    ///
2110    /// Returns `None` if `n == 0` or there are fewer than `n + 1` bars.
2111    pub fn realized_volatility(&self, n: usize, bars_per_year: f64) -> Option<f64> {
2112        if n == 0 || self.bars.len() < n + 1 {
2113            return None;
2114        }
2115        let start = self.bars.len() - n - 1;
2116        let lr: Vec<f64> = self.bars[start..]
2117            .windows(2)
2118            .filter_map(|w| {
2119                let prev = w[0].close.value();
2120                if prev.is_zero() {
2121                    return None;
2122                }
2123                use rust_decimal::prelude::ToPrimitive;
2124                let ratio = (w[1].close.value() / prev).to_f64()?;
2125                Some(ratio.ln())
2126            })
2127            .collect();
2128        if lr.len() < 2 {
2129            return None;
2130        }
2131        let mean = lr.iter().sum::<f64>() / lr.len() as f64;
2132        let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / lr.len() as f64;
2133        Some(variance.sqrt() * bars_per_year.sqrt())
2134    }
2135
2136    /// Pearson correlation of closes between `self` and `other` over the last `n` bars.
2137    ///
2138    /// Returns `None` when either series has fewer than `n` bars, `n < 2`, or
2139    /// either series has zero variance over the window.
2140    pub fn rolling_correlation(&self, other: &OhlcvSeries, n: usize) -> Option<f64> {
2141        if n < 2 || self.bars.len() < n || other.bars.len() < n {
2142            return None;
2143        }
2144        use rust_decimal::prelude::ToPrimitive;
2145        let xs: Vec<f64> = self.bars[self.bars.len() - n..]
2146            .iter()
2147            .filter_map(|b| b.close.value().to_f64())
2148            .collect();
2149        let ys: Vec<f64> = other.bars[other.bars.len() - n..]
2150            .iter()
2151            .filter_map(|b| b.close.value().to_f64())
2152            .collect();
2153        if xs.len() != n || ys.len() != n {
2154            return None;
2155        }
2156        let n_f = n as f64;
2157        let mx = xs.iter().sum::<f64>() / n_f;
2158        let my = ys.iter().sum::<f64>() / n_f;
2159        let cov = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum::<f64>() / n_f;
2160        let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2161        let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2162        if sx == 0.0 || sy == 0.0 {
2163            return None;
2164        }
2165        Some(cov / (sx * sy))
2166    }
2167
2168    /// CAPM beta: `cov(self, market) / var(market)` over the last `n` log-return bars.
2169    ///
2170    /// Returns `None` when either series has fewer than `n + 1` bars, `n < 2`, or
2171    /// the market variance is zero.
2172    pub fn beta(&self, market: &OhlcvSeries, n: usize) -> Option<f64> {
2173        if n < 2 || self.bars.len() < n + 1 || market.bars.len() < n + 1 {
2174            return None;
2175        }
2176        use rust_decimal::prelude::ToPrimitive;
2177        let asset_lr: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2178            .windows(2)
2179            .filter_map(|w| {
2180                let prev = w[0].close.value();
2181                if prev.is_zero() { return None; }
2182                (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2183            })
2184            .collect();
2185        let mkt_lr: Vec<f64> = market.bars[market.bars.len() - n - 1..]
2186            .windows(2)
2187            .filter_map(|w| {
2188                let prev = w[0].close.value();
2189                if prev.is_zero() { return None; }
2190                (w[1].close.value() / prev).to_f64().map(|r| r.ln())
2191            })
2192            .collect();
2193        let len = asset_lr.len().min(mkt_lr.len());
2194        if len < 2 {
2195            return None;
2196        }
2197        let n_f = len as f64;
2198        let ma = asset_lr[..len].iter().sum::<f64>() / n_f;
2199        let mm = mkt_lr[..len].iter().sum::<f64>() / n_f;
2200        let cov = asset_lr[..len].iter().zip(mkt_lr[..len].iter())
2201            .map(|(a, m)| (a - ma) * (m - mm))
2202            .sum::<f64>() / n_f;
2203        let var_m = mkt_lr[..len].iter().map(|m| (m - mm).powi(2)).sum::<f64>() / n_f;
2204        if var_m == 0.0 { return None; }
2205        Some(cov / var_m)
2206    }
2207
2208    /// Information ratio: `(mean_excess_return) / tracking_error` over the last `n` bars.
2209    ///
2210    /// Excess return is `asset_log_return - benchmark_log_return` per bar.
2211    /// Returns `None` when there is insufficient data or tracking error is zero.
2212    pub fn information_ratio(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
2213        if n < 2 || self.bars.len() < n + 1 || benchmark.bars.len() < n + 1 {
2214            return None;
2215        }
2216        use rust_decimal::prelude::ToPrimitive;
2217        let excess: Vec<f64> = self.bars[self.bars.len() - n - 1..]
2218            .windows(2)
2219            .zip(benchmark.bars[benchmark.bars.len() - n - 1..].windows(2))
2220            .filter_map(|(aw, bw)| {
2221                let ap = aw[0].close.value();
2222                let bp = bw[0].close.value();
2223                if ap.is_zero() || bp.is_zero() { return None; }
2224                let ar = (aw[1].close.value() / ap).to_f64()?.ln();
2225                let br = (bw[1].close.value() / bp).to_f64()?.ln();
2226                Some(ar - br)
2227            })
2228            .collect();
2229        if excess.len() < 2 { return None; }
2230        let n_f = excess.len() as f64;
2231        let mean = excess.iter().sum::<f64>() / n_f;
2232        let te = (excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / n_f).sqrt();
2233        if te == 0.0 { return None; }
2234        Some(mean / te)
2235    }
2236
2237    /// Per-bar drawdown series from the rolling high-water mark.
2238    ///
2239    /// Each element is `(rolling_high - close) / rolling_high` expressed as a positive
2240    /// fraction (0 = at new high, 0.1 = 10% below peak). Empty when the series is empty.
2241    pub fn drawdown_series(&self) -> Vec<Decimal> {
2242        if self.bars.is_empty() {
2243            return vec![];
2244        }
2245        let mut peak = Decimal::MIN;
2246        self.bars
2247            .iter()
2248            .map(|b| {
2249                let close = b.close.value();
2250                if close > peak {
2251                    peak = close;
2252                }
2253                if peak.is_zero() {
2254                    Decimal::ZERO
2255                } else {
2256                    (peak - close) / peak
2257                }
2258            })
2259            .collect()
2260    }
2261
2262    /// Returns `true` if the last close is above the SMA of the last `period` closes.
2263    ///
2264    /// Returns `None` when there are fewer than `period` bars or `period == 0`.
2265    pub fn above_moving_average(&self, period: usize) -> Option<bool> {
2266        if period == 0 || self.bars.len() < period {
2267            return None;
2268        }
2269        let start = self.bars.len() - period;
2270        #[allow(clippy::cast_possible_truncation)]
2271        let sma: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2272            / Decimal::from(period as u32);
2273        Some(self.bars.last()?.close.value() > sma)
2274    }
2275
2276    /// Count bars in the last `n` where `high > prev_bar.high` (consecutive higher highs proxy).
2277    ///
2278    /// Returns 0 when the series has fewer than 2 bars or `n == 0`.
2279    pub fn consecutive_higher_highs(&self, n: usize) -> usize {
2280        if n == 0 || self.bars.len() < 2 {
2281            return 0;
2282        }
2283        let start = self.bars.len().saturating_sub(n).max(1);
2284        self.bars[start..]
2285            .iter()
2286            .enumerate()
2287            .filter(|(i, b)| b.high.value() > self.bars[start + i - 1].high.value())
2288            .count()
2289    }
2290
2291    /// Counts bars where each bar's low is strictly below the prior bar's low,
2292    /// looking at the last `n` consecutive bar pairs.
2293    ///
2294    /// Returns `0` when `n == 0` or the series has fewer than 2 bars.
2295    pub fn consecutive_lower_lows(&self, n: usize) -> usize {
2296        if n == 0 || self.bars.len() < 2 {
2297            return 0;
2298        }
2299        let start = self.bars.len().saturating_sub(n).max(1);
2300        self.bars[start..]
2301            .iter()
2302            .enumerate()
2303            .filter(|(i, b)| b.low.value() < self.bars[start + i - 1].low.value())
2304            .count()
2305    }
2306
2307    /// Distance of the latest close from its `n`-bar VWAP, as a percentage of VWAP.
2308    ///
2309    /// `deviation_pct = (close - vwap) / vwap * 100`
2310    ///
2311    /// Returns `None` if `n == 0`, series is shorter than `n`, or total volume is zero.
2312    pub fn vwap_deviation(&self, n: usize) -> Option<Decimal> {
2313        if n == 0 || self.bars.len() < n {
2314            return None;
2315        }
2316        let start = self.bars.len().saturating_sub(n);
2317        let slice = &self.bars[start..];
2318        let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
2319        if total_vol.is_zero() {
2320            return None;
2321        }
2322        let vwap: Decimal = slice.iter()
2323            .map(|b| {
2324                let tp = (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3u32);
2325                tp * b.volume.value()
2326            })
2327            .sum::<Decimal>() / total_vol;
2328        if vwap.is_zero() {
2329            return None;
2330        }
2331        let last_close = self.bars.last()?.close.value();
2332        Some((last_close - vwap) / vwap * Decimal::ONE_HUNDRED)
2333    }
2334
2335    /// ATR as a percentage of the last closing price over the last `n` bars.
2336    ///
2337    /// Computed as `mean(ATR) / close * 100`. Returns `None` if fewer than `n` bars,
2338    /// `n == 0`, or the last close is zero.
2339    pub fn average_true_range_pct(&self, n: usize) -> Option<f64> {
2340        use rust_decimal::prelude::ToPrimitive;
2341        if n == 0 || self.bars.len() < n {
2342            return None;
2343        }
2344        let atrs = self.atr_series(n);
2345        let last_close = self.bars.last()?.close.value();
2346        if last_close.is_zero() {
2347            return None;
2348        }
2349        let atr = (*atrs.last()?.as_ref()?).to_f64()?;
2350        let close_f64 = last_close.to_f64()?;
2351        Some(atr / close_f64 * 100.0)
2352    }
2353
2354    /// Count bars in the last `n` that are doji candles (body ≤ `threshold` × range).
2355    ///
2356    /// Delegates to [`OhlcvBar::is_doji`] for each bar.
2357    pub fn count_doji(&self, n: usize, threshold: Decimal) -> usize {
2358        if n == 0 {
2359            return 0;
2360        }
2361        let start = self.bars.len().saturating_sub(n);
2362        self.bars[start..].iter().filter(|b| b.is_doji(threshold)).count()
2363    }
2364
2365    /// Counts bars in the last `n` where `open > prev_close` (gap-up).
2366    ///
2367    /// Returns `0` if the series has fewer than 2 bars or `n == 0`.
2368    pub fn gap_up_bars(&self, n: usize) -> usize {
2369        if n == 0 || self.bars.len() < 2 {
2370            return 0;
2371        }
2372        let start = self.bars.len().saturating_sub(n + 1);
2373        self.bars[start..].windows(2).filter(|w| w[1].gap_up_from(&w[0])).count()
2374    }
2375
2376    /// Counts bars in the last `n` where `open < prev_close` (gap-down).
2377    ///
2378    /// Returns `0` if the series has fewer than 2 bars or `n == 0`.
2379    pub fn gap_down_bars(&self, n: usize) -> usize {
2380        if n == 0 || self.bars.len() < 2 {
2381            return 0;
2382        }
2383        let start = self.bars.len().saturating_sub(n + 1);
2384        self.bars[start..].windows(2).filter(|w| w[1].gap_down_from(&w[0])).count()
2385    }
2386
2387    /// Returns the cumulative volume over the last `n` bars.
2388    ///
2389    /// Returns `Decimal::ZERO` if `n == 0` or the series is empty.
2390    pub fn cum_volume(&self, n: usize) -> Decimal {
2391        if n == 0 {
2392            return Decimal::ZERO;
2393        }
2394        let start = self.bars.len().saturating_sub(n);
2395        self.bars[start..].iter().map(|b| b.volume.value()).sum()
2396    }
2397
2398    /// Dual-period momentum score: `(sma_short - sma_long) / sma_long * 100`.
2399    ///
2400    /// Returns `None` when the series has fewer than `long` bars, `short == 0`,
2401    /// `long == 0`, `short >= long`, or the long SMA is zero.
2402    pub fn momentum_score(&self, short: usize, long: usize) -> Option<f64> {
2403        use rust_decimal::prelude::ToPrimitive;
2404        if short == 0 || long == 0 || short >= long || self.bars.len() < long {
2405            return None;
2406        }
2407        #[allow(clippy::cast_possible_truncation)]
2408        let sma = |n: usize| -> Option<Decimal> {
2409            let start = self.bars.len().saturating_sub(n);
2410            let s: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
2411            Some(s / Decimal::from(n as u32))
2412        };
2413        let sma_s = sma(short)?;
2414        let sma_l = sma(long)?;
2415        if sma_l.is_zero() {
2416            return None;
2417        }
2418        ((sma_s - sma_l) / sma_l * Decimal::ONE_HUNDRED).to_f64()
2419    }
2420
2421    /// Returns the first bar in the series, or `None` if empty.
2422    pub fn first_bar(&self) -> Option<&OhlcvBar> {
2423        self.bars.first()
2424    }
2425
2426    /// Volume-weighted close over the last `n` bars: `Σ(close × volume) / Σ(volume)`.
2427    ///
2428    /// Returns `None` when `n == 0`, the series has fewer than `n` bars, or total volume is zero.
2429    pub fn volume_weighted_close(&self, n: usize) -> Option<Decimal> {
2430        if n == 0 || self.bars.len() < n {
2431            return None;
2432        }
2433        let start = self.bars.len() - n;
2434        let vol_sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
2435        if vol_sum.is_zero() {
2436            return None;
2437        }
2438        let pv_sum: Decimal = self.bars[start..]
2439            .iter()
2440            .map(|b| b.close.value() * b.volume.value())
2441            .sum();
2442        Some(pv_sum / vol_sum)
2443    }
2444
2445    /// Last bar range divided by average range over the last `n` bars.
2446    ///
2447    /// Values > 1 indicate volatility expansion; < 1 contraction.
2448    /// Returns `None` when `n == 0`, the series has fewer than `n` bars, or average range is zero.
2449    pub fn range_expansion_ratio(&self, n: usize) -> Option<f64> {
2450        use rust_decimal::prelude::ToPrimitive;
2451        if n == 0 || self.bars.len() < n {
2452            return None;
2453        }
2454        let last_range = self.bars.last()?.range();
2455        let start = self.bars.len() - n;
2456        let avg_range = self.bars[start..]
2457            .iter()
2458            .map(|b| b.range())
2459            .sum::<Decimal>();
2460        #[allow(clippy::cast_possible_truncation)]
2461        let avg = avg_range / Decimal::from(n as u32);
2462        if avg.is_zero() {
2463            return None;
2464        }
2465        (last_range / avg).to_f64()
2466    }
2467
2468    /// Kaufman Efficiency Ratio over the last `n` bars.
2469    ///
2470    /// `ER = |close[end] - close[start]| / Σ|close[i] - close[i-1]|`.
2471    /// Returns `None` if fewer than `n+1` bars or the total path length is zero.
2472    pub fn efficiency_ratio(&self, n: usize) -> Option<Decimal> {
2473        if n == 0 || self.bars.len() <= n {
2474            return None;
2475        }
2476        let start = self.bars.len() - n - 1;
2477        let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2478        let direction = (closes[n] - closes[0]).abs();
2479        let path: Decimal = closes.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
2480        if path.is_zero() {
2481            return None;
2482        }
2483        Some(direction / path)
2484    }
2485
2486    /// Body-size as a percentage of range for the last `n` bars.
2487    ///
2488    /// Each element is `|close - open| / (high - low) * 100`, or `None` when
2489    /// the bar's high equals its low.
2490    pub fn body_pct_series(&self, n: usize) -> Vec<Option<Decimal>> {
2491        let start = self.bars.len().saturating_sub(n);
2492        self.bars[start..]
2493            .iter()
2494            .map(|b| {
2495                let range = b.high.value() - b.low.value();
2496                if range.is_zero() {
2497                    None
2498                } else {
2499                    let body = (b.close.value() - b.open.value()).abs();
2500                    Some(body / range * Decimal::ONE_HUNDRED)
2501                }
2502            })
2503            .collect()
2504    }
2505
2506    /// Count of candle direction changes in the last `n` bars.
2507    ///
2508    /// A change is when the current bar's direction (close ≥ open vs close < open)
2509    /// differs from the previous bar. Returns `0` if fewer than 2 bars available.
2510    pub fn candle_color_changes(&self, n: usize) -> usize {
2511        let start = self.bars.len().saturating_sub(n);
2512        let slice = &self.bars[start..];
2513        if slice.len() < 2 {
2514            return 0;
2515        }
2516        slice.windows(2)
2517            .filter(|w| {
2518                let prev_bull = w[0].close.value() >= w[0].open.value();
2519                let curr_bull = w[1].close.value() >= w[1].open.value();
2520                prev_bull != curr_bull
2521            })
2522            .count()
2523    }
2524
2525    /// Typical price `(high + low + close) / 3` for each of the last `n` bars.
2526    pub fn typical_price_series(&self, n: usize) -> Vec<Decimal> {
2527        let start = self.bars.len().saturating_sub(n);
2528        self.bars[start..]
2529            .iter()
2530            .map(|b| (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3))
2531            .collect()
2532    }
2533
2534    /// Returns the open-gap percentage for each consecutive bar pair in the full series.
2535    ///
2536    /// `gap_pct[i] = (open[i] - close[i-1]) / close[i-1] * 100`
2537    ///
2538    /// Returns an empty vec if the series has fewer than 2 bars.
2539    pub fn open_gap_series(&self) -> Vec<Decimal> {
2540        if self.bars.len() < 2 {
2541            return Vec::new();
2542        }
2543        self.bars
2544            .windows(2)
2545            .filter_map(|w| {
2546                let prev_close = w[0].close.value();
2547                if prev_close.is_zero() {
2548                    return None;
2549                }
2550                Some((w[1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
2551            })
2552            .collect()
2553    }
2554
2555    /// Average intraday range as a percentage of open: `mean((high - low) / open * 100)` over last `n` bars.
2556    ///
2557    /// Returns `None` if `n == 0`, the series is empty, or any open is zero.
2558    pub fn intraday_range_pct(&self, n: usize) -> Option<Decimal> {
2559        if n == 0 || self.bars.is_empty() {
2560            return None;
2561        }
2562        let start = self.bars.len().saturating_sub(n);
2563        let slice = &self.bars[start..];
2564        let count = slice.len();
2565        if count == 0 {
2566            return None;
2567        }
2568        let sum: Option<Decimal> = slice.iter().try_fold(Decimal::ZERO, |acc, b| {
2569            let o = b.open.value();
2570            if o.is_zero() { return None; }
2571            Some(acc + (b.high.value() - b.low.value()) / o * Decimal::ONE_HUNDRED)
2572        });
2573        #[allow(clippy::cast_possible_truncation)]
2574        Some(sum? / Decimal::from(count as u32))
2575    }
2576
2577    /// Counts bars in the last `n` where `close > prev_high` (breakout above prior high).
2578    ///
2579    /// Returns `0` if `n == 0` or the series has fewer than 2 bars.
2580    pub fn close_above_prior_high(&self, n: usize) -> usize {
2581        if n == 0 || self.bars.len() < 2 {
2582            return 0;
2583        }
2584        let start = self.bars.len().saturating_sub(n + 1);
2585        self.bars[start..].windows(2).filter(|w| w[1].close.value() > w[0].high.value()).count()
2586    }
2587
2588    /// Skewness of close prices over the last `n` bars (Fisher's moment coefficient of skewness).
2589    ///
2590    /// Returns `None` if `n < 3`, series has fewer than `n` bars, or std dev is zero.
2591    pub fn skewness(&self, n: usize) -> Option<f64> {
2592        use rust_decimal::prelude::ToPrimitive;
2593        if n < 3 || self.bars.len() < n {
2594            return None;
2595        }
2596        let start = self.bars.len().saturating_sub(n);
2597        let vals: Vec<f64> = self.bars[start..]
2598            .iter()
2599            .filter_map(|b| b.close.value().to_f64())
2600            .collect();
2601        if vals.len() < 3 {
2602            return None;
2603        }
2604        let n_f = vals.len() as f64;
2605        let mean = vals.iter().sum::<f64>() / n_f;
2606        let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2607        let std_dev = variance.sqrt();
2608        if std_dev == 0.0 {
2609            return None;
2610        }
2611        let skew = vals.iter().map(|x| ((x - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
2612        Some(skew)
2613    }
2614
2615    /// Excess kurtosis of close prices over the last `n` bars.
2616    ///
2617    /// Excess kurtosis = (fourth central moment / variance²) - 3.
2618    /// Returns `None` if `n < 4`, series has fewer than `n` bars, or variance is zero.
2619    pub fn kurtosis(&self, n: usize) -> Option<f64> {
2620        use rust_decimal::prelude::ToPrimitive;
2621        if n < 4 || self.bars.len() < n {
2622            return None;
2623        }
2624        let start = self.bars.len().saturating_sub(n);
2625        let vals: Vec<f64> = self.bars[start..]
2626            .iter()
2627            .filter_map(|b| b.close.value().to_f64())
2628            .collect();
2629        if vals.len() < 4 {
2630            return None;
2631        }
2632        let n_f = vals.len() as f64;
2633        let mean = vals.iter().sum::<f64>() / n_f;
2634        let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
2635        if variance == 0.0 {
2636            return None;
2637        }
2638        let kurt = vals.iter().map(|x| ((x - mean) / variance.sqrt()).powi(4)).sum::<f64>() / n_f - 3.0;
2639        Some(kurt)
2640    }
2641
2642    /// Returns `true` when the fast SMA is above the slow SMA (golden-cross condition).
2643    ///
2644    /// Returns `false` if the series does not have enough bars for the slow period,
2645    /// or if `fast_period >= slow_period`.
2646    pub fn sma_crossover(&self, fast_period: usize, slow_period: usize) -> bool {
2647        if fast_period == 0 || slow_period == 0 || fast_period >= slow_period {
2648            return false;
2649        }
2650        if self.bars.len() < slow_period {
2651            return false;
2652        }
2653        let fast_start = self.bars.len() - fast_period;
2654        let slow_start = self.bars.len() - slow_period;
2655        let fast_avg: Decimal = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2656            / Decimal::from(fast_period as u32);
2657        let slow_avg: Decimal = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
2658            / Decimal::from(slow_period as u32);
2659        fast_avg > slow_avg
2660    }
2661
2662    /// Fraction of the last `n` closing prices that are at or below `price`.
2663    ///
2664    /// Returns a value in `[0.0, 1.0]`. Returns `None` if `n == 0` or the series is empty.
2665    pub fn price_percentile(&self, price: Decimal, n: usize) -> Option<f64> {
2666        if n == 0 || self.bars.is_empty() {
2667            return None;
2668        }
2669        let start = self.bars.len().saturating_sub(n);
2670        let slice = &self.bars[start..];
2671        let count = slice.iter().filter(|b| b.close.value() <= price).count();
2672        Some(count as f64 / slice.len() as f64)
2673    }
2674
2675    /// Mean of `(high - low)` over the last `n` bars.
2676    ///
2677    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
2678    pub fn intraday_range_mean(&self, n: usize) -> Option<Decimal> {
2679        if n == 0 || self.bars.len() < n {
2680            return None;
2681        }
2682        let start = self.bars.len() - n;
2683        let sum: Decimal = self.bars[start..].iter().map(|b| b.high.value() - b.low.value()).sum();
2684        #[allow(clippy::cast_possible_truncation)]
2685        Some(sum / Decimal::from(n as u32))
2686    }
2687
2688    /// Returns `(current_range / ATR) * 100`, showing how the current bar's
2689    /// high-low range compares to the average true range over the last `n` bars.
2690    ///
2691    /// Returns `None` if fewer than `n+1` bars, `n == 0`, or ATR is zero.
2692    pub fn range_to_atr_ratio(&self, n: usize) -> Option<Decimal> {
2693        if n == 0 || self.bars.len() < n + 1 {
2694            return None;
2695        }
2696        let start = self.bars.len() - n - 1;
2697        let slice = &self.bars[start..];
2698        let mut tr_sum = Decimal::ZERO;
2699        for w in slice.windows(2) {
2700            let prev_close = w[0].close.value();
2701            let high = w[1].high.value();
2702            let low = w[1].low.value();
2703            let tr = (high - low)
2704                .max((high - prev_close).abs())
2705                .max((low - prev_close).abs());
2706            tr_sum += tr;
2707        }
2708        #[allow(clippy::cast_possible_truncation)]
2709        let atr = tr_sum / Decimal::from(n as u32);
2710        if atr.is_zero() {
2711            return None;
2712        }
2713        let last = self.bars.last()?;
2714        let current_range = last.high.value() - last.low.value();
2715        Some(current_range / atr * Decimal::ONE_HUNDRED)
2716    }
2717
2718    /// Returns percentage momentum: `(close - close[n]) / close[n] * 100`.
2719    ///
2720    /// Positive when price has risen over the last `n` bars.
2721    /// Returns `None` if fewer than `n+1` bars, `n == 0`, or reference close is zero.
2722    pub fn close_momentum(&self, n: usize) -> Option<Decimal> {
2723        if n == 0 || self.bars.len() < n + 1 {
2724            return None;
2725        }
2726        let ref_close = self.bars[self.bars.len() - n - 1].close.value();
2727        if ref_close.is_zero() {
2728            return None;
2729        }
2730        let current = self.bars.last()?.close.value();
2731        Some((current - ref_close) / ref_close * Decimal::ONE_HUNDRED)
2732    }
2733
2734    /// Mean absolute gap percentage over the last `n` bars.
2735    ///
2736    /// `gap_pct[i] = |open[i] - close[i-1]| / close[i-1] * 100`.
2737    /// Returns `None` if fewer than `n+1` bars or `n == 0`.
2738    pub fn average_gap_pct(&self, n: usize) -> Option<Decimal> {
2739        if n == 0 || self.bars.len() <= n {
2740            return None;
2741        }
2742        let start = self.bars.len() - n - 1;
2743        let slice = &self.bars[start..];
2744        let mut count = 0;
2745        let mut sum = Decimal::ZERO;
2746        for pair in slice.windows(2) {
2747            let pc = pair[0].close.value();
2748            if pc.is_zero() {
2749                continue;
2750            }
2751            sum += (pair[1].open.value() - pc).abs() / pc * Decimal::ONE_HUNDRED;
2752            count += 1;
2753        }
2754        if count == 0 {
2755            None
2756        } else {
2757            #[allow(clippy::cast_possible_truncation)]
2758            Some(sum / Decimal::from(count as u32))
2759        }
2760    }
2761
2762    /// Bar-over-bar log returns for the last `n` close-to-close periods.
2763    ///
2764    /// Returns a `Vec` of up to `n` values. Requires at least `n + 1` bars in the series.
2765    /// Returns an empty `Vec` when `n == 0` or fewer than 2 bars exist.
2766    pub fn returns_series(&self, n: usize) -> Vec<Decimal> {
2767        if n == 0 || self.bars.len() < 2 {
2768            return vec![];
2769        }
2770        use rust_decimal::prelude::ToPrimitive;
2771        let start = self.bars.len().saturating_sub(n + 1);
2772        let slice = &self.bars[start..];
2773        slice
2774            .windows(2)
2775            .map(|w| {
2776                let prev = w[0].close.value();
2777                let curr = w[1].close.value();
2778                if prev.is_zero() {
2779                    Decimal::ZERO
2780                } else {
2781                    let ratio = (curr / prev).to_f64().unwrap_or(1.0);
2782                    Decimal::try_from(ratio.ln()).unwrap_or(Decimal::ZERO)
2783                }
2784            })
2785            .collect()
2786    }
2787
2788    /// Length of the longest consecutive run of rising closes in the entire series.
2789    ///
2790    /// A close is "rising" when `close[i] > close[i-1]`.
2791    /// Returns `0` when fewer than 2 bars exist.
2792    pub fn max_consecutive_up(&self) -> usize {
2793        if self.bars.len() < 2 {
2794            return 0;
2795        }
2796        let mut max_run = 0usize;
2797        let mut current = 0usize;
2798        for w in self.bars.windows(2) {
2799            if w[1].close.value() > w[0].close.value() {
2800                current += 1;
2801                if current > max_run {
2802                    max_run = current;
2803                }
2804            } else {
2805                current = 0;
2806            }
2807        }
2808        max_run
2809    }
2810
2811    /// Length of the longest consecutive run of falling closes in the entire series.
2812    ///
2813    /// A close is "falling" when `close[i] < close[i-1]`.
2814    /// Returns `0` when fewer than 2 bars exist.
2815    pub fn max_consecutive_down(&self) -> usize {
2816        if self.bars.len() < 2 {
2817            return 0;
2818        }
2819        let mut max_run = 0usize;
2820        let mut current = 0usize;
2821        for w in self.bars.windows(2) {
2822            if w[1].close.value() < w[0].close.value() {
2823                current += 1;
2824                if current > max_run {
2825                    max_run = current;
2826                }
2827            } else {
2828                current = 0;
2829            }
2830        }
2831        max_run
2832    }
2833
2834    /// Simple moving average of the typical price `(high + low + close) / 3`
2835    /// over the last `period` bars.
2836    ///
2837    /// Returns `None` if `period == 0` or fewer than `period` bars exist.
2838    pub fn typical_price_sma(&self, period: usize) -> Option<Decimal> {
2839        if period == 0 || self.bars.len() < period {
2840            return None;
2841        }
2842        let start = self.bars.len() - period;
2843        let sum: Decimal = self.bars[start..]
2844            .iter()
2845            .map(|b| (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3u32))
2846            .sum();
2847        #[allow(clippy::cast_possible_truncation)]
2848        Some(sum / Decimal::from(period as u32))
2849    }
2850
2851    /// Returns a reference to the bar at position `i`, or `None` if out of bounds.
2852    pub fn bar_at_index(&self, i: usize) -> Option<&OhlcvBar> {
2853        self.bars.get(i)
2854    }
2855
2856    /// Standard deviation of closes over the last `n` bars.
2857    ///
2858    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
2859    #[allow(clippy::cast_possible_truncation)]
2860    pub fn rolling_close_std(&self, n: usize) -> Option<Decimal> {
2861        if n < 2 || self.bars.len() < n {
2862            return None;
2863        }
2864        let start = self.bars.len() - n;
2865        let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
2866        let mean = closes.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
2867        let variance = closes
2868            .iter()
2869            .map(|c| { let d = *c - mean; d * d })
2870            .sum::<Decimal>()
2871            / Decimal::from((n - 1) as u32);
2872        use rust_decimal::prelude::ToPrimitive;
2873        let std = variance.to_f64()?.sqrt();
2874        Decimal::try_from(std).ok()
2875    }
2876
2877    /// Returns a `Vec<i8>` of gap directions (`+1` = gap up, `-1` = gap down, `0` = flat)
2878    /// for bar-over-bar open-to-prev-close gaps over the last `n` bars.
2879    ///
2880    /// A gap is defined as `open[i] != close[i-1]`. Returns at most `n - 1` values.
2881    /// Returns empty `Vec` when `n < 2` or fewer than 2 bars exist.
2882    pub fn gap_direction_series(&self, n: usize) -> Vec<i8> {
2883        if n < 2 || self.bars.len() < 2 {
2884            return vec![];
2885        }
2886        let start = self.bars.len().saturating_sub(n);
2887        self.bars[start..]
2888            .windows(2)
2889            .map(|w| {
2890                let gap = w[1].open.value() - w[0].close.value();
2891                if gap > Decimal::ZERO {
2892                    1i8
2893                } else if gap < Decimal::ZERO {
2894                    -1i8
2895                } else {
2896                    0i8
2897                }
2898            })
2899            .collect()
2900    }
2901
2902    /// Returns the linear regression slope of volume over the last `n` bars.
2903    ///
2904    /// Positive slope → volume is trending up; negative → down.
2905    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
2906    pub fn volume_trend(&self, n: usize) -> Option<f64> {
2907        use rust_decimal::prelude::ToPrimitive;
2908        if n < 2 || self.bars.len() < n {
2909            return None;
2910        }
2911        let start = self.bars.len() - n;
2912        let vols: Vec<f64> = self.bars[start..]
2913            .iter()
2914            .filter_map(|b| b.volume.value().to_f64())
2915            .collect();
2916        if vols.len() < 2 {
2917            return None;
2918        }
2919        let n_f = vols.len() as f64;
2920        let sum_x: f64 = (0..vols.len()).map(|i| i as f64).sum();
2921        let sum_y: f64 = vols.iter().sum();
2922        let sum_xy: f64 = vols.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
2923        let sum_xx: f64 = (0..vols.len()).map(|i| (i as f64).powi(2)).sum();
2924        let denom = n_f * sum_xx - sum_x * sum_x;
2925        if denom == 0.0 { return None; }
2926        Some((n_f * sum_xy - sum_x * sum_y) / denom)
2927    }
2928
2929    /// Average ratio of total wick length to body length over the last `n` bars.
2930    ///
2931    /// `wick = (high - low) - |close - open|`; `body = |close - open|`
2932    /// Returns `None` if `n == 0`, fewer than `n` bars, or all bodies are zero.
2933    pub fn wick_body_ratio(&self, n: usize) -> Option<f64> {
2934        use rust_decimal::prelude::ToPrimitive;
2935        if n == 0 || self.bars.len() < n {
2936            return None;
2937        }
2938        let start = self.bars.len() - n;
2939        let mut sum = 0.0f64;
2940        let mut count = 0usize;
2941        for b in &self.bars[start..] {
2942            let body = (b.close.value() - b.open.value()).abs().to_f64()?;
2943            if body == 0.0 { continue; }
2944            let range = (b.high.value() - b.low.value()).to_f64()?;
2945            let wick = (range - body).max(0.0);
2946            sum += wick / body;
2947            count += 1;
2948        }
2949        if count == 0 { return None; }
2950        Some(sum / count as f64)
2951    }
2952
2953    /// Pearson correlation between volume and close price over the last `n` bars.
2954    ///
2955    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or standard deviation is zero.
2956    pub fn volume_price_correlation(&self, n: usize) -> Option<f64> {
2957        use rust_decimal::prelude::ToPrimitive;
2958        if n < 2 || self.bars.len() < n {
2959            return None;
2960        }
2961        let start = self.bars.len() - n;
2962        let xs: Vec<f64> = self.bars[start..]
2963            .iter()
2964            .filter_map(|b| b.volume.value().to_f64())
2965            .collect();
2966        let ys: Vec<f64> = self.bars[start..]
2967            .iter()
2968            .filter_map(|b| b.close.value().to_f64())
2969            .collect();
2970        if xs.len() < 2 { return None; }
2971        let n_f = xs.len() as f64;
2972        let mx = xs.iter().sum::<f64>() / n_f;
2973        let my = ys.iter().sum::<f64>() / n_f;
2974        let num: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum();
2975        let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
2976        let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
2977        if sx == 0.0 || sy == 0.0 { return None; }
2978        Some(num / (n_f * sx * sy))
2979    }
2980
2981    /// Average bar range as a percentage of close: `(high - low) / close × 100` over `n` bars.
2982    ///
2983    /// Returns `None` if `n == 0`, fewer than `n` bars, or any close is zero.
2984    pub fn bar_range_pct(&self, n: usize) -> Option<Decimal> {
2985        if n == 0 || self.bars.len() < n {
2986            return None;
2987        }
2988        let start = self.bars.len() - n;
2989        let mut sum = Decimal::ZERO;
2990        let mut count = 0u32;
2991        for b in &self.bars[start..] {
2992            let c = b.close.value();
2993            if c.is_zero() { continue; }
2994            sum += (b.high.value() - b.low.value()) / c * Decimal::ONE_HUNDRED;
2995            count += 1;
2996        }
2997        if count == 0 { return None; }
2998        Some(sum / Decimal::from(count))
2999    }
3000
3001    /// Count of bars over the last `n` where close > midpoint of prior bar's high-low range.
3002    ///
3003    /// Returns `0` when `n < 2` or fewer than 2 bars available.
3004    pub fn close_vs_prior_range_count(&self, n: usize) -> usize {
3005        if n < 2 || self.bars.len() < 2 {
3006            return 0;
3007        }
3008        let start = self.bars.len().saturating_sub(n);
3009        let slice = &self.bars[start..];
3010        slice.windows(2)
3011            .filter(|w| {
3012                let mid = (w[0].high.value() + w[0].low.value()) / Decimal::TWO;
3013                w[1].close.value() > mid
3014            })
3015            .count()
3016    }
3017
3018    /// Annualised Sharpe ratio of log returns over the last `n` bars.
3019    ///
3020    /// Uses 252 trading days to annualise. Returns `None` if fewer than 2 bars exist,
3021    /// `n == 0`, or the standard deviation of returns is zero.
3022    pub fn rolling_sharpe(&self, n: usize, risk_free_rate: Decimal) -> Option<Decimal> {
3023        if n == 0 || self.bars.len() < 2 {
3024            return None;
3025        }
3026        use rust_decimal::prelude::ToPrimitive;
3027        let returns = self.returns_series(n);
3028        if returns.len() < 2 {
3029            return None;
3030        }
3031        #[allow(clippy::cast_possible_truncation)]
3032        let len_d = Decimal::from(returns.len() as u32);
3033        let mean: Decimal = returns.iter().copied().sum::<Decimal>() / len_d;
3034        let rf_daily = risk_free_rate / Decimal::from(252u32);
3035        let excess_mean = mean - rf_daily;
3036        let variance = returns
3037            .iter()
3038            .map(|r| { let d = *r - mean; d * d })
3039            .sum::<Decimal>()
3040            / len_d;
3041        let std_f64 = variance.to_f64()?.sqrt();
3042        if std_f64 == 0.0 {
3043            return None;
3044        }
3045        let sharpe = excess_mean.to_f64()? / std_f64 * 252.0f64.sqrt();
3046        Decimal::try_from(sharpe).ok()
3047    }
3048
3049    /// Returns where the latest close sits within the high-low range of the last `n` bars (0–100).
3050    ///
3051    /// `result = (close - lowest_low) / (highest_high - lowest_low) * 100`
3052    ///
3053    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or the range is zero.
3054    pub fn close_range_position(&self, n: usize) -> Option<Decimal> {
3055        if n == 0 || self.bars.len() < n {
3056            return None;
3057        }
3058        let start = self.bars.len() - n;
3059        let slice = &self.bars[start..];
3060        let highest = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
3061        let lowest  = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
3062        let range = highest - lowest;
3063        if range.is_zero() {
3064            return None;
3065        }
3066        let close = self.bars.last()?.close.value();
3067        Some((close - lowest) / range * Decimal::ONE_HUNDRED)
3068    }
3069
3070    /// Returns the number of bars since the highest close in the last `n` bars.
3071    ///
3072    /// Returns `0` if the highest close is the most recent bar, or when `n == 0` or
3073    /// fewer than `n` bars exist.
3074    pub fn bar_count_since_high(&self, n: usize) -> usize {
3075        if n == 0 || self.bars.len() < n {
3076            return 0;
3077        }
3078        let start = self.bars.len() - n;
3079        let slice = &self.bars[start..];
3080        let mut max_val = Decimal::MIN;
3081        let mut max_idx = 0;
3082        for (i, b) in slice.iter().enumerate() {
3083            let c = b.close.value();
3084            if c > max_val {
3085                max_val = c;
3086                max_idx = i;
3087            }
3088        }
3089        slice.len() - 1 - max_idx
3090    }
3091
3092    /// Average `(close / open - 1) * 100` percentage over the last `n` bars.
3093    ///
3094    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all opens are zero.
3095    pub fn close_to_open_ratio(&self, n: usize) -> Option<Decimal> {
3096        if n == 0 || self.bars.len() < n {
3097            return None;
3098        }
3099        let start = self.bars.len() - n;
3100        let mut sum = Decimal::ZERO;
3101        let mut count = 0usize;
3102        for b in &self.bars[start..] {
3103            let o = b.open.value();
3104            if o.is_zero() {
3105                continue;
3106            }
3107            sum += (b.close.value() / o - Decimal::ONE) * Decimal::ONE_HUNDRED;
3108            count += 1;
3109        }
3110        if count == 0 {
3111            return None;
3112        }
3113        #[allow(clippy::cast_possible_truncation)]
3114        Some(sum / Decimal::from(count as u32))
3115    }
3116
3117    /// Lag-`k` autocorrelation of log returns over the last `n` bars.
3118    ///
3119    /// Computes the Pearson correlation between `r[t]` and `r[t-lag]`.
3120    /// Returns `None` if `n == 0`, `lag == 0`, fewer than `n + lag + 1` bars exist,
3121    /// or the standard deviation is zero.
3122    pub fn autocorrelation(&self, n: usize, lag: usize) -> Option<f64> {
3123        if n == 0 || lag == 0 || self.bars.len() < n + lag + 1 {
3124            return None;
3125        }
3126        use rust_decimal::prelude::ToPrimitive;
3127        let returns = self.returns_series(n + lag);
3128        if returns.len() <= lag {
3129            return None;
3130        }
3131        let x: Vec<f64> = returns[..returns.len() - lag].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3132        let y: Vec<f64> = returns[lag..].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
3133        let n_f = x.len() as f64;
3134        let mean_x = x.iter().sum::<f64>() / n_f;
3135        let mean_y = y.iter().sum::<f64>() / n_f;
3136        let cov: f64 = x.iter().zip(y.iter()).map(|(xi, yi)| (xi - mean_x) * (yi - mean_y)).sum::<f64>() / n_f;
3137        let std_x = (x.iter().map(|xi| (xi - mean_x).powi(2)).sum::<f64>() / n_f).sqrt();
3138        let std_y = (y.iter().map(|yi| (yi - mean_y).powi(2)).sum::<f64>() / n_f).sqrt();
3139        if std_x == 0.0 || std_y == 0.0 {
3140            return None;
3141        }
3142        Some(cov / (std_x * std_y))
3143    }
3144
3145    /// Hurst exponent estimated via the rescaled range (R/S) method over the last `n` bars.
3146    ///
3147    /// H ≈ 0.5 → random walk; H > 0.5 → trending; H < 0.5 → mean-reverting.
3148    /// Returns `None` if `n < 8` or fewer than `n + 1` bars exist.
3149    pub fn hurst_exponent(&self, n: usize) -> Option<f64> {
3150        if n < 8 || self.bars.len() < n + 1 {
3151            return None;
3152        }
3153        use rust_decimal::prelude::ToPrimitive;
3154        let returns: Vec<f64> = self
3155            .returns_series(n)
3156            .iter()
3157            .map(|r| r.to_f64().unwrap_or(0.0))
3158            .collect();
3159        if returns.is_empty() {
3160            return None;
3161        }
3162        let mean = returns.iter().sum::<f64>() / returns.len() as f64;
3163        let cum: Vec<f64> = returns.iter().scan(0.0f64, |acc, &r| { *acc += r - mean; Some(*acc) }).collect();
3164        let r = cum.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
3165            - cum.iter().cloned().fold(f64::INFINITY, f64::min);
3166        let s = (returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64).sqrt();
3167        if s == 0.0 || r <= 0.0 {
3168            return None;
3169        }
3170        Some((r / s).ln() / (returns.len() as f64).ln())
3171    }
3172
3173    /// Ulcer Index over the last `n` bars: RMS of percentage drawdowns from rolling peak.
3174    ///
3175    /// A measure of downside volatility; higher = more painful drawdowns.
3176    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
3177    pub fn ulcer_index(&self, n: usize) -> Option<Decimal> {
3178        if n == 0 || self.bars.len() < n {
3179            return None;
3180        }
3181        use rust_decimal::prelude::ToPrimitive;
3182        let start = self.bars.len() - n;
3183        let slice = &self.bars[start..];
3184        let mut peak = Decimal::ZERO;
3185        let mut sum_sq = 0.0f64;
3186        for b in slice {
3187            let c = b.close.value();
3188            if c > peak { peak = c; }
3189            if peak.is_zero() { continue; }
3190            let dd_pct = ((c - peak) / peak * Decimal::ONE_HUNDRED).to_f64().unwrap_or(0.0);
3191            sum_sq += dd_pct * dd_pct;
3192        }
3193        let ui = (sum_sq / n as f64).sqrt();
3194        Decimal::try_from(ui).ok()
3195    }
3196
3197    /// Conditional Value-at-Risk (CVaR / Expected Shortfall) at `confidence_pct` over last `n` bars.
3198    ///
3199    /// Returns the average of log returns below the VaR quantile.
3200    /// Returns `None` if `n < 2`, `confidence_pct` is out of `(0, 100)`, or there are
3201    /// fewer than `n + 1` bars.
3202    pub fn cvar(&self, n: usize, confidence_pct: Decimal) -> Option<Decimal> {
3203        use rust_decimal::prelude::ToPrimitive;
3204        if n < 2 || confidence_pct <= Decimal::ZERO || confidence_pct >= Decimal::ONE_HUNDRED {
3205            return None;
3206        }
3207        let mut returns = self.returns_series(n);
3208        if returns.len() < 2 {
3209            return None;
3210        }
3211        returns.sort_unstable_by(|a, b| a.cmp(b));
3212        let cutoff = ((Decimal::ONE - confidence_pct / Decimal::ONE_HUNDRED)
3213            .to_f64()
3214            .unwrap_or(0.05)
3215            * returns.len() as f64)
3216            .ceil() as usize;
3217        let tail = &returns[..cutoff.min(returns.len())];
3218        if tail.is_empty() {
3219            return None;
3220        }
3221        #[allow(clippy::cast_possible_truncation)]
3222        let avg = tail.iter().copied().sum::<Decimal>() / Decimal::from(tail.len() as u32);
3223        Some(avg)
3224    }
3225
3226    /// Returns the percentage change in close price over the last `n` bars.
3227    ///
3228    /// Formula: `(close[-1] - close[-n-1]) / close[-n-1] * 100`.
3229    /// Returns `None` if `n == 0`, fewer than `n + 1` bars exist, or the earlier close is zero.
3230    pub fn close_change_pct(&self, n: usize) -> Option<Decimal> {
3231        if n == 0 || self.bars.len() <= n {
3232            return None;
3233        }
3234        let recent = self.bars.last()?.close.value();
3235        let earlier = self.bars[self.bars.len() - 1 - n].close.value();
3236        if earlier.is_zero() {
3237            return None;
3238        }
3239        Some((recent - earlier) / earlier * Decimal::ONE_HUNDRED)
3240    }
3241
3242    /// Fraction of the last `n` closes that are above the VWAP over that window.
3243    /// Returns `None` if `n` is 0, fewer than `n` bars exist, or total volume is zero.
3244    pub fn close_above_vwap_pct(&self, n: usize) -> Option<f64> {
3245        if n == 0 || self.bars.len() < n { return None; }
3246        let start = self.bars.len() - n;
3247        let window = &self.bars[start..];
3248        let total_vol: Decimal = window.iter().map(|b| b.volume.value()).sum();
3249        if total_vol.is_zero() { return None; }
3250        let vwap = window.iter()
3251            .map(|b| b.typical_price() * b.volume.value())
3252            .sum::<Decimal>() / total_vol;
3253        let above = window.iter().filter(|b| b.close.value() > vwap).count();
3254        Some(above as f64 / n as f64 * 100.0)
3255    }
3256
3257    /// Count of direction reversals in the last `n` closes (close switches from up to down or
3258    /// vice versa). Returns 0 if `n` < 2 or there are insufficient bars.
3259    pub fn reversal_count(&self, n: usize) -> usize {
3260        if n < 2 || self.bars.len() < n { return 0; }
3261        let start = self.bars.len() - n;
3262        self.bars[start..].windows(3)
3263            .filter(|w| {
3264                let prev_dir = w[1].close.value() > w[0].close.value();
3265                let curr_dir = w[2].close.value() > w[1].close.value();
3266                prev_dir != curr_dir
3267            })
3268            .count()
3269    }
3270
3271    /// Percentage of the last `n` bars where a gap open (open != prior close) was filled
3272    /// (i.e., price returned to the prior close within the same bar). Returns `None` if `n` is 0
3273    /// or there are insufficient bars.
3274    pub fn open_gap_fill_rate(&self, n: usize) -> Option<f64> {
3275        if n == 0 || self.bars.len() < n + 1 { return None; }
3276        let start = self.bars.len() - n;
3277        let mut gap_count = 0usize;
3278        let mut filled = 0usize;
3279        for i in start..self.bars.len() {
3280            let prior_close = self.bars[i - 1].close.value();
3281            let bar = &self.bars[i];
3282            let open = bar.open.value();
3283            if open == prior_close { continue; }
3284            gap_count += 1;
3285            let gap_up = open > prior_close;
3286            if gap_up && bar.low.value() <= prior_close {
3287                filled += 1;
3288            } else if !gap_up && bar.high.value() >= prior_close {
3289                filled += 1;
3290            }
3291        }
3292        if gap_count == 0 { return None; }
3293        Some(filled as f64 / gap_count as f64 * 100.0)
3294    }
3295
3296    /// Average candle symmetry over the last `n` bars: ratio of lower shadow to upper shadow
3297    /// where 1.0 means perfectly symmetric. Returns `None` if `n` is 0, fewer than `n` bars
3298    /// exist, or no bar has any shadows.
3299    pub fn candle_symmetry(&self, n: usize) -> Option<f64> {
3300        if n == 0 || self.bars.len() < n { return None; }
3301        let start = self.bars.len() - n;
3302        let mut ratios = Vec::new();
3303        for bar in &self.bars[start..] {
3304            let body_top = bar.close.value().max(bar.open.value());
3305            let body_bot = bar.close.value().min(bar.open.value());
3306            let upper = bar.high.value() - body_top;
3307            let lower = body_bot - bar.low.value();
3308            if upper.is_zero() && lower.is_zero() { continue; }
3309            let total = upper + lower;
3310            if total.is_zero() { continue; }
3311            let ratio: f64 = lower.to_string().parse::<f64>().unwrap_or(0.0)
3312                / total.to_string().parse::<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.close.value() > b.open.value())
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.high.value() - bar.low.value()
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.close.value() - b.open.value()).abs())
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.high.value() + b.low.value()) / Decimal::TWO)
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| {
3792                let body_top = b.open.value().max(b.close.value());
3793                b.high.value() - body_top
3794            })
3795            .sum();
3796        #[allow(clippy::cast_possible_truncation)]
3797        Some(sum / Decimal::from(n as u32))
3798    }
3799
3800    /// Median `(high + low) / 2` midpoint value over the last `n` bars.
3801    ///
3802    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
3803    pub fn median_price(&self, n: usize) -> Option<Decimal> {
3804        if n == 0 || self.bars.len() < n {
3805            return None;
3806        }
3807        let start = self.bars.len() - n;
3808        let mut mids: Vec<Decimal> = self.bars[start..]
3809            .iter()
3810            .map(|b| (b.high.value() + b.low.value()) / Decimal::TWO)
3811            .collect();
3812        mids.sort();
3813        let mid = n / 2;
3814        if n % 2 == 0 {
3815            Some((mids[mid - 1] + mids[mid]) / Decimal::TWO)
3816        } else {
3817            Some(mids[mid])
3818        }
3819    }
3820
3821    /// Mean upper-shadow ratio `(high − max(open,close)) / (high − low)` over the last `n` bars.
3822    ///
3823    /// Bars where `high == low` (doji) contribute 0. Returns `None` if `n == 0`
3824    /// or the series has fewer than `n` bars.
3825    pub fn upper_shadow_ratio(&self, n: usize) -> Option<Decimal> {
3826        if n == 0 || self.bars.len() < n {
3827            return None;
3828        }
3829        let start = self.bars.len() - n;
3830        let sum: Decimal = self.bars[start..]
3831            .iter()
3832            .map(|b| {
3833                let range = b.high.value() - b.low.value();
3834                if range.is_zero() {
3835                    Decimal::ZERO
3836                } else {
3837                    (b.high.value() - b.open.value().max(b.close.value())) / range
3838                }
3839            })
3840            .sum();
3841        #[allow(clippy::cast_possible_truncation)]
3842        Some(sum / Decimal::from(n as u32))
3843    }
3844
3845    /// Fraction of bars in the last `n + 1` where `open[i] > close[i-1]` (gap up).
3846    ///
3847    /// Returns `None` if `n == 0` or the series has fewer than `n + 1` bars.
3848    pub fn percent_gap_up_bars(&self, n: usize) -> Option<Decimal> {
3849        if n == 0 || self.bars.len() < n + 1 {
3850            return None;
3851        }
3852        let start = self.bars.len() - n;
3853        let count = (start..self.bars.len())
3854            .filter(|&i| self.bars[i].open > self.bars[i - 1].close)
3855            .count();
3856        #[allow(clippy::cast_possible_truncation)]
3857        Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
3858    }
3859
3860    /// Length of the longest run of consecutive higher closes within the last `n` bars.
3861    ///
3862    /// A "higher close" means `close[i] > close[i-1]`.  The run is computed across
3863    /// consecutive comparisons (not against a fixed baseline).
3864    ///
3865    /// Returns `None` if `n < 2` or the series has fewer than `n` bars.
3866    pub fn consecutive_higher_closes(&self, n: usize) -> Option<usize> {
3867        if n < 2 || self.bars.len() < n {
3868            return None;
3869        }
3870        let start = self.bars.len() - n;
3871        let mut max_run = 0usize;
3872        let mut cur_run = 0usize;
3873        for i in (start + 1)..self.bars.len() {
3874            if self.bars[i].close > self.bars[i - 1].close {
3875                cur_run += 1;
3876                if cur_run > max_run { max_run = cur_run; }
3877            } else {
3878                cur_run = 0;
3879            }
3880        }
3881        Some(max_run)
3882    }
3883
3884    /// Volume-weighted average return over the last `n` bars.
3885    ///
3886    /// `return[i] = (close[i] - close[i-1]) / close[i-1]`; each return is weighted by
3887    /// the volume of bar `i`.  Bars with zero prior close are excluded from the sum.
3888    ///
3889    /// Returns `None` if `n < 2`, the series has fewer than `n` bars, or total volume is zero.
3890    pub fn volume_weighted_return(&self, n: usize) -> Option<Decimal> {
3891        if n < 2 || self.bars.len() < n {
3892            return None;
3893        }
3894        let start = self.bars.len() - n;
3895        let mut vol_return_sum = Decimal::ZERO;
3896        let mut vol_sum = Decimal::ZERO;
3897        for i in (start + 1)..self.bars.len() {
3898            let prev_close = self.bars[i - 1].close.value();
3899            if prev_close.is_zero() { continue; }
3900            let ret = (self.bars[i].close.value() - prev_close) / prev_close;
3901            let vol = self.bars[i].volume.value();
3902            vol_return_sum += ret * vol;
3903            vol_sum += vol;
3904        }
3905        if vol_sum.is_zero() {
3906            return None;
3907        }
3908        Some(vol_return_sum / vol_sum)
3909    }
3910
3911    /// Returns arithmetic close-to-close returns for the last `n` bars as `(close[i] - close[i-1]) / close[i-1]`.
3912    ///
3913    /// The result has `n - 1` entries (each bar needs a previous bar to compute a return).
3914    /// Returns `None` if `n < 2` or the series has fewer than `n` bars.
3915    pub fn close_returns(&self, n: usize) -> Option<Vec<Decimal>> {
3916        if n < 2 || self.bars.len() < n {
3917            return None;
3918        }
3919        let start = self.bars.len() - n;
3920        let mut returns = Vec::with_capacity(n - 1);
3921        for i in (start + 1)..self.bars.len() {
3922            let prev = self.bars[i - 1].close.value();
3923            if prev.is_zero() {
3924                returns.push(Decimal::ZERO);
3925            } else {
3926                returns.push((self.bars[i].close.value() - prev) / prev);
3927            }
3928        }
3929        Some(returns)
3930    }
3931
3932    /// Classifies recent volatility as `"low"`, `"medium"`, or `"high"` by comparing
3933    /// the average ATR of the last `atr_period` bars to its own mean over the last `lookback` bars.
3934    ///
3935    /// - **low**: latest ATR < 80% of the rolling mean
3936    /// - **high**: latest ATR > 120% of the rolling mean
3937    /// - **medium**: otherwise
3938    ///
3939    /// Returns `None` if there are fewer than `lookback + 1` bars (need history to compute ATR)
3940    /// or if `atr_period == 0` or `lookback == 0`.
3941    pub fn volatility_regime(&self, atr_period: usize, lookback: usize) -> Option<&'static str> {
3942        if atr_period == 0 || lookback == 0 {
3943            return None;
3944        }
3945        let needed = lookback + atr_period;
3946        if self.bars.len() < needed {
3947            return None;
3948        }
3949        let atr_series = self.atr_series(atr_period);
3950        let recent_atrs: Vec<Decimal> = atr_series
3951            .iter()
3952            .rev()
3953            .take(lookback)
3954            .filter_map(|v| *v)
3955            .collect();
3956        if recent_atrs.is_empty() {
3957            return None;
3958        }
3959        let mean: Decimal = recent_atrs.iter().copied().sum::<Decimal>()
3960            / Decimal::from(recent_atrs.len() as u32);
3961        if mean.is_zero() {
3962            return Some("medium");
3963        }
3964        let latest = *recent_atrs.first()?;
3965        let ratio = latest / mean;
3966        if ratio < Decimal::new(80, 2) {
3967            Some("low")
3968        } else if ratio > Decimal::new(120, 2) {
3969            Some("high")
3970        } else {
3971            Some("medium")
3972        }
3973    }
3974
3975    /// Ratio of total volume on up-bars to total volume on down-bars over the last `n` bars.
3976    ///
3977    /// An up-bar is `close > open`; a down-bar is `close < open`. Doji bars are excluded.
3978    ///
3979    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or there are no down-bars.
3980    pub fn up_down_volume_ratio(&self, n: usize) -> Option<Decimal> {
3981        if n == 0 || self.bars.len() < n {
3982            return None;
3983        }
3984        let start = self.bars.len() - n;
3985        let mut up_vol = Decimal::ZERO;
3986        let mut dn_vol = Decimal::ZERO;
3987        for b in &self.bars[start..] {
3988            let vol = b.volume.value();
3989            if b.close > b.open { up_vol += vol; }
3990            else if b.close < b.open { dn_vol += vol; }
3991        }
3992        if dn_vol.is_zero() { return None; }
3993        Some(up_vol / dn_vol)
3994    }
3995
3996    /// Average bar range (high − low) as a percentage of the typical price, over the last `n` bars.
3997    ///
3998    /// `typical = (H + L + C) / 3`. Bars with zero typical price are excluded.
3999    ///
4000    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or no bar has positive typical price.
4001    pub fn avg_range_pct(&self, n: usize) -> Option<Decimal> {
4002        if n == 0 || self.bars.len() < n {
4003            return None;
4004        }
4005        let start = self.bars.len() - n;
4006        let mut sum = Decimal::ZERO;
4007        let mut count = 0usize;
4008        let hundred = Decimal::from(100u32);
4009        let three = Decimal::from(3u32);
4010        for b in &self.bars[start..] {
4011            let tp = (b.high.value() + b.low.value() + b.close.value()) / three;
4012            if tp.is_zero() { continue; }
4013            sum += (b.high.value() - b.low.value()) / tp * hundred;
4014            count += 1;
4015        }
4016        if count == 0 { return None; }
4017        Some(sum / Decimal::from(count as u32))
4018    }
4019
4020    /// Bar efficiency over the last `n` bars: net directional move / total path length.
4021    ///
4022    /// `efficiency = |close[last] - close[first]| / Σ|close[i] - close[i-1]|`
4023    ///
4024    /// A value of 1.0 means perfectly directional; near 0 means highly erratic.
4025    ///
4026    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or total path is zero.
4027    pub fn bar_efficiency(&self, n: usize) -> Option<f64> {
4028        use rust_decimal::prelude::ToPrimitive;
4029        if n < 2 || self.bars.len() < n {
4030            return None;
4031        }
4032        let start = self.bars.len() - n;
4033        let net = (self.bars.last().unwrap().close.value()
4034            - self.bars[start].close.value())
4035            .abs()
4036            .to_f64()
4037            .unwrap_or(0.0);
4038        let path: f64 = (start + 1..self.bars.len())
4039            .map(|i| {
4040                (self.bars[i].close.value() - self.bars[i - 1].close.value())
4041                    .abs()
4042                    .to_f64()
4043                    .unwrap_or(0.0)
4044            })
4045            .sum();
4046        if path == 0.0 { return None; }
4047        Some(net / path)
4048    }
4049
4050    /// Average number of bars between successive new `n`-bar highs in the last `m` bars.
4051    ///
4052    /// A new high at bar `i` means `close[i] > max(close[i-n..i])`.
4053    ///
4054    /// Returns `None` if `m <= n`, fewer than `m` bars exist, or no new high is found.
4055    pub fn avg_bars_between_highs(&self, n: usize, m: usize) -> Option<f64> {
4056        if n == 0 || m <= n || self.bars.len() < m {
4057            return None;
4058        }
4059        let start = self.bars.len() - m;
4060        let mut high_indices: Vec<usize> = Vec::new();
4061        for i in (start + n)..self.bars.len() {
4062            let prev_max = self.bars[(i - n)..i]
4063                .iter()
4064                .map(|b| b.close.value())
4065                .max()
4066                .unwrap_or(Decimal::ZERO);
4067            if self.bars[i].close.value() > prev_max {
4068                high_indices.push(i);
4069            }
4070        }
4071        if high_indices.len() < 2 { return None; }
4072        let gaps: Vec<usize> = high_indices.windows(2).map(|w| w[1] - w[0]).collect();
4073        Some(gaps.iter().sum::<usize>() as f64 / gaps.len() as f64)
4074    }
4075
4076    /// Number of consecutive bars (from the most recent bar backward) where close exceeded
4077    /// the prior `n`-bar rolling high.
4078    ///
4079    /// A bar at index `i` counts if `close[i] > max(close[i-n..i])`.
4080    /// The first `n` bars of the series are skipped (no prior window).
4081    ///
4082    /// Returns `None` if `n == 0` or the series has fewer than `n + 1` bars.
4083    pub fn breakout_bars(&self, n: usize) -> Option<usize> {
4084        if n == 0 || self.bars.len() <= n {
4085            return None;
4086        }
4087        let mut streak = 0usize;
4088        for i in (n..self.bars.len()).rev() {
4089            let prior_max = self.bars[(i - n)..i]
4090                .iter()
4091                .map(|b| b.close.value())
4092                .max()
4093                .unwrap_or(Decimal::ZERO);
4094            if self.bars[i].close.value() > prior_max {
4095                streak += 1;
4096            } else {
4097                break;
4098            }
4099        }
4100        Some(streak)
4101    }
4102
4103    /// Count of doji candles in the last `n` bars.
4104    ///
4105    /// A bar is a doji when `|close - open| / (high - low) < threshold`.
4106    /// Use `threshold = 0.1` for the classic 10% body rule.
4107    ///
4108    /// Returns `None` if `n == 0` or the series has fewer than `n` bars.
4109    pub fn doji_count(&self, n: usize, threshold: f64) -> Option<usize> {
4110        if n == 0 || self.bars.len() < n {
4111            return None;
4112        }
4113        let start = self.bars.len() - n;
4114        use rust_decimal::prelude::ToPrimitive;
4115        let count = self.bars[start..]
4116            .iter()
4117            .filter(|b| {
4118                let range = (b.high.value() - b.low.value()).to_f64().unwrap_or(0.0);
4119                if range == 0.0 {
4120                    return true; // zero-range bar is a perfect doji
4121                }
4122                let body = (b.close.value() - b.open.value())
4123                    .abs()
4124                    .to_f64()
4125                    .unwrap_or(0.0);
4126                body / range < threshold
4127            })
4128            .count();
4129        Some(count)
4130    }
4131
4132    /// Coefficient of variation of closes over the last `n` bars: `std_dev / mean`.
4133    ///
4134    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or mean is zero.
4135    pub fn close_dispersion(&self, n: usize) -> Option<f64> {
4136        use rust_decimal::prelude::ToPrimitive;
4137        if n < 2 || self.bars.len() < n {
4138            return None;
4139        }
4140        let start = self.bars.len() - n;
4141        let vals: Vec<f64> = self.bars[start..]
4142            .iter()
4143            .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4144            .collect();
4145        let mean = vals.iter().sum::<f64>() / n as f64;
4146        if mean == 0.0 { return None; }
4147        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
4148        Some(variance.sqrt() / mean)
4149    }
4150
4151    /// Volume of the most recent bar as a percentage of the average volume over the last `n` bars.
4152    ///
4153    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or average volume is zero.
4154    pub fn relative_volume(&self, n: usize) -> Option<Decimal> {
4155        if n == 0 || self.bars.len() < n {
4156            return None;
4157        }
4158        let start = self.bars.len() - n;
4159        let avg_vol: Decimal = self.bars[start..]
4160            .iter()
4161            .map(|b| b.volume.value())
4162            .sum::<Decimal>()
4163            / Decimal::from(n as u32);
4164        if avg_vol.is_zero() { return None; }
4165        let last_vol = self.bars.last()?.volume.value();
4166        Some(last_vol / avg_vol * Decimal::from(100u32))
4167    }
4168
4169    /// Average midpoint of the open-close range over the last `n` bars.
4170    ///
4171    /// `midpoint[i] = (open[i] + close[i]) / 2`
4172    ///
4173    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4174    pub fn avg_oc_midpoint(&self, n: usize) -> Option<Decimal> {
4175        if n == 0 || self.bars.len() < n {
4176            return None;
4177        }
4178        let start = self.bars.len() - n;
4179        let sum: Decimal = self.bars[start..]
4180            .iter()
4181            .map(|b| (b.open.value() + b.close.value()) / Decimal::TWO)
4182            .sum();
4183        Some(sum / Decimal::from(n as u32))
4184    }
4185
4186    /// Count of bars in the last `n` with volume > `threshold × average_volume`.
4187    ///
4188    /// A `threshold` of `2.0` finds bars with more than twice the average volume.
4189    ///
4190    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or average volume is zero.
4191    pub fn volume_spike_count(&self, n: usize, threshold: Decimal) -> Option<usize> {
4192        if n == 0 || self.bars.len() < n {
4193            return None;
4194        }
4195        let start = self.bars.len() - n;
4196        let avg_vol: Decimal = self.bars[start..]
4197            .iter()
4198            .map(|b| b.volume.value())
4199            .sum::<Decimal>()
4200            / Decimal::from(n as u32);
4201        if avg_vol.is_zero() { return None; }
4202        let limit = avg_vol * threshold;
4203        let count = self.bars[start..].iter().filter(|b| b.volume.value() > limit).count();
4204        Some(count)
4205    }
4206
4207    /// Acceleration of closing prices over the last `n` bars (second derivative).
4208    ///
4209    /// Computes `Δmom = mom[last] - mom[first]` where `mom[i] = close[i] - close[i-1]`.
4210    /// Requires at least `n + 2` bars in the series.
4211    ///
4212    /// Returns `None` if `n == 0` or fewer than `n + 2` bars exist.
4213    pub fn close_acceleration(&self, n: usize) -> Option<Decimal> {
4214        if n == 0 || self.bars.len() < n + 2 {
4215            return None;
4216        }
4217        let total = self.bars.len();
4218        let last_mom = self.bars[total - 1].close.value() - self.bars[total - 2].close.value();
4219        let first_idx = total - n - 1;
4220        let first_mom = self.bars[first_idx + 1].close.value() - self.bars[first_idx].close.value();
4221        Some(last_mom - first_mom)
4222    }
4223
4224    /// Ratio of up-close bars to down-close bars over the last `n` bars.
4225    ///
4226    /// A bar is "up" if `close > open` and "down" if `close < open`. Doji bars are ignored.
4227    ///
4228    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or there are no down bars.
4229    pub fn up_down_ratio(&self, n: usize) -> Option<Decimal> {
4230        if n == 0 || self.bars.len() < n {
4231            return None;
4232        }
4233        let start = self.bars.len() - n;
4234        let ups = self.bars[start..].iter().filter(|b| b.close.value() > b.open.value()).count();
4235        let downs = self.bars[start..].iter().filter(|b| b.close.value() < b.open.value()).count();
4236        if downs == 0 {
4237            return None;
4238        }
4239        Some(Decimal::from(ups as u32) / Decimal::from(downs as u32))
4240    }
4241
4242    /// Count of consecutive up-close bars at the end of the series, capped at `n`.
4243    ///
4244    /// Returns `0` if the series is empty or the last bar is not up, `n` if all `n`
4245    /// trailing bars are up. Returns `None` if `n == 0` or fewer than 1 bar exists.
4246    pub fn consecutive_up_bars(&self, n: usize) -> Option<usize> {
4247        if n == 0 || self.bars.is_empty() {
4248            return None;
4249        }
4250        let window_start = self.bars.len().saturating_sub(n);
4251        let count = self.bars[window_start..]
4252            .iter()
4253            .rev()
4254            .take_while(|b| b.close.value() > b.open.value())
4255            .count();
4256        Some(count)
4257    }
4258
4259    /// Z-score of the last close price vs the `n`-bar rolling mean and standard deviation.
4260    ///
4261    /// `z = (close - mean) / std_dev`
4262    ///
4263    /// Returns `None` if `n < 2`, fewer than `n` bars, or standard deviation is zero (flat prices).
4264    pub fn normalized_close(&self, n: usize) -> Option<f64> {
4265        use rust_decimal::prelude::ToPrimitive;
4266        if n < 2 || self.bars.len() < n {
4267            return None;
4268        }
4269        let start = self.bars.len() - n;
4270        let vals: Vec<f64> = self.bars[start..]
4271            .iter()
4272            .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4273            .collect();
4274        let mean = vals.iter().sum::<f64>() / n as f64;
4275        let std = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64).sqrt();
4276        if std == 0.0 { return None; }
4277        let last = *vals.last()?;
4278        Some((last - mean) / std)
4279    }
4280
4281    /// Counts the number of gap-up and gap-down opens in the last `n` bars as a pair.
4282    ///
4283    /// A gap-up is `open[i] > close[i-1]`; a gap-down is `open[i] < close[i-1]`.
4284    ///
4285    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
4286    /// Returns `(gap_ups, gap_downs)`.
4287    pub fn gap_counts(&self, n: usize) -> Option<(usize, usize)> {
4288        if n < 2 || self.bars.len() < n {
4289            return None;
4290        }
4291        let start = self.bars.len() - n;
4292        let mut ups = 0usize;
4293        let mut downs = 0usize;
4294        for i in (start + 1)..self.bars.len() {
4295            let prior_close = self.bars[i - 1].close.value();
4296            let cur_open = self.bars[i].open.value();
4297            if cur_open > prior_close { ups += 1; }
4298            else if cur_open < prior_close { downs += 1; }
4299        }
4300        Some((ups, downs))
4301    }
4302
4303    /// Count of consecutive recent bars where volume exceeded the `period`-bar average volume
4304    /// by at least `factor`.
4305    ///
4306    /// Counts backward from the most recent bar; stops at the first bar that does NOT satisfy
4307    /// the condition. Returns `None` if `period == 0`, `factor <= 0`, or the series has fewer
4308    /// than `period + 1` bars.
4309    pub fn consecutive_volume_surge(&self, period: usize, factor: f64) -> Option<usize> {
4310        use rust_decimal::prelude::ToPrimitive;
4311        if period == 0 || factor <= 0.0 || self.bars.len() <= period {
4312            return None;
4313        }
4314        let mut streak = 0usize;
4315        // Walk backward from the last bar
4316        let last = self.bars.len() - 1;
4317        let mut i = last;
4318        loop {
4319            if i < period {
4320                break;
4321            }
4322            let avg_vol: f64 = self.bars[(i - period)..i]
4323                .iter()
4324                .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4325                .sum::<f64>()
4326                / period as f64;
4327            let bar_vol = self.bars[i].volume.value().to_f64().unwrap_or(0.0);
4328            if avg_vol > 0.0 && bar_vol >= avg_vol * factor {
4329                streak += 1;
4330            } else {
4331                break;
4332            }
4333            if i == 0 { break; }
4334            i -= 1;
4335        }
4336        Some(streak)
4337    }
4338
4339    /// Ratio of the current bar's high-low range to the average range over the last `n` bars.
4340    ///
4341    /// `ratio = current_range / avg_range(last n bars)`.
4342    /// Values above 1.5 indicate a volatility expansion bar.
4343    ///
4344    /// Returns `None` if `n == 0`, the series has fewer than `n` bars, or the average range is zero.
4345    pub fn intrabar_range_expansion(&self, n: usize) -> Option<f64> {
4346        use rust_decimal::prelude::ToPrimitive;
4347        if n == 0 || self.bars.len() < n {
4348            return None;
4349        }
4350        let start = self.bars.len() - n;
4351        let avg_range: f64 = self.bars[start..]
4352            .iter()
4353            .map(|b| (b.high.value() - b.low.value()).to_f64().unwrap_or(0.0))
4354            .sum::<f64>()
4355            / n as f64;
4356        if avg_range == 0.0 {
4357            return None;
4358        }
4359        let current = self.bars.last()?;
4360        let cur_range = (current.high.value() - current.low.value())
4361            .to_f64()
4362            .unwrap_or(0.0);
4363        Some(cur_range / avg_range)
4364    }
4365
4366    /// Range ratio over the last `n` bars: `(highest_close - lowest_close) / avg_close`.
4367    ///
4368    /// Measures how much price has moved relative to its average level.
4369    ///
4370    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or avg_close is zero.
4371    pub fn price_range_ratio(&self, n: usize) -> Option<Decimal> {
4372        if n < 2 || self.bars.len() < n {
4373            return None;
4374        }
4375        let start = self.bars.len() - n;
4376        let closes: Vec<Decimal> = self.bars[start..]
4377            .iter()
4378            .map(|b| b.close.value())
4379            .collect();
4380        let hi = closes.iter().copied().max()?;
4381        let lo = closes.iter().copied().min()?;
4382        let avg = closes.iter().sum::<Decimal>() / Decimal::from(n as u32);
4383        if avg.is_zero() { return None; }
4384        Some((hi - lo) / avg)
4385    }
4386
4387    /// Rolling Pearson correlation between close prices and volume over the last `n` bars.
4388    ///
4389    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or either standard deviation is zero.
4390    pub fn close_volume_correlation(&self, n: usize) -> Option<f64> {
4391        use rust_decimal::prelude::ToPrimitive;
4392        if n < 2 || self.bars.len() < n {
4393            return None;
4394        }
4395        let start = self.bars.len() - n;
4396        let closes: Vec<f64> = self.bars[start..].iter()
4397            .map(|b| b.close.value().to_f64().unwrap_or(0.0))
4398            .collect();
4399        let vols: Vec<f64> = self.bars[start..].iter()
4400            .map(|b| b.volume.value().to_f64().unwrap_or(0.0))
4401            .collect();
4402        let n_f = n as f64;
4403        let mean_c = closes.iter().sum::<f64>() / n_f;
4404        let mean_v = vols.iter().sum::<f64>() / n_f;
4405        let cov: f64 = closes.iter().zip(vols.iter())
4406            .map(|(c, v)| (c - mean_c) * (v - mean_v))
4407            .sum::<f64>() / n_f;
4408        let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / n_f).sqrt();
4409        let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / n_f).sqrt();
4410        if std_c == 0.0 || std_v == 0.0 { return None; }
4411        Some(cov / (std_c * std_v))
4412    }
4413
4414    /// Position of the last bar's close within the `n`-bar high-low range, normalised to `[0, 1]`.
4415    ///
4416    /// `0.0` means close is at the period low; `1.0` means close is at the period high.
4417    ///
4418    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or `high == low`.
4419    pub fn close_relative_to_range(&self, n: usize) -> Option<Decimal> {
4420        if n == 0 || self.bars.len() < n {
4421            return None;
4422        }
4423        let start = self.bars.len() - n;
4424        let slice = &self.bars[start..];
4425        let high = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
4426        let low = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
4427        let range = high - low;
4428        if range.is_zero() {
4429            return None;
4430        }
4431        let close = self.bars.last()?.close.value();
4432        Some((close - low) / range)
4433    }
4434
4435    /// Simple moving average of volume over the last `n` bars.
4436    ///
4437    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4438    pub fn volume_sma(&self, n: usize) -> Option<Decimal> {
4439        if n == 0 || self.bars.len() < n {
4440            return None;
4441        }
4442        let start = self.bars.len() - n;
4443        #[allow(clippy::cast_possible_truncation)]
4444        let avg = self.bars[start..].iter().map(|b| b.volume.value()).sum::<Decimal>()
4445            / Decimal::from(n as u32);
4446        Some(avg)
4447    }
4448
4449    /// Ratio of short-term ATR to long-term ATR — a "squeeze" or "compression" indicator.
4450    ///
4451    /// `compression_ratio = ATR(fast) / ATR(slow)` where ATR is the simple average of
4452    /// true ranges. Values < 1.0 indicate the recent range is tighter than the longer-term
4453    /// range (potential compression / squeeze). Values > 1.0 indicate expansion.
4454    ///
4455    /// Returns `None` if `fast == 0`, `slow == 0`, `fast >= slow`, fewer than `slow + 1` bars
4456    /// exist, or the long-term ATR is zero.
4457    pub fn compression_ratio(&self, fast: usize, slow: usize) -> Option<Decimal> {
4458        if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow + 1 {
4459            return None;
4460        }
4461        let atr_avg = |n: usize| -> Option<Decimal> {
4462            let start = self.bars.len() - n;
4463            let trs: Decimal = self.bars[start..].iter().enumerate().map(|(i, b)| {
4464                let prev = if i == 0 { &self.bars[start - 1] } else { &self.bars[start + i - 1] };
4465                let hl = b.high.value() - b.low.value();
4466                let hpc = (b.high.value() - prev.close.value()).abs();
4467                let lpc = (b.low.value() - prev.close.value()).abs();
4468                hl.max(hpc).max(lpc)
4469            }).sum();
4470            #[allow(clippy::cast_possible_truncation)]
4471            Some(trs / Decimal::from(n as u32))
4472        };
4473        let atr_fast = atr_avg(fast)?;
4474        let atr_slow = atr_avg(slow)?;
4475        if atr_slow.is_zero() { return None; }
4476        atr_fast.checked_div(atr_slow)
4477    }
4478
4479    /// Average typical price `(H + L + C) / 3` over the last `n` bars.
4480    ///
4481    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4482    pub fn typical_price_avg(&self, n: usize) -> Option<Decimal> {
4483        if n == 0 || self.bars.len() < n {
4484            return None;
4485        }
4486        let start = self.bars.len() - n;
4487        #[allow(clippy::cast_possible_truncation)]
4488        let avg = self.bars[start..]
4489            .iter()
4490            .map(|b| (b.high.value() + b.low.value() + b.close.value()) / Decimal::from(3u32))
4491            .sum::<Decimal>()
4492            / Decimal::from(n as u32);
4493        Some(avg)
4494    }
4495
4496    /// Average ratio of candle body to high-low range over the last `n` bars.
4497    ///
4498    /// `body_to_range[i] = |close[i] - open[i]| / (high[i] - low[i])`
4499    ///
4500    /// Bars where `high == low` are skipped. Returns `None` if `n == 0`,
4501    /// fewer than `n` bars exist, or all bars are flat.
4502    pub fn avg_body_to_range(&self, n: usize) -> Option<Decimal> {
4503        if n == 0 || self.bars.len() < n {
4504            return None;
4505        }
4506        let start = self.bars.len() - n;
4507        let mut sum = Decimal::ZERO;
4508        let mut count = 0u32;
4509        for b in &self.bars[start..] {
4510            let range = b.high.value() - b.low.value();
4511            if range.is_zero() { continue; }
4512            let body = (b.close.value() - b.open.value()).abs();
4513            sum += body
4514                .checked_div(range)
4515                .unwrap_or(Decimal::ZERO);
4516            count += 1;
4517        }
4518        if count == 0 { return None; }
4519        Some(sum / Decimal::from(count))
4520    }
4521
4522    /// Rolling mean of the tick count field over the last `n` bars.
4523    ///
4524    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4525    pub fn avg_tick_count(&self, n: usize) -> Option<Decimal> {
4526        if n == 0 || self.bars.len() < n {
4527            return None;
4528        }
4529        let start = self.bars.len() - n;
4530        let sum: u64 = self.bars[start..].iter().map(|b| b.tick_count).sum();
4531        Some(Decimal::from(sum) / Decimal::from(n as u32))
4532    }
4533
4534    /// Current bar's high-low range as a fraction of the maximum range over the last `n` bars.
4535    ///
4536    /// `range_compression = last_range / max_range(n)`
4537    ///
4538    /// Values near 1 mean the current bar's range is close to the recent maximum (expansion);
4539    /// values near 0 mean the range is compressed relative to recent history.
4540    ///
4541    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or max range is zero.
4542    pub fn range_compression(&self, n: usize) -> Option<Decimal> {
4543        if n == 0 || self.bars.len() < n {
4544            return None;
4545        }
4546        let start = self.bars.len() - n;
4547        let max_range = self.bars[start..]
4548            .iter()
4549            .map(|b| b.high.value() - b.low.value())
4550            .max()?;
4551        if max_range.is_zero() {
4552            return None;
4553        }
4554        let last = self.bars.last()?;
4555        let last_range = last.high.value() - last.low.value();
4556        last_range.checked_div(max_range)
4557    }
4558
4559    /// Largest absolute gap (open-to-prev-close) as a percentage of the prior close
4560    /// over the last `n` bars.
4561    ///
4562    /// `gap_pct[i] = |open[i] - close[i-1]| / close[i-1] × 100`
4563    ///
4564    /// Returns `None` if `n < 2` or fewer than `n` bars exist, or if any close is zero.
4565    pub fn largest_gap_pct(&self, n: usize) -> Option<Decimal> {
4566        if n < 2 || self.bars.len() < n {
4567            return None;
4568        }
4569        let start = self.bars.len() - n;
4570        let mut max_gap = Decimal::ZERO;
4571        for i in start + 1..self.bars.len() {
4572            let prev_close = self.bars[i - 1].close.value();
4573            if prev_close.is_zero() { return None; }
4574            let gap = (self.bars[i].open.value() - prev_close).abs()
4575                / prev_close
4576                * Decimal::from(100u32);
4577            if gap > max_gap { max_gap = gap; }
4578        }
4579        Some(max_gap)
4580    }
4581
4582    /// Returns `1` if the last close crossed above SMA(n), `-1` if it crossed below, `0` otherwise.
4583    ///
4584    /// A crossover is defined as: the previous close was on one side of the SMA, and the
4585    /// current close is on the other side (strict inequality crossing).
4586    ///
4587    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
4588    pub fn close_sma_crossover(&self, n: usize) -> Option<i8> {
4589        if n == 0 || self.bars.len() < n + 1 {
4590            return None;
4591        }
4592        let total = self.bars.len();
4593        #[allow(clippy::cast_possible_truncation)]
4594        let sma_now: Decimal = self.bars[total - n..]
4595            .iter()
4596            .map(|b| b.close.value())
4597            .sum::<Decimal>() / Decimal::from(n as u32);
4598        let sma_prev: Decimal = self.bars[total - n - 1..total - 1]
4599            .iter()
4600            .map(|b| b.close.value())
4601            .sum::<Decimal>() / Decimal::from(n as u32);
4602        let close_now = self.bars[total - 1].close.value();
4603        let close_prev = self.bars[total - 2].close.value();
4604        if close_prev <= sma_prev && close_now > sma_now {
4605            Some(1)
4606        } else if close_prev >= sma_prev && close_now < sma_now {
4607            Some(-1)
4608        } else {
4609            Some(0)
4610        }
4611    }
4612
4613    /// Simple average true range over the last `n` bars (not EMA-smoothed).
4614    ///
4615    /// `ATR = mean(true_range[i])` where `true_range[i] = max(H−L, |H−C_prev|, |L−C_prev|)`.
4616    ///
4617    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
4618    pub fn avg_true_range(&self, n: usize) -> Option<Decimal> {
4619        if n == 0 || self.bars.len() < n + 1 {
4620            return None;
4621        }
4622        let total = self.bars.len();
4623        let start = total - n;
4624        #[allow(clippy::cast_possible_truncation)]
4625        let atr = self.bars[start..].iter().enumerate().map(|(i, b)| {
4626            let prev_close = if i == 0 {
4627                self.bars[start - 1].close.value()
4628            } else {
4629                self.bars[start + i - 1].close.value()
4630            };
4631            let hl = b.high.value() - b.low.value();
4632            let hpc = (b.high.value() - prev_close).abs();
4633            let lpc = (b.low.value() - prev_close).abs();
4634            hl.max(hpc).max(lpc)
4635        }).sum::<Decimal>() / Decimal::from(n as u32);
4636        Some(atr)
4637    }
4638
4639    /// Index (0-based within the last `n` bars) of the bar with the highest volume.
4640    ///
4641    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4642    pub fn max_volume_bar_idx(&self, n: usize) -> Option<usize> {
4643        if n == 0 || self.bars.len() < n {
4644            return None;
4645        }
4646        let start = self.bars.len() - n;
4647        self.bars[start..]
4648            .iter()
4649            .enumerate()
4650            .max_by(|a, b| a.1.volume.value().cmp(&b.1.volume.value()))
4651            .map(|(i, _)| i)
4652    }
4653
4654    /// Last bar's range (high−low) as a percentage of the simple ATR over `n` bars.
4655    ///
4656    /// Values > 100% indicate an unusually wide bar; values < 100% indicate compression.
4657    ///
4658    /// Returns `None` if `n == 0`, fewer than `n + 1` bars exist, or ATR is zero.
4659    pub fn range_pct_of_atr(&self, n: usize) -> Option<Decimal> {
4660        let atr = self.avg_true_range(n)?;
4661        if atr.is_zero() { return None; }
4662        let last = self.bars.last()?;
4663        let range = last.high.value() - last.low.value();
4664        range.checked_div(atr).map(|r| r * Decimal::ONE_HUNDRED)
4665    }
4666
4667    /// Maximum peak-to-trough drawdown of closing prices over the last `n` bars, as a percentage.
4668    ///
4669    /// Scans the window left-to-right; tracks a running peak and records the worst
4670    /// (close - peak) / peak × 100 seen (a negative number or zero).
4671    ///
4672    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4673    pub fn max_close_drawdown(&self, n: usize) -> Option<Decimal> {
4674        if n == 0 || self.bars.len() < n {
4675            return None;
4676        }
4677        let start = self.bars.len() - n;
4678        let mut peak = self.bars[start].close.value();
4679        let mut max_dd = Decimal::ZERO;
4680        for b in &self.bars[start..] {
4681            let c = b.close.value();
4682            if c > peak { peak = c; }
4683            if !peak.is_zero() {
4684                let dd = (c - peak) / peak * Decimal::ONE_HUNDRED;
4685                if dd < max_dd { max_dd = dd; }
4686            }
4687        }
4688        Some(max_dd)
4689    }
4690
4691    /// Percentage of the last `n` bars where the close is above its `sma_period`-bar SMA.
4692    ///
4693    /// For each bar `i` in the window, the SMA uses the `sma_period` bars ending at `i`.
4694    /// Bars with insufficient history (fewer than `sma_period` prior bars) are skipped.
4695    ///
4696    /// Returns `None` if `n == 0` or fewer than `n + sma_period - 1` total bars exist.
4697    pub fn close_above_sma_pct(&self, n: usize, sma_period: usize) -> Option<Decimal> {
4698        if n == 0 || sma_period == 0 || self.bars.len() < n + sma_period - 1 {
4699            return None;
4700        }
4701        let window_start = self.bars.len() - n;
4702        let mut above = 0u32;
4703        for (offset, b) in self.bars[window_start..].iter().enumerate() {
4704            let abs_idx = window_start + offset;
4705            if abs_idx + 1 < sma_period { continue; }
4706            let sma_start = abs_idx + 1 - sma_period;
4707            let sma = self.bars[sma_start..=abs_idx]
4708                .iter()
4709                .map(|x| x.close.value())
4710                .sum::<Decimal>()
4711                / Decimal::from(sma_period as u32);
4712            if b.close.value() > sma { above += 1; }
4713        }
4714        Some(Decimal::from(above) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
4715    }
4716
4717    /// Counts swing highs within the last `n` bars.
4718    ///
4719    /// A bar at index `i` is a swing high if its `high` is strictly greater than the
4720    /// highs of the `lookback` bars immediately before and after it.
4721    ///
4722    /// Returns `None` if `n == 0`, `lookback == 0`, or fewer than `n` bars exist.
4723    pub fn swing_high_count(&self, n: usize, lookback: usize) -> Option<usize> {
4724        if n == 0 || lookback == 0 || self.bars.len() < n { return None; }
4725        let start = self.bars.len() - n;
4726        let slice = &self.bars[start..];
4727        let len = slice.len();
4728        let mut count = 0usize;
4729        for i in lookback..len.saturating_sub(lookback) {
4730            let peak = slice[i].high.value();
4731            let is_high = (0..lookback).all(|k| peak > slice[i - 1 - k].high.value())
4732                && (0..lookback).all(|k| peak > slice[i + 1 + k].high.value());
4733            if is_high { count += 1; }
4734        }
4735        Some(count)
4736    }
4737
4738    /// Mean absolute open-to-prev-close gap as a percentage of the prior close
4739    /// over the last `n` bars.
4740    ///
4741    /// `gap_pct[i] = |open[i] - close[i-1]| / close[i-1] × 100`
4742    ///
4743    /// Returns `None` if `n == 0`, `n < 2`, fewer than `n` bars exist, or any prior close is zero.
4744    pub fn open_gap_pct(&self, n: usize) -> Option<Decimal> {
4745        if n < 2 || self.bars.len() < n {
4746            return None;
4747        }
4748        let start = self.bars.len() - n;
4749        let mut sum = Decimal::ZERO;
4750        for i in start..self.bars.len() {
4751            let prev_close = self.bars[i - 1].close.value();
4752            if prev_close.is_zero() { return None; }
4753            let gap = (self.bars[i].open.value() - prev_close).abs();
4754            sum += gap / prev_close * Decimal::ONE_HUNDRED;
4755        }
4756        Some(sum / Decimal::from((n - 1) as u32))
4757    }
4758
4759    /// Ratio of the average volume on up-close bars to the average volume on down-close bars
4760    /// over the last `n` bars.
4761    ///
4762    /// Returns `None` if `n == 0`, fewer than `n` bars exist, there are no up or no down bars,
4763    /// or the average down volume is zero.
4764    pub fn volume_trend_ratio(&self, n: usize) -> Option<Decimal> {
4765        if n == 0 || self.bars.len() < n {
4766            return None;
4767        }
4768        let start = self.bars.len() - n;
4769        let mut up_sum = Decimal::ZERO;
4770        let mut up_count = 0u32;
4771        let mut down_sum = Decimal::ZERO;
4772        let mut down_count = 0u32;
4773        for b in &self.bars[start..] {
4774            let v = b.volume.value();
4775            if b.close.value() > b.open.value() {
4776                up_sum += v;
4777                up_count += 1;
4778            } else if b.close.value() < b.open.value() {
4779                down_sum += v;
4780                down_count += 1;
4781            }
4782        }
4783        if up_count == 0 || down_count == 0 { return None; }
4784        let avg_up = up_sum / Decimal::from(up_count);
4785        let avg_down = down_sum / Decimal::from(down_count);
4786        if avg_down.is_zero() { return None; }
4787        avg_up.checked_div(avg_down)
4788    }
4789
4790    /// Average wick percentage over the last `n` bars.
4791    ///
4792    /// For each bar: `wick_pct = (upper_wick + lower_wick) / (high - low) × 100`.
4793    /// A value near 100 means the bar is almost entirely wicks; near 0 means it's mostly body.
4794    ///
4795    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or any bar has zero range.
4796    pub fn avg_wick_pct(&self, n: usize) -> Option<Decimal> {
4797        if n == 0 || self.bars.len() < n { return None; }
4798        let start = self.bars.len() - n;
4799        let mut sum = Decimal::ZERO;
4800        for b in &self.bars[start..] {
4801            let range = b.high.value() - b.low.value();
4802            if range.is_zero() { return None; }
4803            let upper_wick = b.high.value() - b.close.value().max(b.open.value());
4804            let lower_wick = b.close.value().min(b.open.value()) - b.low.value();
4805            sum += (upper_wick + lower_wick) / range * Decimal::from(100u32);
4806        }
4807        #[allow(clippy::cast_possible_truncation)]
4808        Some(sum / Decimal::from(n as u32))
4809    }
4810
4811    /// Percentage of the last `n` bars that moved in the same direction as the prior bar.
4812    ///
4813    /// A bar "trends" when its close-to-close direction matches the previous bar's.
4814    /// Requires `n + 1` bars.
4815    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
4816    pub fn trend_continuation_pct(&self, n: usize) -> Option<Decimal> {
4817        if n == 0 || self.bars.len() < n + 1 { return None; }
4818        let start = self.bars.len() - n - 1;
4819        let mut continuing = 0u32;
4820        for i in 0..n {
4821            let prev_dir = self.bars[start + i].close.value()
4822                .cmp(&self.bars[start + i].open.value());
4823            let curr_dir = self.bars[start + i + 1].close.value()
4824                .cmp(&self.bars[start + i + 1].open.value());
4825            if prev_dir == curr_dir && prev_dir != std::cmp::Ordering::Equal {
4826                continuing += 1;
4827            }
4828        }
4829        #[allow(clippy::cast_possible_truncation)]
4830        Some(Decimal::from(continuing) / Decimal::from(n as u32) * Decimal::from(100u32))
4831    }
4832
4833    /// Count of inside bars (high ≤ prev high AND low ≥ prev low) in the last `n` bars.
4834    pub fn inside_bar_count(&self, n: usize) -> Option<usize> {
4835        if n == 0 || self.bars.len() < n { return None; }
4836        let start = self.bars.len() - n;
4837        let mut count = 0usize;
4838        for i in start..self.bars.len() {
4839            if i == 0 { continue; }
4840            let prev = &self.bars[i - 1];
4841            let cur = &self.bars[i];
4842            if cur.high <= prev.high && cur.low >= prev.low { count += 1; }
4843        }
4844        Some(count)
4845    }
4846
4847    /// Count of outside bars (high > prev high AND low < prev low) in the last `n` bars.
4848    pub fn outside_bar_count(&self, n: usize) -> Option<usize> {
4849        if n == 0 || self.bars.len() < n { return None; }
4850        let start = self.bars.len() - n;
4851        let mut count = 0usize;
4852        for i in start..self.bars.len() {
4853            if i == 0 { continue; }
4854            let prev = &self.bars[i - 1];
4855            let cur = &self.bars[i];
4856            if cur.high > prev.high && cur.low < prev.low { count += 1; }
4857        }
4858        Some(count)
4859    }
4860
4861    /// Close price of the bar with the highest volume among the last `n` bars.
4862    ///
4863    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4864    pub fn high_volume_price(&self, n: usize) -> Option<Decimal> {
4865        if n == 0 || self.bars.len() < n { return None; }
4866        let start = self.bars.len() - n;
4867        self.bars[start..].iter()
4868            .max_by_key(|b| b.volume.value())
4869            .map(|b| b.close.value())
4870    }
4871
4872    /// Average of `close - open` over the last `n` bars.
4873    ///
4874    /// Positive values indicate a net bullish directional bias; negative indicate bearish.
4875    ///
4876    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4877    pub fn avg_close_minus_open(&self, n: usize) -> Option<Decimal> {
4878        if n == 0 || self.bars.len() < n { return None; }
4879        let start = self.bars.len() - n;
4880        let sum: Decimal = self.bars[start..]
4881            .iter()
4882            .map(|b| b.close.value() - b.open.value())
4883            .sum();
4884        #[allow(clippy::cast_possible_truncation)]
4885        Some(sum / Decimal::from(n as u32))
4886    }
4887
4888    /// Average upper shadow length as a percentage of close over the last `n` bars.
4889    ///
4890    /// `upper_shadow = high - max(open, close)`;
4891    /// returned as `upper_shadow / close × 100`.
4892    ///
4893    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4894    pub fn avg_upper_shadow_pct(&self, n: usize) -> Option<Decimal> {
4895        if n == 0 || self.bars.len() < n { return None; }
4896        let start = self.bars.len() - n;
4897        let sum: Decimal = self.bars[start..].iter().map(|b| {
4898            let body_top = b.open.value().max(b.close.value());
4899            let shadow = b.high.value() - body_top;
4900            let close = b.close.value();
4901            if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4902        }).sum();
4903        #[allow(clippy::cast_possible_truncation)]
4904        Some(sum / Decimal::from(n as u32))
4905    }
4906
4907    /// Average lower shadow length as a percentage of close over the last `n` bars.
4908    ///
4909    /// `lower_shadow = min(open, close) - low`;
4910    /// returned as `lower_shadow / close × 100`.
4911    ///
4912    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4913    pub fn avg_lower_shadow_pct(&self, n: usize) -> Option<Decimal> {
4914        if n == 0 || self.bars.len() < n { return None; }
4915        let start = self.bars.len() - n;
4916        let sum: Decimal = self.bars[start..].iter().map(|b| {
4917            let body_bottom = b.open.value().min(b.close.value());
4918            let shadow = body_bottom - b.low.value();
4919            let close = b.close.value();
4920            if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
4921        }).sum();
4922        #[allow(clippy::cast_possible_truncation)]
4923        Some(sum / Decimal::from(n as u32))
4924    }
4925
4926    /// Percentage of doji bars (|close - open| / (high - low) < 0.1) over the last `n` bars.
4927    ///
4928    /// Bars with zero range are counted as doji.
4929    ///
4930    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
4931    pub fn percent_doji(&self, n: usize) -> Option<Decimal> {
4932        if n == 0 || self.bars.len() < n { return None; }
4933        let start = self.bars.len() - n;
4934        let threshold = rust_decimal_macros::dec!(0.1);
4935        let mut doji_count = 0u32;
4936        for b in &self.bars[start..] {
4937            let range = b.high.value() - b.low.value();
4938            let body = (b.close.value() - b.open.value()).abs();
4939            if range.is_zero() || body / range < threshold {
4940                doji_count += 1;
4941            }
4942        }
4943        #[allow(clippy::cast_possible_truncation)]
4944        Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::from(100u32))
4945    }
4946
4947    /// Average close position within the high-low range over the last `n` bars.
4948    ///
4949    /// `close_range_pct = (close - low) / (high - low) × 100`
4950    ///
4951    /// 100 means close was at the high; 0 means close was at the low; 50 means mid-range.
4952    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or any bar has zero range.
4953    pub fn avg_close_range_pct(&self, n: usize) -> Option<Decimal> {
4954        if n == 0 || self.bars.len() < n { return None; }
4955        let start = self.bars.len() - n;
4956        let mut sum = Decimal::ZERO;
4957        for b in &self.bars[start..] {
4958            let range = b.high.value() - b.low.value();
4959            if range.is_zero() { return None; }
4960            sum += (b.close.value() - b.low.value()) / range * Decimal::from(100u32);
4961        }
4962        #[allow(clippy::cast_possible_truncation)]
4963        Some(sum / Decimal::from(n as u32))
4964    }
4965
4966    /// Price channel width over last `n` bars as a percentage of the channel low.
4967    ///
4968    /// `width = (max_high - min_low) / min_low × 100`
4969    ///
4970    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or `min_low` is zero.
4971    pub fn price_channel_width(&self, n: usize) -> Option<Decimal> {
4972        if n == 0 || self.bars.len() < n { return None; }
4973        let start = self.bars.len() - n;
4974        let slice = &self.bars[start..];
4975        let max_high = slice.iter().map(|b| b.high.value()).max()?;
4976        let min_low = slice.iter().map(|b| b.low.value()).min()?;
4977        if min_low.is_zero() { return None; }
4978        Some((max_high - min_low) / min_low * Decimal::ONE_HUNDRED)
4979    }
4980
4981    /// Average candle efficiency over last `n` bars.
4982    ///
4983    /// `efficiency = |close - open| / (high - low)` per bar (0 = all wick, 1 = all body).
4984    ///
4985    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or any bar has zero range.
4986    pub fn avg_candle_efficiency(&self, n: usize) -> Option<Decimal> {
4987        if n == 0 || self.bars.len() < n { return None; }
4988        let start = self.bars.len() - n;
4989        let mut sum = Decimal::ZERO;
4990        for b in &self.bars[start..] {
4991            let range = b.high.value() - b.low.value();
4992            if range.is_zero() { return None; }
4993            sum += (b.close.value() - b.open.value()).abs() / range;
4994        }
4995        #[allow(clippy::cast_possible_truncation)]
4996        Some(sum / Decimal::from(n as u32))
4997    }
4998
4999    /// Total volume on bars that made a new n-bar high.
5000    ///
5001    /// Counts volume on any bar whose high exceeds all previous bars in the window.
5002    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5003    pub fn volume_at_high(&self, n: usize) -> Option<Decimal> {
5004        if n == 0 || self.bars.len() < n { return None; }
5005        let start = self.bars.len() - n;
5006        let slice = &self.bars[start..];
5007        let mut running_high = slice[0].high.value();
5008        let mut total = slice[0].volume.value();
5009        for b in &slice[1..] {
5010            if b.high.value() > running_high {
5011                running_high = b.high.value();
5012                total += b.volume.value();
5013            }
5014        }
5015        Some(total)
5016    }
5017
5018    /// Percentage of last `n` bars where close > previous bar's close.
5019    ///
5020    /// Requires `n + 1` bars. Returns `None` if `n == 0` or insufficient bars.
5021    pub fn close_momentum_consistency(&self, n: usize) -> Option<Decimal> {
5022        if n == 0 || self.bars.len() < n + 1 { return None; }
5023        let start = self.bars.len() - n - 1;
5024        let mut up = 0u32;
5025        for i in 0..n {
5026            if self.bars[start + i + 1].close > self.bars[start + i].close {
5027                up += 1;
5028            }
5029        }
5030        #[allow(clippy::cast_possible_truncation)]
5031        Some(Decimal::from(up) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5032    }
5033
5034    /// Opening gap percentage: `(open - prev_close) / prev_close * 100` for the most recent bar.
5035    ///
5036    /// Returns `None` if fewer than 2 bars exist or `prev_close` is zero.
5037    pub fn price_gap_pct(&self) -> Option<Decimal> {
5038        let n = self.bars.len();
5039        if n < 2 { return None; }
5040        let prev_close = self.bars[n - 2].close.value();
5041        if prev_close.is_zero() { return None; }
5042        Some((self.bars[n - 1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
5043    }
5044
5045    /// Longest consecutive run of up-closes (close > prev close) across the entire series.
5046    ///
5047    /// Returns `0` if the series has fewer than 2 bars.
5048    pub fn longest_winning_streak(&self) -> usize {
5049        if self.bars.len() < 2 { return 0; }
5050        let mut max_streak = 0usize;
5051        let mut streak = 0usize;
5052        for i in 1..self.bars.len() {
5053            if self.bars[i].close > self.bars[i - 1].close {
5054                streak += 1;
5055                if streak > max_streak { max_streak = streak; }
5056            } else {
5057                streak = 0;
5058            }
5059        }
5060        max_streak
5061    }
5062
5063    /// Average absolute opening gap percentage over the last `n` bars.
5064    ///
5065    /// Each gap is `|open - prev_close| / prev_close * 100`.
5066    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
5067    pub fn avg_gap_pct(&self, n: usize) -> Option<Decimal> {
5068        if n == 0 || self.bars.len() < n + 1 { return None; }
5069        let start = self.bars.len() - n;
5070        let mut sum = Decimal::ZERO;
5071        for i in start..self.bars.len() {
5072            let prev_close = self.bars[i - 1].close.value();
5073            if prev_close.is_zero() { continue; }
5074            sum += (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5075        }
5076        #[allow(clippy::cast_possible_truncation)]
5077        Some(sum / Decimal::from(n as u32))
5078    }
5079
5080    /// Average intrabar momentum over the last `n` bars.
5081    ///
5082    /// Momentum per bar = `(close − open) / (high − low)`.  Bars with zero range are skipped.
5083    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all bars have zero range.
5084    pub fn intrabar_momentum(&self, n: usize) -> Option<Decimal> {
5085        if n == 0 || self.bars.len() < n { return None; }
5086        let start = self.bars.len() - n;
5087        let mut sum = Decimal::ZERO;
5088        let mut count = 0u32;
5089        for bar in &self.bars[start..] {
5090            let range = bar.high.value() - bar.low.value();
5091            if range.is_zero() { continue; }
5092            sum += (bar.close.value() - bar.open.value()) / range;
5093            count += 1;
5094        }
5095        if count == 0 { return None; }
5096        Some(sum / Decimal::from(count))
5097    }
5098
5099    /// Average volume per bar over the last `n` bars.
5100    ///
5101    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5102    pub fn volume_per_bar(&self, n: usize) -> Option<Decimal> {
5103        if n == 0 || self.bars.len() < n { return None; }
5104        let start = self.bars.len() - n;
5105        let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
5106        #[allow(clippy::cast_possible_truncation)]
5107        Some(sum / Decimal::from(n as u32))
5108    }
5109
5110    /// Percentage of the last `n` bars where close is within `threshold_pct`% of the bar high.
5111    ///
5112    /// A close is "near the high" when `(high − close) / high * 100 <= threshold_pct`.
5113    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5114    pub fn pct_bars_near_high(&self, n: usize, threshold_pct: Decimal) -> Option<Decimal> {
5115        if n == 0 || self.bars.len() < n { return None; }
5116        let start = self.bars.len() - n;
5117        let mut near = 0u32;
5118        for bar in &self.bars[start..] {
5119            let high = bar.high.value();
5120            if high.is_zero() { continue; }
5121            let dist_pct = (high - bar.close.value()) / high * Decimal::ONE_HUNDRED;
5122            if dist_pct <= threshold_pct {
5123                near += 1;
5124            }
5125        }
5126        #[allow(clippy::cast_possible_truncation)]
5127        Some(Decimal::from(near) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5128    }
5129
5130    /// Average candle body size as a percentage of the bar's range over the last `n` bars.
5131    ///
5132    /// Body % per bar = `|close − open| / (high − low) * 100`.  Bars with zero range are skipped.
5133    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all bars have zero range.
5134    pub fn avg_body_pct(&self, n: usize) -> Option<Decimal> {
5135        if n == 0 || self.bars.len() < n { return None; }
5136        let start = self.bars.len() - n;
5137        let mut sum = Decimal::ZERO;
5138        let mut count = 0u32;
5139        for bar in &self.bars[start..] {
5140            let range = bar.high.value() - bar.low.value();
5141            if range.is_zero() { continue; }
5142            sum += (bar.close.value() - bar.open.value()).abs() / range * Decimal::ONE_HUNDRED;
5143            count += 1;
5144        }
5145        if count == 0 { return None; }
5146        Some(sum / Decimal::from(count))
5147    }
5148
5149    /// Average upper-to-lower wick ratio over the last `n` bars.
5150    ///
5151    /// Upper wick = `high − max(open, close)`.  Lower wick = `min(open, close) − low`.
5152    /// Bars where the lower wick is zero are skipped.
5153    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or all bars have zero lower wick.
5154    pub fn tail_ratio(&self, n: usize) -> Option<Decimal> {
5155        if n == 0 || self.bars.len() < n { return None; }
5156        let start = self.bars.len() - n;
5157        let mut sum = Decimal::ZERO;
5158        let mut count = 0u32;
5159        for bar in &self.bars[start..] {
5160            let body_top = bar.open.value().max(bar.close.value());
5161            let body_bot = bar.open.value().min(bar.close.value());
5162            let upper = bar.high.value() - body_top;
5163            let lower = body_bot - bar.low.value();
5164            if lower.is_zero() { continue; }
5165            sum += upper / lower;
5166            count += 1;
5167        }
5168        if count == 0 { return None; }
5169        Some(sum / Decimal::from(count))
5170    }
5171
5172    /// Ratio of the average volume over the last `n` bars to the average volume over the last `m` bars.
5173    ///
5174    /// Useful for detecting volume surges when `n < m`.
5175    /// Returns `None` if `n == 0`, `m == 0`, insufficient bars, or the `m`-bar average is zero.
5176    pub fn avg_volume_ratio(&self, n: usize, m: usize) -> Option<Decimal> {
5177        let len = self.bars.len();
5178        if n == 0 || m == 0 || len < n.max(m) { return None; }
5179        #[allow(clippy::cast_possible_truncation)]
5180        let avg_n: Decimal = self.bars[len - n..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5181            / Decimal::from(n as u32);
5182        #[allow(clippy::cast_possible_truncation)]
5183        let avg_m: Decimal = self.bars[len - m..].iter().map(|b| b.volume.value()).sum::<Decimal>()
5184            / Decimal::from(m as u32);
5185        if avg_m.is_zero() { return None; }
5186        Some(avg_n / avg_m)
5187    }
5188
5189    /// Pearson correlation between open and close prices over the last `n` bars.
5190    ///
5191    /// Returns `None` if `n < 2`, fewer than `n` bars exist, or standard deviations are zero.
5192    pub fn open_close_correlation(&self, n: usize) -> Option<f64> {
5193        use rust_decimal::prelude::ToPrimitive;
5194        if n < 2 || self.bars.len() < n { return None; }
5195        let start = self.bars.len() - n;
5196        let opens: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.open.value().to_f64()).collect();
5197        let closes: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.close.value().to_f64()).collect();
5198        if opens.len() < 2 { return None; }
5199        let nf = opens.len() as f64;
5200        let mean_o = opens.iter().sum::<f64>() / nf;
5201        let mean_c = closes.iter().sum::<f64>() / nf;
5202        let cov: f64 = opens.iter().zip(closes.iter()).map(|(o, c)| (o - mean_o) * (c - mean_c)).sum::<f64>() / nf;
5203        let std_o = (opens.iter().map(|o| (o - mean_o).powi(2)).sum::<f64>() / nf).sqrt();
5204        let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
5205        if std_o == 0.0 || std_c == 0.0 { return None; }
5206        Some(cov / (std_o * std_c))
5207    }
5208
5209    /// Price acceleration: difference in average close-to-close change between the first and
5210    /// second halves of the last `n` bars.
5211    ///
5212    /// Positive value = momentum is building; negative = fading.
5213    /// Returns `None` if `n < 4` or fewer than `n + 1` bars exist.
5214    pub fn price_acceleration(&self, n: usize) -> Option<Decimal> {
5215        if n < 4 || self.bars.len() < n + 1 { return None; }
5216        let start = self.bars.len() - n - 1;
5217        let half = n / 2;
5218        let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5219            .map(|i| self.bars[i + 1].close.value() - self.bars[i].close.value())
5220            .collect();
5221        #[allow(clippy::cast_possible_truncation)]
5222        let avg_first = changes[..half].iter().sum::<Decimal>() / Decimal::from(half as u32);
5223        #[allow(clippy::cast_possible_truncation)]
5224        let avg_second = changes[half..].iter().sum::<Decimal>() / Decimal::from((changes.len() - half) as u32);
5225        Some(avg_second - avg_first)
5226    }
5227
5228    /// Sample skewness of close-to-close log-returns over the last `n` bars.
5229    ///
5230    /// Requires at least `n + 1` bars and 3+ valid returns.
5231    /// Returns `None` if `n == 0`, insufficient bars, or returns have zero variance.
5232    pub fn returns_skewness(&self, n: usize) -> Option<f64> {
5233        use rust_decimal::prelude::ToPrimitive;
5234        if n == 0 || self.bars.len() < n + 1 { return None; }
5235        let start = self.bars.len() - n - 1;
5236        let returns: Vec<f64> = (start..self.bars.len() - 1)
5237            .filter_map(|i| {
5238                let prev = self.bars[i].close.value().to_f64()?;
5239                let curr = self.bars[i + 1].close.value().to_f64()?;
5240                if prev == 0.0 { return None; }
5241                Some((curr / prev).ln())
5242            })
5243            .collect();
5244        if returns.len() < 3 { return None; }
5245        let m = returns.len() as f64;
5246        let mean = returns.iter().sum::<f64>() / m;
5247        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m;
5248        let std = variance.sqrt();
5249        if std == 0.0 { return None; }
5250        Some(returns.iter().map(|r| ((r - mean) / std).powi(3)).sum::<f64>() / m)
5251    }
5252
5253    /// Z-score of the most recent bar's volume relative to the last `n` bars.
5254    ///
5255    /// Returns `None` if `n < 2` or fewer than `n` bars exist or volume std-dev is zero.
5256    pub fn volume_zscore(&self, n: usize) -> Option<f64> {
5257        use rust_decimal::prelude::ToPrimitive;
5258        if n < 2 || self.bars.len() < n { return None; }
5259        let start = self.bars.len() - n;
5260        let vols: Vec<f64> = self.bars[start..].iter()
5261            .filter_map(|b| b.volume.value().to_f64())
5262            .collect();
5263        if vols.len() < 2 { return None; }
5264        let m = vols.len() as f64;
5265        let mean = vols.iter().sum::<f64>() / m;
5266        let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
5267        let std = variance.sqrt();
5268        if std == 0.0 { return None; }
5269        let last_vol = self.bars.last()?.volume.value().to_f64()?;
5270        Some((last_vol - mean) / std)
5271    }
5272
5273    /// Ratio of upper shadow to lower shadow for the most recent bar.
5274    ///
5275    /// Upper shadow = `high - max(open, close)`.
5276    /// Lower shadow = `min(open, close) - low`.
5277    /// Returns `None` if the lower shadow is zero.
5278    pub fn upper_lower_shadow_ratio(&self) -> Option<Decimal> {
5279        let bar = self.bars.last()?;
5280        let body_top = bar.open.value().max(bar.close.value());
5281        let body_bot = bar.open.value().min(bar.close.value());
5282        let upper = bar.high.value() - body_top;
5283        let lower = body_bot - bar.low.value();
5284        if lower.is_zero() { return None; }
5285        Some(upper / lower)
5286    }
5287
5288    /// Simple moving average of close over last `n` bars.
5289    fn sma(&self, n: usize) -> Option<Decimal> {
5290        if n == 0 || self.bars.len() < n { return None; }
5291        let start = self.bars.len() - n;
5292        let sum: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
5293        Some(sum / Decimal::from(n as u32))
5294    }
5295
5296    /// Mean true range over last `n` bars (requires `n + 1` bars for true range).
5297    fn atr(&self, n: usize) -> Option<Decimal> {
5298        if n == 0 || self.bars.len() < n + 1 { return None; }
5299        let start = self.bars.len() - n - 1;
5300        let mut sum = Decimal::ZERO;
5301        for i in start..self.bars.len() - 1 {
5302            let pc = self.bars[i].close.value();
5303            let h = self.bars[i + 1].high.value();
5304            let l = self.bars[i + 1].low.value();
5305            let tr = (h - l).max((h - pc).abs()).max((l - pc).abs());
5306            sum += tr;
5307        }
5308        Some(sum / Decimal::from(n as u32))
5309    }
5310
5311    /// Exponential moving average of close over `n` bars (SMA seed).
5312    fn ema(&self, n: usize) -> Option<Decimal> {
5313        if n == 0 || self.bars.len() < n { return None; }
5314        let start = self.bars.len() - n;
5315        let seed: Decimal = self.bars[start..start + n.min(self.bars.len() - start)]
5316            .iter().map(|b| b.close.value()).sum::<Decimal>()
5317            / Decimal::from(n as u32);
5318        let k = Decimal::from(2u32) / Decimal::from((n + 1) as u32);
5319        let mut e = seed;
5320        // If extra bars exist beyond seed, smooth them
5321        for bar in &self.bars[start + n..] {
5322            e = e * (Decimal::ONE - k) + bar.close.value() * k;
5323        }
5324        Some(e)
5325    }
5326
5327    /// Mean-reversion score: `|close - sma(n)| / atr(n)`.
5328    ///
5329    /// High values (> 2) suggest price is extended and may revert toward the mean.
5330    /// Returns `None` if `n == 0`, insufficient bars, or ATR is zero.
5331    pub fn mean_reversion_score(&self, n: usize) -> Option<Decimal> {
5332        let close = self.bars.last()?.close.value();
5333        let sma = self.sma(n)?;
5334        let atr = self.atr(n)?;
5335        if atr.is_zero() { return None; }
5336        Some((close - sma).abs() / atr)
5337    }
5338
5339    /// Volume Price Trend: cumulative `(close_pct_change * volume)` over last `n` bars.
5340    ///
5341    /// Combines momentum and volume into a single trend confirmation signal.
5342    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
5343    pub fn volume_price_trend(&self, n: usize) -> Option<Decimal> {
5344        if n == 0 || self.bars.len() < n + 1 { return None; }
5345        let start = self.bars.len() - n - 1;
5346        let mut vpt = Decimal::ZERO;
5347        for i in start..self.bars.len() - 1 {
5348            let prev_close = self.bars[i].close.value();
5349            if prev_close.is_zero() { continue; }
5350            let pct_chg = (self.bars[i + 1].close.value() - prev_close) / prev_close;
5351            vpt += pct_chg * self.bars[i + 1].volume.value();
5352        }
5353        Some(vpt)
5354    }
5355
5356    /// Number of trailing consecutive bars where close < previous close.
5357    ///
5358    /// Returns `0` if the series has fewer than 2 bars or the most recent bar is not bearish.
5359    pub fn bear_run_length(&self) -> usize {
5360        let n = self.bars.len();
5361        if n < 2 { return 0; }
5362        let mut count = 0;
5363        let mut i = n - 1;
5364        while i > 0 && self.bars[i].close.value() < self.bars[i - 1].close.value() {
5365            count += 1;
5366            i -= 1;
5367        }
5368        count
5369    }
5370
5371    /// Average True Range expressed as a percentage of the closing price over `n` bars.
5372    ///
5373    /// `atr_pct = ATR(n) / close * 100`.
5374    /// Returns `None` if `n == 0`, insufficient bars, or the last close is zero.
5375    pub fn avg_true_range_pct(&self, n: usize) -> Option<Decimal> {
5376        let atr = self.atr(n)?;
5377        let close = self.bars.last()?.close.value();
5378        if close.is_zero() { return None; }
5379        Some(atr / close * Decimal::ONE_HUNDRED)
5380    }
5381
5382    /// Deviation of the last close from its EMA(n) as a percentage of the close.
5383    ///
5384    /// `(close - EMA(n)) / close * 100`.  Positive = above EMA, negative = below.
5385    /// Returns `None` if `n == 0`, insufficient bars, or the last close is zero.
5386    pub fn close_vs_ema(&self, n: usize) -> Option<Decimal> {
5387        let ema = self.ema(n)?;
5388        let close = self.bars.last()?.close.value();
5389        if close.is_zero() { return None; }
5390        Some((close - ema) / close * Decimal::ONE_HUNDRED)
5391    }
5392
5393    /// Average per-bar volume change (linear slope) over the last `n` bars.
5394    ///
5395    /// Positive = volume trend is increasing; negative = decreasing.
5396    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
5397    pub fn volume_momentum(&self, n: usize) -> Option<Decimal> {
5398        if n < 2 || self.bars.len() < n { return None; }
5399        let start = self.bars.len() - n;
5400        let changes: Vec<Decimal> = (start..self.bars.len() - 1)
5401            .map(|i| self.bars[i + 1].volume.value() - self.bars[i].volume.value())
5402            .collect();
5403        if changes.is_empty() { return None; }
5404        #[allow(clippy::cast_possible_truncation)]
5405        Some(changes.iter().sum::<Decimal>() / Decimal::from(changes.len() as u32))
5406    }
5407
5408    /// Returns the 0-based index from the end (0 = most recent) of the bar with the highest
5409    /// volume in the last `n` bars.
5410    ///
5411    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5412    pub fn max_volume_bar(&self, n: usize) -> Option<usize> {
5413        if n == 0 || self.bars.len() < n { return None; }
5414        let start = self.bars.len() - n;
5415        let (rel_idx, _) = self.bars[start..]
5416            .iter()
5417            .enumerate()
5418            .max_by_key(|(_, b)| b.volume.value())?;
5419        Some(n - 1 - rel_idx)
5420    }
5421
5422    /// Number of bars in the last `n` where the absolute open-to-open gap ≥ `min_pct`%.
5423    ///
5424    /// Gap is measured as `|open - prev_close| / prev_close * 100`.
5425    /// Returns `None` if `n == 0` or fewer than `n + 1` bars exist.
5426    pub fn gap_count(&self, n: usize, min_pct: Decimal) -> Option<usize> {
5427        if n == 0 || self.bars.len() < n + 1 { return None; }
5428        let start = self.bars.len() - n;
5429        let count = (start..self.bars.len()).filter(|&i| {
5430            let prev_close = self.bars[i - 1].close.value();
5431            if prev_close.is_zero() { return false; }
5432            let gap = (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
5433            gap >= min_pct
5434        }).count();
5435        Some(count)
5436    }
5437
5438    /// Mean close-to-close percentage change over the last `n` bars.
5439    ///
5440    /// `avg = mean((close[i] - close[i-1]) / close[i-1] * 100)` for the last n periods.
5441    /// Returns `None` if `n == 0`, fewer than `n + 1` bars exist, or any previous close is zero.
5442    pub fn avg_close_pct_change(&self, n: usize) -> Option<Decimal> {
5443        if n == 0 || self.bars.len() < n + 1 { return None; }
5444        let start = self.bars.len() - n - 1;
5445        let mut sum = Decimal::ZERO;
5446        for i in start..self.bars.len() - 1 {
5447            let prev = self.bars[i].close.value();
5448            if prev.is_zero() { return None; }
5449            sum += (self.bars[i + 1].close.value() - prev) / prev * Decimal::ONE_HUNDRED;
5450        }
5451        #[allow(clippy::cast_possible_truncation)]
5452        Some(sum / Decimal::from(n as u32))
5453    }
5454
5455    /// Bollinger Band width: `(upper - lower) / sma(n)`.
5456    ///
5457    /// Normalized band expansion metric. Values approaching zero indicate a squeeze.
5458    /// Returns `None` if `n == 0`, insufficient bars, or SMA is zero.
5459    pub fn bollinger_width(&self, n: usize, multiplier: Decimal) -> Option<Decimal> {
5460        let sma = self.sma(n)?;
5461        if sma.is_zero() { return None; }
5462        let std = self.std_dev(n)?;
5463        let upper = sma + multiplier * std;
5464        let lower = sma - multiplier * std;
5465        Some((upper - lower) / sma)
5466    }
5467
5468    /// Current consecutive run of bars where close >= SMA(period).
5469    ///
5470    /// Counts backward from the most recent bar. Returns `0` if current close is below SMA
5471    /// or insufficient bars exist.
5472    pub fn close_above_ma_streak(&self, period: usize) -> usize {
5473        if self.bars.len() < period { return 0; }
5474        let mut streak = 0usize;
5475        // Walk backward: for each bar compute its SMA and check
5476        for i in (period - 1..self.bars.len()).rev() {
5477            let sum: Decimal = (0..period).map(|j| self.bars[i + 1 - period + j].close.value()).sum();
5478            #[allow(clippy::cast_possible_truncation)]
5479            let sma = sum / Decimal::from(period as u32);
5480            if self.bars[i].close.value() >= sma {
5481                streak += 1;
5482            } else {
5483                break;
5484            }
5485        }
5486        streak
5487    }
5488
5489    /// Average `|close − open| / (high − low)` (body-to-range ratio) over the last `n` bars.
5490    ///
5491    /// Bars with zero range are skipped.  Returns `None` if all bars have zero range.
5492    pub fn avg_body_to_range_ratio(&self, n: usize) -> Option<Decimal> {
5493        if n == 0 || self.bars.len() < n { return None; }
5494        let start = self.bars.len() - n;
5495        let mut sum = Decimal::ZERO;
5496        let mut count = 0u32;
5497        for bar in &self.bars[start..] {
5498            let range = bar.high.value() - bar.low.value();
5499            if range.is_zero() { continue; }
5500            sum += (bar.close.value() - bar.open.value()).abs() / range;
5501            count += 1;
5502        }
5503        if count == 0 { return None; }
5504        Some(sum / Decimal::from(count))
5505    }
5506
5507    /// Net directional volume over the last `n` bars.
5508    ///
5509    /// Approximates buy-side volume as `volume * (close - low) / (high - low)` and sell-side as
5510    /// the remainder.  Net = buy_vol − sell_vol.  Bars with zero range contribute zero.
5511    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5512    pub fn net_volume(&self, n: usize) -> Option<Decimal> {
5513        if n == 0 || self.bars.len() < n { return None; }
5514        let start = self.bars.len() - n;
5515        let mut net = Decimal::ZERO;
5516        for bar in &self.bars[start..] {
5517            let range = bar.high.value() - bar.low.value();
5518            let vol = bar.volume.value();
5519            if range.is_zero() { continue; }
5520            let buy_frac = (bar.close.value() - bar.low.value()) / range;
5521            let buy_vol = vol * buy_frac;
5522            let sell_vol = vol - buy_vol;
5523            net += buy_vol - sell_vol;
5524        }
5525        Some(net)
5526    }
5527
5528    /// Average `high − open` over the last `n` bars.
5529    ///
5530    /// Measures the typical upper extension from the open.
5531    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5532    pub fn avg_high_minus_open(&self, n: usize) -> Option<Decimal> {
5533        if n == 0 || self.bars.len() < n { return None; }
5534        let start = self.bars.len() - n;
5535        #[allow(clippy::cast_possible_truncation)]
5536        let sum: Decimal = self.bars[start..].iter()
5537            .map(|b| b.high.value() - b.open.value())
5538            .sum();
5539        Some(sum / Decimal::from(n as u32))
5540    }
5541
5542    /// Percentage of the last `n` bars where close is in the upper half of the bar's range.
5543    ///
5544    /// A close is in the upper half when `close >= (high + low) / 2`.
5545    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5546    pub fn close_consistency(&self, n: usize) -> Option<Decimal> {
5547        if n == 0 || self.bars.len() < n { return None; }
5548        let start = self.bars.len() - n;
5549        let upper = self.bars[start..].iter().filter(|b| {
5550            let mid = (b.high.value() + b.low.value()) / Decimal::TWO;
5551            b.close.value() >= mid
5552        }).count();
5553        #[allow(clippy::cast_possible_truncation)]
5554        Some(Decimal::from(upper as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5555    }
5556
5557    /// Difference in momentum between a fast window and a slow window.
5558    ///
5559    /// Momentum is `close - close[n]`. Divergence = `fast_momentum - slow_momentum`.
5560    /// Positive values indicate short-term momentum is stronger than longer-term.
5561    /// Returns `None` if `fast >= slow` or insufficient bars.
5562    pub fn momentum_divergence(&self, fast: usize, slow: usize) -> Option<Decimal> {
5563        if fast == 0 || slow == 0 || fast >= slow { return None; }
5564        if self.bars.len() <= slow { return None; }
5565        let n = self.bars.len();
5566        let current = self.bars[n - 1].close.value();
5567        let fast_prev = self.bars[n - 1 - fast].close.value();
5568        let slow_prev = self.bars[n - 1 - slow].close.value();
5569        Some((current - fast_prev) - (current - slow_prev))
5570    }
5571
5572    /// Normalized price range: `(highest_high - lowest_low) / lowest_low * 100` over `n` bars.
5573    ///
5574    /// Returns `None` if `n == 0`, fewer than `n` bars, or lowest_low is zero.
5575    pub fn price_range_pct(&self, n: usize) -> Option<Decimal> {
5576        if n == 0 || self.bars.len() < n { return None; }
5577        let start = self.bars.len() - n;
5578        let high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5579        let low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5580        if low.is_zero() { return None; }
5581        Some((high - low) / low * Decimal::ONE_HUNDRED)
5582    }
5583
5584    /// Average signed `close − open` over the last `n` bars.
5585    ///
5586    /// Positive = net bullish body on average; negative = net bearish.
5587    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5588    pub fn avg_open_to_close(&self, n: usize) -> Option<Decimal> {
5589        if n == 0 || self.bars.len() < n { return None; }
5590        let start = self.bars.len() - n;
5591        #[allow(clippy::cast_possible_truncation)]
5592        let sum: Decimal = self.bars[start..].iter()
5593            .map(|b| b.close.value() - b.open.value())
5594            .sum();
5595        Some(sum / Decimal::from(n as u32))
5596    }
5597
5598    /// Change in H−L range: last-n average range minus prior-n average range.
5599    ///
5600    /// Positive = ranges are expanding; negative = contracting.
5601    /// Returns `None` if `n == 0` or fewer than `2 * n` bars exist.
5602    pub fn price_range_expansion(&self, n: usize) -> Option<Decimal> {
5603        if n == 0 || self.bars.len() < 2 * n { return None; }
5604        let len = self.bars.len();
5605        #[allow(clippy::cast_possible_truncation)]
5606        let n_dec = Decimal::from(n as u32);
5607        let recent_sum: Decimal = self.bars[len - n..].iter()
5608            .map(|b| b.high.value() - b.low.value())
5609            .sum();
5610        let prior_sum: Decimal = self.bars[len - 2 * n..len - n].iter()
5611            .map(|b| b.high.value() - b.low.value())
5612            .sum();
5613        Some((recent_sum - prior_sum) / n_dec)
5614    }
5615
5616    /// Fraction of total volume contributed by up-bars (close > open) over the last `n` bars.
5617    ///
5618    /// Returns `None` if `n == 0`, fewer than `n` bars exist, or total volume is zero.
5619    pub fn up_volume_fraction(&self, n: usize) -> Option<Decimal> {
5620        if n == 0 || self.bars.len() < n { return None; }
5621        let start = self.bars.len() - n;
5622        let mut up_vol = Decimal::ZERO;
5623        let mut total_vol = Decimal::ZERO;
5624        for bar in &self.bars[start..] {
5625            let v = bar.volume.value();
5626            total_vol += v;
5627            if bar.close.value() > bar.open.value() {
5628                up_vol += v;
5629            }
5630        }
5631        if total_vol.is_zero() { return None; }
5632        Some(up_vol / total_vol)
5633    }
5634
5635    /// Sample standard deviation of volume over the last `n` bars.
5636    ///
5637    /// Returns `None` if `n < 2` or fewer than `n` bars exist.
5638    pub fn std_volume(&self, n: usize) -> Option<f64> {
5639        use rust_decimal::prelude::ToPrimitive;
5640        if n < 2 || self.bars.len() < n { return None; }
5641        let start = self.bars.len() - n;
5642        let vols: Vec<f64> = self.bars[start..].iter()
5643            .filter_map(|b| b.volume.value().to_f64())
5644            .collect();
5645        if vols.len() < 2 { return None; }
5646        let nf = vols.len() as f64;
5647        let mean = vols.iter().sum::<f64>() / nf;
5648        let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0);
5649        Some(var.sqrt())
5650    }
5651
5652    /// Longest consecutive run of bars with close < prev close across the entire series.
5653    ///
5654    /// Returns `0` if the series has fewer than 2 bars.
5655    pub fn longest_losing_streak(&self) -> usize {
5656        if self.bars.len() < 2 { return 0; }
5657        let mut max_streak = 0usize;
5658        let mut current = 0usize;
5659        for i in 1..self.bars.len() {
5660            if self.bars[i].close.value() < self.bars[i - 1].close.value() {
5661                current += 1;
5662                if current > max_streak { max_streak = current; }
5663            } else {
5664                current = 0;
5665            }
5666        }
5667        max_streak
5668    }
5669
5670    /// Maximum close price over the last `n` bars.
5671    ///
5672    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5673    pub fn recent_max_close(&self, n: usize) -> Option<Decimal> {
5674        if n == 0 || self.bars.len() < n { return None; }
5675        let start = self.bars.len() - n;
5676        self.bars[start..].iter().map(|b| b.close.value()).max()
5677    }
5678
5679    /// Minimum close price over the last `n` bars.
5680    ///
5681    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5682    pub fn recent_min_close(&self, n: usize) -> Option<Decimal> {
5683        if n == 0 || self.bars.len() < n { return None; }
5684        let start = self.bars.len() - n;
5685        self.bars[start..].iter().map(|b| b.close.value()).min()
5686    }
5687
5688    /// Chaikin oscillator: fast EMA of (close-change * volume) minus slow EMA.
5689    ///
5690    /// Measures divergence between fast and slow accumulation/distribution momentum.
5691    /// Returns `None` if `fast == 0`, `slow == 0`, `fast >= slow`, or not enough bars.
5692    pub fn chaikin_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
5693        if fast == 0 || slow == 0 || fast >= slow || self.bars.len() <= slow { return None; }
5694        let n = self.bars.len();
5695        // fast EMA: use simple approximation via alpha = 2/(fast+1)
5696        // We compute EMA of (close * volume) over last `slow` bars
5697        let alpha_fast = Decimal::TWO / Decimal::from(fast + 1);
5698        let alpha_slow = Decimal::TWO / Decimal::from(slow + 1);
5699        let start = n - slow;
5700        let mut ema_fast = self.bars[start].close.value() * self.bars[start].volume.value();
5701        let mut ema_slow = ema_fast;
5702        for bar in &self.bars[start + 1..] {
5703            let adv = bar.close.value() * bar.volume.value();
5704            ema_fast = alpha_fast * adv + (Decimal::ONE - alpha_fast) * ema_fast;
5705            ema_slow = alpha_slow * adv + (Decimal::ONE - alpha_slow) * ema_slow;
5706        }
5707        Some(ema_fast - ema_slow)
5708    }
5709
5710    /// Net bullish/bearish bias over the last `n` bars.
5711    ///
5712    /// Returns `(bullish_count as i64) - (bearish_count as i64)` where
5713    /// bullish = `close > open` and bearish = `close < open`.
5714    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5715    pub fn candle_body_trend(&self, n: usize) -> Option<i64> {
5716        if n == 0 || self.bars.len() < n { return None; }
5717        let start = self.bars.len() - n;
5718        let bull = self.bars[start..].iter()
5719            .filter(|b| b.close.value() > b.open.value()).count() as i64;
5720        let bear = self.bars[start..].iter()
5721            .filter(|b| b.close.value() < b.open.value()).count() as i64;
5722        Some(bull - bear)
5723    }
5724
5725    /// Percentage of bars over the last `n` that are doji (body ≤ 10% of range).
5726    pub fn pct_doji(&self, n: usize) -> Option<Decimal> {
5727        if n == 0 || self.bars.len() < n { return None; }
5728        let start = self.bars.len() - n;
5729        let doji_count = self.bars[start..].iter().filter(|b| {
5730            let range = b.high.value() - b.low.value();
5731            if range.is_zero() { return true; }
5732            let body = (b.close.value() - b.open.value()).abs();
5733            body / range <= Decimal::new(1, 1)
5734        }).count() as u32;
5735        Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5736    }
5737
5738    /// Linear regression slope direction of closes over `n` bars.
5739    /// Returns `+1` if upward, `-1` if downward, `0` if flat.
5740    pub fn recent_close_trend(&self, n: usize) -> Option<i64> {
5741        if n < 2 || self.bars.len() < n { return None; }
5742        let start = self.bars.len() - n;
5743        let closes: Vec<f64> = self.bars[start..]
5744            .iter()
5745            .map(|b| b.close.value().to_string().parse::<f64>().unwrap_or(0.0))
5746            .collect();
5747        let m = closes.len() as f64;
5748        let x_mean = (m - 1.0) / 2.0;
5749        let y_mean: f64 = closes.iter().sum::<f64>() / m;
5750        let mut num = 0.0f64;
5751        let mut den = 0.0f64;
5752        for (i, &y) in closes.iter().enumerate() {
5753            let dx = i as f64 - x_mean;
5754            num += dx * (y - y_mean);
5755            den += dx * dx;
5756        }
5757        if den == 0.0 { return Some(0); }
5758        let slope = num / den;
5759        if slope > 1e-10 { Some(1) } else if slope < -1e-10 { Some(-1) } else { Some(0) }
5760    }
5761
5762    /// Max high minus min low over the last `n` bars.
5763    pub fn high_low_range(&self, n: usize) -> Option<Decimal> {
5764        if n == 0 || self.bars.len() < n { return None; }
5765        let start = self.bars.len() - n;
5766        let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5767        let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5768        Some(max_high - min_low)
5769    }
5770
5771    /// Count of bars in the last `n` where volume exceeds the rolling average over those `n` bars.
5772    ///
5773    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
5774    pub fn volume_above_avg_count(&self, n: usize) -> Option<usize> {
5775        if n == 0 || self.bars.len() < n { return None; }
5776        let start = self.bars.len() - n;
5777        let vols: Vec<Decimal> = self.bars[start..].iter().map(|b| b.volume.value()).collect();
5778        let avg = vols.iter().sum::<Decimal>() / Decimal::from(n);
5779        Some(vols.iter().filter(|&&v| v > avg).count())
5780    }
5781
5782    /// Ratio of the last bar's range to the average range over `n` bars.
5783    ///
5784    /// Returns `None` if fewer than `n` bars exist or average range is zero.
5785    pub fn range_vs_atr_ratio(&self, n: usize) -> Option<Decimal> {
5786        if n == 0 || self.bars.len() < n { return None; }
5787        let start = self.bars.len() - n;
5788        let avg_range = self.bars[start..].iter()
5789            .map(|b| b.high.value() - b.low.value())
5790            .sum::<Decimal>() / Decimal::from(n);
5791        if avg_range.is_zero() { return None; }
5792        let last = self.bars.last()?;
5793        Some((last.high.value() - last.low.value()) / avg_range)
5794    }
5795
5796    /// Average volume on bullish bars (close > open) over the last `n` bars.
5797    pub fn avg_volume_on_up_bars(&self, n: usize) -> Option<Decimal> {
5798        if n == 0 || self.bars.len() < n { return None; }
5799        let start = self.bars.len() - n;
5800        let up_vols: Vec<Decimal> = self.bars[start..].iter()
5801            .filter(|b| b.close.value() > b.open.value())
5802            .map(|b| b.volume.value())
5803            .collect();
5804        if up_vols.is_empty() { return None; }
5805        Some(up_vols.iter().sum::<Decimal>() / Decimal::from(up_vols.len() as u32))
5806    }
5807
5808    /// Average volume on bearish bars (close < open) over the last `n` bars.
5809    pub fn avg_volume_on_down_bars(&self, n: usize) -> Option<Decimal> {
5810        if n == 0 || self.bars.len() < n { return None; }
5811        let start = self.bars.len() - n;
5812        let down_vols: Vec<Decimal> = self.bars[start..].iter()
5813            .filter(|b| b.close.value() < b.open.value())
5814            .map(|b| b.volume.value())
5815            .collect();
5816        if down_vols.is_empty() { return None; }
5817        Some(down_vols.iter().sum::<Decimal>() / Decimal::from(down_vols.len() as u32))
5818    }
5819
5820    /// Percentage of bars over the last `n` where close > open.
5821    pub fn pct_bars_close_above_open(&self, n: usize) -> Option<Decimal> {
5822        if n == 0 || self.bars.len() < n { return None; }
5823        let start = self.bars.len() - n;
5824        let bull = self.bars[start..].iter()
5825            .filter(|b| b.close.value() > b.open.value())
5826            .count() as u32;
5827        Some(Decimal::from(bull) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
5828    }
5829
5830    /// Where the latest open sits within the recent `n`-bar high-low range, in `[0, 1]`.
5831    ///
5832    /// `0` = at the low, `1` = at the high. Returns `None` if range is zero.
5833    pub fn open_range_position(&self, n: usize) -> Option<Decimal> {
5834        if n == 0 || self.bars.len() < n { return None; }
5835        let start = self.bars.len() - n;
5836        let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
5837        let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
5838        let range = max_high - min_low;
5839        if range.is_zero() { return None; }
5840        let last_open = self.bars.last()?.open.value();
5841        Some((last_open - min_low) / range)
5842    }
5843
5844    /// Count of bars in last `n` where `|open - prev_close| / prev_close >= threshold_pct / 100`.
5845    ///
5846    /// Returns `None` if `n < 2` or fewer than `n+1` bars exist.
5847    pub fn overnight_gap_count(&self, n: usize, threshold_pct: Decimal) -> Option<usize> {
5848        if n < 2 || self.bars.len() <= n { return None; }
5849        let start = self.bars.len() - n;
5850        let threshold = threshold_pct / Decimal::ONE_HUNDRED;
5851        let count = self.bars[start..].iter().enumerate().filter(|(i, b)| {
5852            let prev_close = self.bars[start + i - 1].close.value();
5853            if prev_close.is_zero() { return false; }
5854            let gap = (b.open.value() - prev_close).abs() / prev_close;
5855            gap >= threshold
5856        }).count();
5857        Some(count)
5858    }
5859
5860    /// Fraction of bars in the last `n` where close direction matches the overall n-bar trend.
5861    ///
5862    /// Overall trend is up if `close[-1] > close[-n]`, down if < , flat otherwise.
5863    /// Returns `None` if `n < 2` or fewer than `n` bars.
5864    pub fn trend_consistency(&self, n: usize) -> Option<Decimal> {
5865        if n < 2 || self.bars.len() < n { return None; }
5866        let start = self.bars.len() - n;
5867        let first_close = self.bars[start].close.value();
5868        let last_close = self.bars.last()?.close.value();
5869        if first_close == last_close { return Some(Decimal::ZERO); }
5870        let up_trend = last_close > first_close;
5871        let consistent: usize = self.bars[start + 1..].iter().enumerate()
5872            .filter(|(i, b)| {
5873                let prev = self.bars[start + i].close.value();
5874                if up_trend { b.close.value() > prev } else { b.close.value() < prev }
5875            })
5876            .count();
5877        Some(Decimal::from(consistent) / Decimal::from(n - 1))
5878    }
5879
5880    /// The close price of the most recent bar.
5881    pub fn last_close(&self) -> Option<Decimal> {
5882        self.bars.last().map(|b| b.close.value())
5883    }
5884
5885    /// The close price of the earliest bar.
5886    pub fn first_close(&self) -> Option<Decimal> {
5887        self.bars.first().map(|b| b.close.value())
5888    }
5889
5890    /// Absolute close price change between the bar `n` bars ago and the latest bar.
5891    ///
5892    /// Returns `None` if fewer than `n + 1` bars exist.
5893    pub fn close_change_n(&self, n: usize) -> Option<Decimal> {
5894        if n == 0 || self.bars.len() <= n { return None; }
5895        let prev = self.bars[self.bars.len() - 1 - n].close.value();
5896        let last = self.bars.last()?.close.value();
5897        Some(last - prev)
5898    }
5899
5900    /// Percentage close price change over the last `n` bars.
5901    ///
5902    /// Returns `None` if fewer than `n + 1` bars exist or the reference close is zero.
5903    pub fn pct_change_n(&self, n: usize) -> Option<Decimal> {
5904        if n == 0 || self.bars.len() <= n { return None; }
5905        let prev = self.bars[self.bars.len() - 1 - n].close.value();
5906        if prev.is_zero() { return None; }
5907        let last = self.bars.last()?.close.value();
5908        Some((last - prev) / prev * Decimal::ONE_HUNDRED)
5909    }
5910
5911    /// Average ratio of `(high - close) / range` over the last `n` bars.
5912    ///
5913    /// Measures how far the close is from the high on average. Returns `None` if range is always zero.
5914    pub fn close_to_high_ratio(&self, n: usize) -> Option<Decimal> {
5915        if n == 0 || self.bars.len() < n { return None; }
5916        let start = self.bars.len() - n;
5917        let mut sum = Decimal::ZERO;
5918        let mut count = 0u32;
5919        for b in &self.bars[start..] {
5920            let range = b.high.value() - b.low.value();
5921            if range.is_zero() { continue; }
5922            sum += (b.high.value() - b.close.value()) / range;
5923            count += 1;
5924        }
5925        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5926    }
5927
5928    /// Average ratio of `(close - low) / range` over the last `n` bars.
5929    ///
5930    /// Measures how far the close is from the low on average.
5931    pub fn close_to_low_ratio(&self, n: usize) -> Option<Decimal> {
5932        if n == 0 || self.bars.len() < n { return None; }
5933        let start = self.bars.len() - n;
5934        let mut sum = Decimal::ZERO;
5935        let mut count = 0u32;
5936        for b in &self.bars[start..] {
5937            let range = b.high.value() - b.low.value();
5938            if range.is_zero() { continue; }
5939            sum += (b.close.value() - b.low.value()) / range;
5940            count += 1;
5941        }
5942        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5943    }
5944
5945    /// Coefficient of variation of volume over the last `n` bars: `std(vol) / mean(vol)`.
5946    ///
5947    /// Returns `None` if fewer than 2 bars or mean volume is zero.
5948    pub fn volume_coefficient_of_variation(&self, n: usize) -> Option<f64> {
5949        if n < 2 || self.bars.len() < n { return None; }
5950        let start = self.bars.len() - n;
5951        let vols: Vec<f64> = self.bars[start..]
5952            .iter()
5953            .map(|b| b.volume.value().to_string().parse::<f64>().unwrap_or(0.0))
5954            .collect();
5955        let mean = vols.iter().sum::<f64>() / vols.len() as f64;
5956        if mean == 0.0 { return None; }
5957        let variance = vols.iter().map(|&v| { let d = v - mean; d * d }).sum::<f64>() / vols.len() as f64;
5958        Some(variance.sqrt() / mean)
5959    }
5960
5961    /// Average ratio of the upper wick to the total bar range over the last `n` bars.
5962    ///
5963    /// Upper wick = `high - max(open, close)`. Returns `None` if range is always zero.
5964    pub fn close_wick_ratio(&self, n: usize) -> Option<Decimal> {
5965        if n == 0 || self.bars.len() < n { return None; }
5966        let start = self.bars.len() - n;
5967        let mut sum = Decimal::ZERO;
5968        let mut count = 0u32;
5969        for b in &self.bars[start..] {
5970            let range = b.high.value() - b.low.value();
5971            if range.is_zero() { continue; }
5972            let body_top = b.open.value().max(b.close.value());
5973            let upper_wick = b.high.value() - body_top;
5974            sum += upper_wick / range;
5975            count += 1;
5976        }
5977        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
5978    }
5979
5980    /// Wick imbalance over last `n` bars: `(upper_wick_sum - lower_wick_sum) / range_sum`.
5981    ///
5982    /// Positive = more upper-wick pressure (bearish); Negative = more lower-wick pressure (bullish).
5983    /// Returns `None` if `n == 0`, fewer than `n` bars, or range_sum is zero.
5984    pub fn wick_imbalance(&self, n: usize) -> Option<Decimal> {
5985        if n == 0 || self.bars.len() < n { return None; }
5986        let start = self.bars.len() - n;
5987        let mut upper_sum = Decimal::ZERO;
5988        let mut lower_sum = Decimal::ZERO;
5989        let mut range_sum = Decimal::ZERO;
5990        for b in &self.bars[start..] {
5991            let range = b.high.value() - b.low.value();
5992            if range.is_zero() { continue; }
5993            let body_top = b.open.value().max(b.close.value());
5994            let body_bot = b.open.value().min(b.close.value());
5995            upper_sum += b.high.value() - body_top;
5996            lower_sum += body_bot - b.low.value();
5997            range_sum += range;
5998        }
5999        if range_sum.is_zero() { return None; }
6000        Some((upper_sum - lower_sum) / range_sum)
6001    }
6002
6003    /// Average candle size (`high - low`) over the last `n` bars.
6004    ///
6005    /// Returns `None` if `n == 0` or fewer than `n` bars exist.
6006    pub fn avg_candle_size(&self, n: usize) -> Option<Decimal> {
6007        if n == 0 || self.bars.len() < n { return None; }
6008        let start = self.bars.len() - n;
6009        Some(self.bars[start..].iter().map(|b| b.high.value() - b.low.value()).sum::<Decimal>()
6010            / Decimal::from(n))
6011    }
6012
6013    /// Average body-to-range ratio for bullish bars (close > open) over the last `n` bars.
6014    ///
6015    /// Returns `None` if `n == 0`, fewer than `n` bars, or no bullish bars with range > 0.
6016    pub fn bull_strength(&self, n: usize) -> Option<Decimal> {
6017        if n == 0 || self.bars.len() < n { return None; }
6018        let start = self.bars.len() - n;
6019        let mut sum = Decimal::ZERO;
6020        let mut count = 0u32;
6021        for b in &self.bars[start..] {
6022            if b.close.value() <= b.open.value() { continue; }
6023            let range = b.high.value() - b.low.value();
6024            if range.is_zero() { continue; }
6025            sum += (b.close.value() - b.open.value()) / range;
6026            count += 1;
6027        }
6028        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6029    }
6030
6031    /// Average body-to-range ratio for bearish bars (close < open) over the last `n` bars.
6032    pub fn bear_strength(&self, n: usize) -> Option<Decimal> {
6033        if n == 0 || self.bars.len() < n { return None; }
6034        let start = self.bars.len() - n;
6035        let mut sum = Decimal::ZERO;
6036        let mut count = 0u32;
6037        for b in &self.bars[start..] {
6038            if b.close.value() >= b.open.value() { continue; }
6039            let range = b.high.value() - b.low.value();
6040            if range.is_zero() { continue; }
6041            sum += (b.open.value() - b.close.value()) / range;
6042            count += 1;
6043        }
6044        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6045    }
6046
6047    /// Open price of the most recent bar.
6048    pub fn last_open(&self) -> Option<Decimal> {
6049        self.bars.last().map(|b| b.open.value())
6050    }
6051
6052    /// High price of the most recent bar.
6053    pub fn last_high(&self) -> Option<Decimal> {
6054        self.bars.last().map(|b| b.high.value())
6055    }
6056
6057    /// Low price of the most recent bar.
6058    pub fn last_low(&self) -> Option<Decimal> {
6059        self.bars.last().map(|b| b.low.value())
6060    }
6061
6062    /// Volume of the most recent bar.
6063    pub fn last_volume(&self) -> Option<Decimal> {
6064        self.bars.last().map(|b| b.volume.value())
6065    }
6066
6067    /// Count of bars in the last `n` where the close exceeded the prior bar's high.
6068    ///
6069    /// Returns `None` if fewer than `n + 1` bars exist.
6070    pub fn close_above_prev_high(&self, n: usize) -> Option<usize> {
6071        if n == 0 || self.bars.len() <= n { return None; }
6072        let start = self.bars.len() - n;
6073        // start >= 1 since bars.len() > n
6074        let count = self.bars[start..].iter().enumerate()
6075            .filter(|(i, b)| b.close.value() > self.bars[start - 1 + i].high.value())
6076            .count();
6077        Some(count)
6078    }
6079
6080    /// Shannon entropy of close price direction over last `n` bars (in bits).
6081    ///
6082    /// Uses up/down proportions `p` and `1-p`. Returns `None` if `n < 2` or all moves same direction.
6083    pub fn price_entropy(&self, n: usize) -> Option<f64> {
6084        if n < 2 || self.bars.len() < n { return None; }
6085        let start = self.bars.len() - n;
6086        let mut ups = 0usize;
6087        for i in start + 1..self.bars.len() {
6088            if self.bars[i].close.value() > self.bars[i - 1].close.value() { ups += 1; }
6089        }
6090        let total = n - 1;
6091        if ups == 0 || ups == total { return None; }
6092        let p = ups as f64 / total as f64;
6093        let q = 1.0 - p;
6094        Some(-(p * p.log2() + q * q.log2()))
6095    }
6096
6097    /// Average intraday spread percentage `(high - low) / close * 100` over last `n` bars.
6098    ///
6099    /// Returns `None` if `n == 0`, fewer than `n` bars, or any close is zero.
6100    pub fn avg_spread_pct(&self, n: usize) -> Option<Decimal> {
6101        if n == 0 || self.bars.len() < n { return None; }
6102        let start = self.bars.len() - n;
6103        let mut sum = Decimal::ZERO;
6104        for b in &self.bars[start..] {
6105            let close = b.close.value();
6106            if close.is_zero() { return None; }
6107            sum += (b.high.value() - b.low.value()) / close * Decimal::ONE_HUNDRED;
6108        }
6109        Some(sum / Decimal::from(n))
6110    }
6111
6112    /// Ratio of latest close to the close `n` bars ago.
6113    ///
6114    /// Returns `None` if fewer than `n + 1` bars or the reference close is zero.
6115    pub fn close_momentum_ratio(&self, n: usize) -> Option<Decimal> {
6116        if n == 0 || self.bars.len() <= n { return None; }
6117        let prev = self.bars[self.bars.len() - 1 - n].close.value();
6118        if prev.is_zero() { return None; }
6119        Some(self.bars.last()?.close.value() / prev)
6120    }
6121
6122    /// Change in momentum: `pct_change(fast) - pct_change(slow)`.
6123    ///
6124    /// Returns `None` if insufficient bars or a reference close is zero.
6125    pub fn price_velocity(&self, fast: usize, slow: usize) -> Option<Decimal> {
6126        let fast_chg = self.pct_change_n(fast)?;
6127        let slow_chg = self.pct_change_n(slow)?;
6128        Some(fast_chg - slow_chg)
6129    }
6130
6131    /// Longest run of consecutive bars where `close == open`.
6132    pub fn longest_flat_streak(&self) -> usize {
6133        let mut max_run = 0usize;
6134        let mut run = 0usize;
6135        for b in &self.bars {
6136            if b.close.value() == b.open.value() {
6137                run += 1;
6138                max_run = max_run.max(run);
6139            } else {
6140                run = 0;
6141            }
6142        }
6143        max_run
6144    }
6145
6146    /// Number of bars since the last new all-time high close.
6147    ///
6148    /// Returns `None` if there are no bars.
6149    pub fn bars_since_new_high(&self) -> Option<usize> {
6150        if self.bars.is_empty() { return None; }
6151        let mut last_high_idx = 0;
6152        let mut peak = self.bars[0].close.value();
6153        for (i, b) in self.bars.iter().enumerate() {
6154            if b.close.value() >= peak {
6155                peak = b.close.value();
6156                last_high_idx = i;
6157            }
6158        }
6159        Some(self.bars.len() - 1 - last_high_idx)
6160    }
6161
6162    /// Drawdown from the n-bar peak: `(current_close - n_bar_high) / n_bar_high * 100`.
6163    ///
6164    /// Negative values indicate drawdown; zero means at the n-bar high.
6165    /// Returns `None` if `n == 0`, fewer than `n` bars, or n-bar high is zero.
6166    pub fn drawdown_from_peak(&self, n: usize) -> Option<Decimal> {
6167        if n == 0 || self.bars.len() < n { return None; }
6168        let start = self.bars.len() - n;
6169        let peak = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6170        if peak.is_zero() { return None; }
6171        let current = self.bars.last()?.close.value();
6172        Some((current - peak) / peak * Decimal::ONE_HUNDRED)
6173    }
6174
6175    /// Percentage price oscillator: `(fast_sma - slow_sma) / slow_sma * 100`.
6176    ///
6177    /// Returns `None` if `fast == 0`, `slow == 0`, `fast >= slow`, fewer than `slow` bars,
6178    /// or the slow SMA is zero.
6179    pub fn price_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
6180        if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow { return None; }
6181        let n = self.bars.len();
6182        let fast_start = n - fast;
6183        let slow_start = n - slow;
6184        let fast_sma = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6185            / Decimal::from(fast);
6186        let slow_sma = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6187            / Decimal::from(slow);
6188        if slow_sma.is_zero() { return None; }
6189        Some((fast_sma - slow_sma) / slow_sma * Decimal::ONE_HUNDRED)
6190    }
6191
6192    /// Count of bars in the last `n` where the close was below the prior bar's low.
6193    ///
6194    /// Returns `None` if fewer than `n + 1` bars exist.
6195    pub fn close_below_prev_low(&self, n: usize) -> Option<usize> {
6196        if n == 0 || self.bars.len() <= n { return None; }
6197        let start = self.bars.len() - n;
6198        let count = self.bars[start..].iter().enumerate()
6199            .filter(|(i, b)| b.close.value() < self.bars[start - 1 + i].low.value())
6200            .count();
6201        Some(count)
6202    }
6203
6204    /// Count of bars in the last `n` where the close is above the `period`-bar simple moving average.
6205    ///
6206    /// Returns `None` if `n == 0`, `period == 0`, or fewer than `max(n, period)` bars.
6207    pub fn bars_above_ma(&self, n: usize, period: usize) -> Option<usize> {
6208        if n == 0 || period == 0 || self.bars.len() < n.max(period) { return None; }
6209        let sma_start = self.bars.len() - period;
6210        let sma = self.bars[sma_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
6211            / Decimal::from(period);
6212        let bar_start = self.bars.len() - n;
6213        let count = self.bars[bar_start..].iter()
6214            .filter(|b| b.close.value() > sma)
6215            .count();
6216        Some(count)
6217    }
6218
6219    /// Ratio of latest `n`-bar range to prior `n`-bar range (price contraction < 1, expansion > 1).
6220    ///
6221    /// Returns `None` if fewer than `2 * n` bars or prior range is zero.
6222    pub fn price_contraction(&self, n: usize) -> Option<Decimal> {
6223        if n == 0 || self.bars.len() < 2 * n { return None; }
6224        let len = self.bars.len();
6225        let recent_high = self.bars[len - n..].iter().map(|b| b.high.value()).max()?;
6226        let recent_low = self.bars[len - n..].iter().map(|b| b.low.value()).min()?;
6227        let prior_high = self.bars[len - 2 * n..len - n].iter().map(|b| b.high.value()).max()?;
6228        let prior_low = self.bars[len - 2 * n..len - n].iter().map(|b| b.low.value()).min()?;
6229        let recent_range = recent_high - recent_low;
6230        let prior_range = prior_high - prior_low;
6231        if prior_range.is_zero() { return None; }
6232        Some(recent_range / prior_range)
6233    }
6234
6235    /// Number of bars since the last new all-time low close.
6236    ///
6237    /// Returns `None` if there are no bars.
6238    pub fn bars_since_new_low(&self) -> Option<usize> {
6239        if self.bars.is_empty() { return None; }
6240        let mut last_low_idx = 0;
6241        let mut trough = self.bars[0].close.value();
6242        for (i, b) in self.bars.iter().enumerate() {
6243            if b.close.value() <= trough {
6244                trough = b.close.value();
6245                last_low_idx = i;
6246            }
6247        }
6248        Some(self.bars.len() - 1 - last_low_idx)
6249    }
6250
6251    /// Average `volume / (high - low)` over last `n` bars — liquidity density metric.
6252    ///
6253    /// Higher values mean more volume traded per unit of price range.
6254    /// Returns `None` if `n == 0`, fewer than `n` bars, or all ranges are zero.
6255    pub fn volume_per_range(&self, n: usize) -> Option<Decimal> {
6256        if n == 0 || self.bars.len() < n { return None; }
6257        let start = self.bars.len() - n;
6258        let mut sum = Decimal::ZERO;
6259        let mut count = 0u32;
6260        for b in &self.bars[start..] {
6261            let range = b.high.value() - b.low.value();
6262            if range.is_zero() { continue; }
6263            sum += b.volume.value() / range;
6264            count += 1;
6265        }
6266        if count == 0 { None } else { Some(sum / Decimal::from(count)) }
6267    }
6268
6269    /// Ratio of fast vol (std dev of closes over `fast` bars) to slow vol (over `slow` bars).
6270    ///
6271    /// Values > 1 mean recent volatility is elevated vs the longer window.
6272    /// Returns `None` if `fast < 2`, `slow < 2`, `fast >= slow`, fewer than `slow` bars,
6273    /// or slow vol is zero.
6274    pub fn price_volatility_ratio(&self, fast: usize, slow: usize) -> Option<f64> {
6275        use rust_decimal::prelude::ToPrimitive;
6276        if fast < 2 || slow < 2 || fast >= slow || self.bars.len() < slow { return None; }
6277        let n = self.bars.len();
6278        let std_dev = |bars: &[crate::ohlcv::OhlcvBar]| -> Option<f64> {
6279            let m = bars.len() as f64;
6280            let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.value().to_f64()).collect();
6281            if vals.len() < 2 { return None; }
6282            let mean = vals.iter().sum::<f64>() / m;
6283            let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
6284            Some(var.sqrt())
6285        };
6286        let fast_vol = std_dev(&self.bars[n - fast..])?;
6287        let slow_vol = std_dev(&self.bars[n - slow..])?;
6288        if slow_vol == 0.0 { return None; }
6289        Some(fast_vol / slow_vol)
6290    }
6291
6292    /// Reference to the most recent bar, or `None` if the series is empty.
6293    pub fn last_bar(&self) -> Option<&OhlcvBar> {
6294        self.bars.last()
6295    }
6296
6297    /// Absolute distance from the latest close to the `n`-bar high.
6298    ///
6299    /// Returns `None` if fewer than `n` bars exist.
6300    pub fn close_distance_from_high(&self, n: usize) -> Option<Decimal> {
6301        if n == 0 || self.bars.len() < n { return None; }
6302        let start = self.bars.len() - n;
6303        let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
6304        Some((max_high - self.bars.last()?.close.value()).abs())
6305    }
6306
6307    /// Current close as a percentage above the `n`-bar low.
6308    ///
6309    /// Returns `None` if fewer than `n` bars or the low is zero.
6310    pub fn pct_from_low(&self, n: usize) -> Option<Decimal> {
6311        if n == 0 || self.bars.len() < n { return None; }
6312        let start = self.bars.len() - n;
6313        let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
6314        if min_low.is_zero() { return None; }
6315        Some((self.bars.last()?.close.value() - min_low) / min_low * Decimal::ONE_HUNDRED)
6316    }
6317
6318    /// Returns `true` if the latest close exceeds the highest close of the prior `n` bars.
6319    pub fn is_breakout_up(&self, n: usize) -> bool {
6320        if n == 0 || self.bars.len() <= n { return false; }
6321        let len = self.bars.len();
6322        let prior_high = self.bars[len - 1 - n..len - 1].iter().map(|b| b.close.value()).max();
6323        match (prior_high, self.bars.last()) {
6324            (Some(ph), Some(last)) => last.close.value() > ph,
6325            _ => false,
6326        }
6327    }
6328
6329    /// Count of the most recent consecutive bars whose close is above `price`.
6330    ///
6331    /// Starts from the latest bar and counts backwards. Returns `0` if the
6332    /// latest bar's close is not above `price`, or if the series is empty.
6333    pub fn consecutive_closes_above(&self, price: Decimal) -> usize {
6334        self.bars.iter().rev().take_while(|b| b.close.value() > price).count()
6335    }
6336
6337    /// Average `(open - low) / (high - low) * 100` over the last `n` bars.
6338    ///
6339    /// Measures where the open sits within the bar's range. Returns `None` if
6340    /// fewer than `n` bars exist or `n` is zero. Bars with zero range are skipped.
6341    pub fn open_range_pct(&self, n: usize) -> Option<f64> {
6342        use rust_decimal::prelude::ToPrimitive;
6343        if n == 0 || self.bars.len() < n { return None; }
6344        let start = self.bars.len() - n;
6345        let vals: Vec<f64> = self.bars[start..].iter().filter_map(|b| {
6346            let range = b.high.value() - b.low.value();
6347            if range.is_zero() { return None; }
6348            let num = (b.open.value() - b.low.value()).to_f64()?;
6349            let den = range.to_f64()?;
6350            Some(num / den * 100.0)
6351        }).collect();
6352        if vals.is_empty() { return None; }
6353        Some(vals.iter().sum::<f64>() / vals.len() as f64)
6354    }
6355}
6356
6357#[cfg(test)]
6358mod tests {
6359    use super::*;
6360    use crate::types::Side;
6361    use rust_decimal_macros::dec;
6362
6363    fn make_price(s: &str) -> Price {
6364        Price::new(s.parse().unwrap()).unwrap()
6365    }
6366
6367    fn make_qty(s: &str) -> Quantity {
6368        Quantity::new(s.parse().unwrap()).unwrap()
6369    }
6370
6371    fn make_bar(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
6372        OhlcvBar {
6373            symbol: Symbol::new("X").unwrap(),
6374            open: make_price(o),
6375            high: make_price(h),
6376            low: make_price(l),
6377            close: make_price(c),
6378            volume: make_qty("100"),
6379            ts_open: NanoTimestamp::new(0),
6380            ts_close: NanoTimestamp::new(1),
6381            tick_count: 1,
6382        }
6383    }
6384
6385    /// Convenience helper: create a bar where O=H=L=C = `close`.
6386    fn bar(close: &str) -> OhlcvBar {
6387        make_bar(close, close, close, close)
6388    }
6389
6390    fn make_tick(sym: &str, price: &str, qty: &str, ts: i64) -> Tick {
6391        Tick::new(
6392            Symbol::new(sym).unwrap(),
6393            make_price(price),
6394            make_qty(qty),
6395            Side::Ask,
6396            NanoTimestamp::new(ts),
6397        )
6398    }
6399
6400    // --- OhlcvBar ---
6401
6402    #[test]
6403    fn test_ohlcv_bar_validate_ok() {
6404        let bar = make_bar("100", "110", "90", "105");
6405        assert!(bar.validate().is_ok());
6406    }
6407
6408    #[test]
6409    fn test_ohlcv_bar_validate_high_less_than_close_fails() {
6410        let bar = make_bar("100", "104", "90", "110");
6411        assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
6412    }
6413
6414    #[test]
6415    fn test_ohlcv_bar_validate_low_greater_than_open_fails() {
6416        let bar = make_bar("80", "110", "90", "105");
6417        assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
6418    }
6419
6420    #[test]
6421    fn test_ohlcv_bar_validate_high_less_than_open_fails() {
6422        let bar = make_bar("115", "110", "90", "105");
6423        assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
6424    }
6425
6426    #[test]
6427    fn test_ohlcv_bar_typical_price() {
6428        let bar = make_bar("100", "120", "80", "110");
6429        let expected = dec!(310) / Decimal::from(3u32);
6430        assert_eq!(bar.typical_price(), expected);
6431    }
6432
6433    #[test]
6434    fn test_ohlcv_bar_range() {
6435        let bar = make_bar("100", "120", "80", "110");
6436        assert_eq!(bar.range(), dec!(40));
6437    }
6438
6439    #[test]
6440    fn test_ohlcv_bar_is_bullish_true() {
6441        let bar = make_bar("100", "110", "95", "105");
6442        assert!(bar.is_bullish());
6443    }
6444
6445    #[test]
6446    fn test_ohlcv_bar_is_bullish_false() {
6447        let bar = make_bar("105", "110", "95", "100");
6448        assert!(!bar.is_bullish());
6449    }
6450
6451    #[test]
6452    fn test_ohlcv_bar_midpoint() {
6453        let bar = make_bar("100", "120", "80", "110");
6454        assert_eq!(bar.midpoint(), dec!(100)); // (120 + 80) / 2
6455    }
6456
6457    #[test]
6458    fn test_ohlcv_bar_body_size_bullish() {
6459        let bar = make_bar("100", "120", "80", "110");
6460        assert_eq!(bar.body_size(), dec!(10)); // |110 - 100|
6461    }
6462
6463    #[test]
6464    fn test_ohlcv_bar_body_size_bearish() {
6465        let bar = make_bar("110", "120", "80", "100");
6466        assert_eq!(bar.body_size(), dec!(10)); // |100 - 110|
6467    }
6468
6469    #[test]
6470    fn test_ohlcv_bar_is_long_candle_flat() {
6471        // range == 0 → always false
6472        let bar = make_bar("100", "100", "100", "100");
6473        assert!(!bar.is_long_candle(dec!(0.7)));
6474    }
6475
6476    #[test]
6477    fn test_ohlcv_bar_is_long_candle_true() {
6478        // open=100, close=110, high=112, low=98 → body=10, range=14 → 10/14 ≈ 0.714 >= 0.7
6479        let bar = make_bar("100", "112", "98", "110");
6480        assert!(bar.is_long_candle(dec!(0.7)));
6481    }
6482
6483    #[test]
6484    fn test_ohlcv_bar_is_long_candle_false() {
6485        // open=100, close=101, high=110, low=90 → body=1, range=20 → 0.05 < 0.7
6486        let bar = make_bar("100", "110", "90", "101");
6487        assert!(!bar.is_long_candle(dec!(0.7)));
6488    }
6489
6490    #[test]
6491    fn test_ohlcv_bar_is_doji_flat_range() {
6492        let bar = make_bar("100", "100", "100", "100");
6493        assert!(bar.is_doji(dec!(0.1)));
6494        assert!(!bar.is_doji(dec!(0)));
6495    }
6496
6497    #[test]
6498    fn test_ohlcv_bar_is_doji_small_body() {
6499        // range = 20, body = 1 → body/range = 0.05 < 0.1 threshold
6500        let bar = make_bar("100", "110", "90", "101");
6501        assert!(bar.is_doji(dec!(0.1)));
6502        assert!(!bar.is_doji(dec!(0.04)));
6503    }
6504
6505    #[test]
6506    fn test_ohlcv_bar_partial_eq() {
6507        let a = make_bar("100", "110", "90", "105");
6508        let b = make_bar("100", "110", "90", "105");
6509        assert_eq!(a, b);
6510        let c = make_bar("100", "110", "90", "106");
6511        assert_ne!(a, c);
6512    }
6513
6514    // --- Timeframe ---
6515
6516    #[test]
6517    fn test_timeframe_seconds_to_nanos() {
6518        let tf = Timeframe::Seconds(5);
6519        assert_eq!(tf.to_nanos().unwrap(), 5_000_000_000);
6520    }
6521
6522    #[test]
6523    fn test_timeframe_minutes_to_nanos() {
6524        let tf = Timeframe::Minutes(1);
6525        assert_eq!(tf.to_nanos().unwrap(), 60_000_000_000);
6526    }
6527
6528    #[test]
6529    fn test_timeframe_zero_seconds_fails() {
6530        let tf = Timeframe::Seconds(0);
6531        assert!(matches!(tf.to_nanos(), Err(FinError::InvalidTimeframe)));
6532    }
6533
6534    #[test]
6535    fn test_timeframe_weeks_to_nanos() {
6536        let tf = Timeframe::Weeks(1);
6537        assert_eq!(tf.to_nanos().unwrap(), 7 * 86_400 * 1_000_000_000_i64);
6538    }
6539
6540    #[test]
6541    fn test_timeframe_bucket_start() {
6542        let tf = Timeframe::Seconds(60);
6543        let nanos_per_min = 60_000_000_000_i64;
6544        let ts = NanoTimestamp::new(nanos_per_min + 500_000_000);
6545        let bucket = tf.bucket_start(ts).unwrap();
6546        assert_eq!(bucket.nanos(), nanos_per_min);
6547    }
6548
6549    // --- OhlcvAggregator ---
6550
6551    #[test]
6552    fn test_ohlcv_aggregator_new_invalid_timeframe_fails() {
6553        let sym = Symbol::new("X").unwrap();
6554        let result = OhlcvAggregator::new(sym, Timeframe::Seconds(0));
6555        assert!(matches!(result, Err(FinError::InvalidTimeframe)));
6556    }
6557
6558    #[test]
6559    fn test_ohlcv_aggregator_completes_bar_on_boundary() {
6560        let sym = Symbol::new("X").unwrap();
6561        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6562        let nanos_per_min = 60_000_000_000_i64;
6563
6564        let t1 = make_tick("X", "100", "1", 0);
6565        let t2 = make_tick("X", "105", "2", nanos_per_min / 2);
6566        let t3 = make_tick("X", "110", "1", nanos_per_min + 1);
6567
6568        let r1 = agg.push_tick(&t1).unwrap();
6569        assert!(r1.is_empty());
6570        let r2 = agg.push_tick(&t2).unwrap();
6571        assert!(r2.is_empty());
6572        let r3 = agg.push_tick(&t3).unwrap();
6573        assert_eq!(r3.len(), 1);
6574        let bar = &r3[0];
6575        assert_eq!(bar.open.value(), dec!(100));
6576        assert_eq!(bar.high.value(), dec!(105));
6577        assert_eq!(bar.close.value(), dec!(105));
6578        assert_eq!(bar.tick_count, 2);
6579    }
6580
6581    #[test]
6582    fn test_ohlcv_aggregator_gap_fills_empty_buckets() {
6583        let sym = Symbol::new("X").unwrap();
6584        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6585        let nanos_per_min = 60_000_000_000_i64;
6586
6587        // First bar in minute 0.
6588        agg.push_tick(&make_tick("X", "100", "1", 0)).unwrap();
6589        // Tick jumps 3 minutes ahead: should emit bar for min 0 + gap bars for min 1, min 2.
6590        let out = agg
6591            .push_tick(&make_tick("X", "200", "1", 3 * nanos_per_min + 1))
6592            .unwrap();
6593        // 1 completed bar + 2 gap bars
6594        assert_eq!(out.len(), 3, "expected 1 completed + 2 gap bars, got {}", out.len());
6595        // Completed bar has real data.
6596        assert_eq!(out[0].tick_count, 1);
6597        // Gap bars have zero volume and tick_count.
6598        assert_eq!(out[1].tick_count, 0);
6599        assert_eq!(out[1].volume.value(), dec!(0));
6600        assert_eq!(out[2].tick_count, 0);
6601        // Gap bars use the last close.
6602        assert_eq!(out[1].close, out[0].close);
6603    }
6604
6605    #[test]
6606    fn test_ohlcv_aggregator_flush_returns_partial() {
6607        let sym = Symbol::new("X").unwrap();
6608        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6609        let t1 = make_tick("X", "100", "1", 0);
6610        agg.push_tick(&t1).unwrap();
6611        let bar = agg.flush().unwrap();
6612        assert_eq!(bar.open.value(), dec!(100));
6613        assert!(agg.flush().is_none());
6614    }
6615
6616    #[test]
6617    fn test_ohlcv_aggregator_symbol_getter() {
6618        let sym = Symbol::new("BTC").unwrap();
6619        let agg = OhlcvAggregator::new(sym.clone(), Timeframe::Seconds(60)).unwrap();
6620        assert_eq!(agg.symbol(), &sym);
6621    }
6622
6623    #[test]
6624    fn test_ohlcv_aggregator_ignores_different_symbol() {
6625        let sym = Symbol::new("X").unwrap();
6626        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
6627        let t = make_tick("Y", "100", "1", 0);
6628        let result = agg.push_tick(&t).unwrap();
6629        assert!(result.is_empty());
6630        assert!(agg.current_bar().is_none());
6631    }
6632
6633    // --- OhlcvSeries ---
6634
6635    #[test]
6636    fn test_ohlcv_series_push_valid() {
6637        let mut series = OhlcvSeries::new();
6638        let bar = make_bar("100", "110", "90", "105");
6639        assert!(series.push(bar).is_ok());
6640        assert_eq!(series.len(), 1);
6641    }
6642
6643    #[test]
6644    fn test_ohlcv_series_push_invalid_fails() {
6645        let mut series = OhlcvSeries::new();
6646        let bar = make_bar("100", "95", "90", "105");
6647        assert!(matches!(series.push(bar), Err(FinError::BarInvariant(_))));
6648    }
6649
6650    #[test]
6651    fn test_ohlcv_series_window_returns_last_n() {
6652        let mut series = OhlcvSeries::new();
6653        for i in 1u32..=5 {
6654            let p = format!("{}", 100 + i);
6655            let h = format!("{}", 110 + i);
6656            let l = format!("{}", 90 + i);
6657            let c = format!("{}", 105 + i);
6658            series.push(make_bar(&p, &h, &l, &c)).unwrap();
6659        }
6660        let w = series.window(3);
6661        assert_eq!(w.len(), 3);
6662        assert_eq!(w[0].open.value(), dec!(103));
6663    }
6664
6665    #[test]
6666    fn test_ohlcv_series_window_larger_than_len() {
6667        let mut series = OhlcvSeries::new();
6668        series.push(make_bar("100", "110", "90", "105")).unwrap();
6669        let w = series.window(10);
6670        assert_eq!(w.len(), 1);
6671    }
6672
6673    #[test]
6674    fn test_ohlcv_series_opens() {
6675        let mut series = OhlcvSeries::new();
6676        series.push(make_bar("100", "110", "90", "105")).unwrap();
6677        series.push(make_bar("105", "115", "95", "110")).unwrap();
6678        assert_eq!(series.opens(), vec![dec!(100), dec!(105)]);
6679    }
6680
6681    #[test]
6682    fn test_ohlcv_series_highs() {
6683        let mut series = OhlcvSeries::new();
6684        series.push(make_bar("100", "110", "90", "105")).unwrap();
6685        series.push(make_bar("105", "115", "95", "110")).unwrap();
6686        assert_eq!(series.highs(), vec![dec!(110), dec!(115)]);
6687    }
6688
6689    #[test]
6690    fn test_ohlcv_series_lows() {
6691        let mut series = OhlcvSeries::new();
6692        series.push(make_bar("100", "110", "90", "105")).unwrap();
6693        series.push(make_bar("105", "115", "95", "110")).unwrap();
6694        assert_eq!(series.lows(), vec![dec!(90), dec!(95)]);
6695    }
6696
6697    #[test]
6698    fn test_ohlcv_series_closes() {
6699        let mut series = OhlcvSeries::new();
6700        series.push(make_bar("100", "110", "90", "105")).unwrap();
6701        series.push(make_bar("105", "115", "95", "110")).unwrap();
6702        let closes = series.closes();
6703        assert_eq!(closes, vec![dec!(105), dec!(110)]);
6704    }
6705
6706    #[test]
6707    fn test_ohlcv_series_is_empty() {
6708        let series = OhlcvSeries::new();
6709        assert!(series.is_empty());
6710    }
6711
6712    #[test]
6713    fn test_ohlcv_series_into_iterator() {
6714        let mut series = OhlcvSeries::new();
6715        series.push(make_bar("100", "110", "90", "105")).unwrap();
6716        series.push(make_bar("105", "115", "95", "110")).unwrap();
6717        let count = (&series).into_iter().count();
6718        assert_eq!(count, 2);
6719    }
6720
6721    #[test]
6722    fn test_ohlcv_series_iter() {
6723        let mut series = OhlcvSeries::new();
6724        series.push(make_bar("100", "110", "90", "105")).unwrap();
6725        let bar = series.iter().next().unwrap();
6726        assert_eq!(bar.open.value(), dec!(100));
6727    }
6728
6729    #[test]
6730    fn test_ohlcv_bar_upper_shadow() {
6731        // bullish: open=100, close=108, high=115 → upper = 115-108 = 7
6732        let b = make_bar("100", "115", "90", "108");
6733        assert_eq!(b.upper_shadow(), dec!(7));
6734    }
6735
6736    #[test]
6737    fn test_ohlcv_bar_lower_shadow() {
6738        // bullish: open=100, close=108, low=90 → lower = 100-90 = 10
6739        let b = make_bar("100", "115", "90", "108");
6740        assert_eq!(b.lower_shadow(), dec!(10));
6741    }
6742
6743    #[test]
6744    fn test_ohlcv_bar_from_tick() {
6745        let tick = make_tick("AAPL", "150", "5", 1_000);
6746        let bar = OhlcvBar::from_tick(&tick);
6747        assert_eq!(bar.open.value(), dec!(150));
6748        assert_eq!(bar.high.value(), dec!(150));
6749        assert_eq!(bar.low.value(), dec!(150));
6750        assert_eq!(bar.close.value(), dec!(150));
6751        assert_eq!(bar.volume.value(), dec!(5));
6752        assert_eq!(bar.tick_count, 1);
6753        assert_eq!(bar.ts_open.nanos(), 1_000);
6754    }
6755
6756    #[test]
6757    fn test_ohlcv_series_bars_slice() {
6758        let mut series = OhlcvSeries::new();
6759        series.push(make_bar("100", "110", "90", "105")).unwrap();
6760        series.push(make_bar("105", "115", "95", "110")).unwrap();
6761        assert_eq!(series.bars().len(), 2);
6762    }
6763
6764    #[test]
6765    fn test_ohlcv_series_max_high_min_low() {
6766        let mut series = OhlcvSeries::new();
6767        series.push(make_bar("100", "110", "90", "105")).unwrap();
6768        series.push(make_bar("105", "120", "85", "110")).unwrap();
6769        assert_eq!(series.max_high().unwrap(), dec!(120));
6770        assert_eq!(series.min_low().unwrap(), dec!(85));
6771    }
6772
6773    #[test]
6774    fn test_ohlcv_series_max_high_empty() {
6775        let series = OhlcvSeries::new();
6776        assert!(series.max_high().is_none());
6777        assert!(series.min_low().is_none());
6778    }
6779
6780    #[test]
6781    fn test_ohlcv_series_slice() {
6782        let mut series = OhlcvSeries::new();
6783        series.push(make_bar("100", "110", "90", "105")).unwrap();
6784        series.push(make_bar("105", "115", "95", "110")).unwrap();
6785        series.push(make_bar("110", "120", "100", "115")).unwrap();
6786        let s = series.slice(1, 3).unwrap();
6787        assert_eq!(s.len(), 2);
6788        assert_eq!(s[0].open.value(), dec!(105));
6789    }
6790
6791    #[test]
6792    fn test_ohlcv_series_slice_out_of_bounds() {
6793        let series = OhlcvSeries::new();
6794        assert!(series.slice(0, 1).is_none());
6795    }
6796
6797    #[test]
6798    fn test_ohlcv_series_truncate_keeps_last_n() {
6799        let mut series = OhlcvSeries::new();
6800        for _ in 0..5 {
6801            series.push(make_bar("100", "110", "90", "105")).unwrap();
6802        }
6803        series.truncate(3);
6804        assert_eq!(series.len(), 3);
6805    }
6806
6807    #[test]
6808    fn test_ohlcv_series_truncate_noop_when_n_ge_len() {
6809        let mut series = OhlcvSeries::new();
6810        series.push(make_bar("100", "110", "90", "105")).unwrap();
6811        series.push(make_bar("105", "115", "95", "110")).unwrap();
6812        series.truncate(5);
6813        assert_eq!(series.len(), 2);
6814    }
6815
6816    #[test]
6817    fn test_ohlcv_series_truncate_to_zero() {
6818        let mut series = OhlcvSeries::new();
6819        series.push(make_bar("100", "110", "90", "105")).unwrap();
6820        series.push(make_bar("105", "115", "95", "110")).unwrap();
6821        series.truncate(0);
6822        assert!(series.is_empty());
6823    }
6824
6825    #[test]
6826    fn test_ohlcv_bar_serde_roundtrip() {
6827        let bar = make_bar("100", "110", "90", "105");
6828        let json = serde_json::to_string(&bar).unwrap();
6829        let back: OhlcvBar = serde_json::from_str(&json).unwrap();
6830        assert_eq!(back.open, bar.open);
6831        assert_eq!(back.high, bar.high);
6832        assert_eq!(back.low, bar.low);
6833        assert_eq!(back.close, bar.close);
6834        assert_eq!(back.tick_count, bar.tick_count);
6835    }
6836
6837    #[test]
6838    fn test_ohlcv_bar_duration_nanos() {
6839        let mut bar = make_bar("100", "110", "90", "105");
6840        bar.ts_open = NanoTimestamp::new(1_000_000_000);
6841        bar.ts_close = NanoTimestamp::new(1_060_000_000_000);
6842        assert_eq!(bar.duration_nanos(), 1_059_000_000_000);
6843    }
6844
6845    #[test]
6846    fn test_ohlcv_bar_duration_nanos_same_timestamps() {
6847        let mut bar = make_bar("100", "110", "90", "100");
6848        bar.ts_open = NanoTimestamp::new(5_000);
6849        bar.ts_close = NanoTimestamp::new(5_000);
6850        assert_eq!(bar.duration_nanos(), 0);
6851    }
6852
6853    #[test]
6854    fn test_ohlcv_series_extend_valid() {
6855        let mut series = OhlcvSeries::new();
6856        let bars = vec![
6857            make_bar("100", "110", "90", "105"),
6858            make_bar("105", "115", "95", "110"),
6859        ];
6860        series.extend(bars).unwrap();
6861        assert_eq!(series.len(), 2);
6862    }
6863
6864    #[test]
6865    fn test_ohlcv_series_extend_stops_on_invalid_bar() {
6866        let mut series = OhlcvSeries::new();
6867        let valid = make_bar("100", "110", "90", "105");
6868        let mut invalid = make_bar("100", "110", "90", "105");
6869        // Make bar invalid: high < low
6870        invalid.high = make_price("80");
6871        invalid.low = make_price("110");
6872        let result = series.extend([valid, invalid]);
6873        assert!(result.is_err());
6874        assert_eq!(series.len(), 1, "valid bar added before error");
6875    }
6876
6877    #[test]
6878    fn test_ohlcv_bar_to_bar_input_fields_match() {
6879        let bar = make_bar("100", "110", "90", "105");
6880        let input = bar.to_bar_input();
6881        assert_eq!(input.open, bar.open.value());
6882        assert_eq!(input.high, bar.high.value());
6883        assert_eq!(input.low, bar.low.value());
6884        assert_eq!(input.close, bar.close.value());
6885        assert_eq!(input.volume, bar.volume.value());
6886    }
6887
6888    #[test]
6889    fn test_ohlcv_series_retain_removes_gap_fills() {
6890        let mut series = OhlcvSeries::new();
6891        series.push(make_bar("100", "110", "90", "105")).unwrap();
6892        // add a gap-fill bar (tick_count == 0)
6893        let mut gap = make_bar("105", "105", "105", "105");
6894        gap.tick_count = 0;
6895        series.push(gap).unwrap();
6896        series.push(make_bar("105", "115", "95", "110")).unwrap();
6897        series.retain(|b| !b.is_gap_fill());
6898        assert_eq!(series.len(), 2);
6899    }
6900
6901    #[test]
6902    fn test_ohlcv_series_retain_keeps_all() {
6903        let mut series = OhlcvSeries::new();
6904        series.push(make_bar("100", "110", "90", "105")).unwrap();
6905        series.push(make_bar("105", "115", "95", "110")).unwrap();
6906        series.retain(|_| true);
6907        assert_eq!(series.len(), 2);
6908    }
6909
6910    #[test]
6911    fn test_ohlcv_bar_is_bearish() {
6912        let bar = make_bar("110", "115", "95", "100");
6913        assert!(bar.is_bearish());
6914        assert!(!bar.is_bullish());
6915    }
6916
6917    #[test]
6918    fn test_ohlcv_bar_is_hammer() {
6919        // body = 5 (100→105), lower shadow = 20 (80→100), upper shadow = 6 (105→111) → NOT hammer (upper > body)
6920        let not_hammer = make_bar("100", "111", "80", "105");
6921        assert!(!not_hammer.is_hammer());
6922        // body = 5, lower shadow = 20 (75→95), upper shadow = 0 → IS hammer
6923        let hammer = make_bar("95", "100", "75", "100");
6924        assert!(hammer.is_hammer());
6925    }
6926
6927    #[test]
6928    fn test_ohlcv_bar_is_shooting_star() {
6929        // body = 5, upper shadow = 20, lower shadow = 0 → IS shooting star
6930        let star = make_bar("100", "125", "100", "105");
6931        assert!(star.is_shooting_star());
6932        // body = 5, upper shadow = 5, lower shadow = 20 → NOT shooting star
6933        let not_star = make_bar("100", "110", "80", "105");
6934        assert!(!not_star.is_shooting_star());
6935    }
6936
6937    #[test]
6938    fn test_ohlcv_bar_bar_return_positive() {
6939        let bar = make_bar("100", "110", "90", "110");
6940        assert_eq!(bar.bar_return().unwrap(), dec!(10));
6941    }
6942
6943    #[test]
6944    fn test_ohlcv_bar_bar_return_negative() {
6945        let bar = make_bar("100", "105", "85", "90");
6946        assert_eq!(bar.bar_return().unwrap(), dec!(-10));
6947    }
6948
6949    #[test]
6950    fn test_ohlcv_series_highest_high() {
6951        let mut series = OhlcvSeries::new();
6952        series.push(make_bar("100", "150", "90", "105")).unwrap();
6953        series.push(make_bar("105", "130", "95", "110")).unwrap();
6954        series.push(make_bar("110", "120", "100", "115")).unwrap();
6955        assert_eq!(series.highest_high(2).unwrap(), dec!(130));
6956        assert_eq!(series.highest_high(10).unwrap(), dec!(150));
6957    }
6958
6959    #[test]
6960    fn test_ohlcv_series_lowest_low() {
6961        let mut series = OhlcvSeries::new();
6962        series.push(make_bar("100", "110", "70", "105")).unwrap();
6963        series.push(make_bar("105", "115", "85", "110")).unwrap();
6964        series.push(make_bar("110", "120", "90", "115")).unwrap();
6965        assert_eq!(series.lowest_low(2).unwrap(), dec!(85));
6966        assert_eq!(series.lowest_low(10).unwrap(), dec!(70));
6967    }
6968
6969    #[test]
6970    fn test_ohlcv_series_extend_from_series() {
6971        let mut a = OhlcvSeries::new();
6972        a.push(make_bar("100", "110", "90", "105")).unwrap();
6973        let mut b = OhlcvSeries::new();
6974        b.push(make_bar("105", "115", "95", "110")).unwrap();
6975        b.push(make_bar("110", "120", "100", "115")).unwrap();
6976        a.extend_from_series(&b).unwrap();
6977        assert_eq!(a.len(), 3);
6978    }
6979
6980    #[test]
6981    fn test_ohlcv_aggregator_bar_count() {
6982        let sym = Symbol::new("AAPL").unwrap();
6983        let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(1)).unwrap();
6984        assert_eq!(agg.bar_count(), 0);
6985        agg.push_tick(&make_tick("AAPL", "100", "1", 0)).unwrap();
6986        // t=2s lands in bucket [2s,3s): completes [0s,1s) + gap fills [1s,2s) = 2 bars emitted
6987        agg.push_tick(&make_tick("AAPL", "101", "1", 2_000_000_000))
6988            .unwrap();
6989        assert_eq!(agg.bar_count(), 2);
6990        agg.flush();
6991        assert_eq!(agg.bar_count(), 3);
6992        agg.reset();
6993        assert_eq!(agg.bar_count(), 0);
6994    }
6995
6996    #[test]
6997    fn test_ohlcv_series_vwap_empty_returns_none() {
6998        assert!(OhlcvSeries::new().vwap().is_none());
6999    }
7000
7001    #[test]
7002    fn test_ohlcv_series_vwap_zero_volume_returns_none() {
7003        let mut series = OhlcvSeries::new();
7004        let mut bar = make_bar("100", "110", "90", "100");
7005        bar.volume = Quantity::zero();
7006        series.push(bar).unwrap();
7007        assert!(series.vwap().is_none());
7008    }
7009
7010    #[test]
7011    fn test_ohlcv_series_vwap_constant_price() {
7012        let mut series = OhlcvSeries::new();
7013        series.push(make_bar("100", "100", "100", "100")).unwrap();
7014        series.push(make_bar("100", "100", "100", "100")).unwrap();
7015        assert_eq!(series.vwap().unwrap(), dec!(100));
7016    }
7017
7018    #[test]
7019    fn test_ohlcv_series_sum_volume_empty() {
7020        assert_eq!(OhlcvSeries::new().sum_volume(), dec!(0));
7021    }
7022
7023    #[test]
7024    fn test_ohlcv_series_sum_volume_multiple_bars() {
7025        let mut series = OhlcvSeries::new();
7026        series.push(make_bar("100", "110", "90", "105")).unwrap();
7027        series.push(make_bar("105", "115", "95", "110")).unwrap();
7028        series.push(make_bar("110", "120", "100", "115")).unwrap();
7029        // make_bar sets volume = 100 per bar
7030        assert_eq!(series.sum_volume(), dec!(300));
7031    }
7032
7033    #[test]
7034    fn test_ohlcv_series_avg_volume_none_when_empty() {
7035        assert!(OhlcvSeries::new().avg_volume(3).is_none());
7036    }
7037
7038    #[test]
7039    fn test_ohlcv_series_avg_volume_none_when_n_zero() {
7040        let mut series = OhlcvSeries::new();
7041        series.push(make_bar("100", "110", "90", "105")).unwrap();
7042        assert!(series.avg_volume(0).is_none());
7043    }
7044
7045    #[test]
7046    fn test_ohlcv_series_avg_volume_correct() {
7047        // make_bar sets volume = 100 per bar
7048        let mut series = OhlcvSeries::new();
7049        series.push(make_bar("100", "110", "90", "105")).unwrap();
7050        series.push(make_bar("105", "115", "95", "110")).unwrap();
7051        series.push(make_bar("110", "120", "100", "115")).unwrap();
7052        // avg over 3 bars: (100+100+100)/3 = 100
7053        assert_eq!(series.avg_volume(3).unwrap(), dec!(100));
7054    }
7055
7056    #[test]
7057    fn test_ohlcv_series_avg_volume_partial_window() {
7058        // n=5 but only 3 bars → None
7059        let mut series = OhlcvSeries::new();
7060        series.push(make_bar("100", "110", "90", "105")).unwrap();
7061        series.push(make_bar("105", "115", "95", "110")).unwrap();
7062        assert!(series.avg_volume(5).is_none());
7063    }
7064
7065    #[test]
7066    fn test_ohlcv_series_price_range_none_when_insufficient() {
7067        let mut series = OhlcvSeries::new();
7068        series.push(make_bar("100", "110", "90", "105")).unwrap();
7069        assert!(series.price_range(0).is_none());
7070        assert!(series.price_range(2).is_none());
7071    }
7072
7073    #[test]
7074    fn test_ohlcv_series_price_range_correct() {
7075        // bar1: high=110 low=90; bar2: high=120 low=80 → range = 120-80 = 40
7076        let mut series = OhlcvSeries::new();
7077        series.push(make_bar("100", "110", "90", "100")).unwrap();
7078        series.push(make_bar("100", "120", "80", "100")).unwrap();
7079        assert_eq!(series.price_range(2).unwrap(), dec!(40));
7080    }
7081
7082    #[test]
7083    fn test_ohlcv_series_above_ema_false_when_insufficient() {
7084        assert!(!OhlcvSeries::new().above_ema(3));
7085    }
7086
7087    #[test]
7088    fn test_ohlcv_series_above_ema_rising_close() {
7089        let mut series = OhlcvSeries::new();
7090        for c in ["100", "100", "100", "100", "200"] {
7091            series.push(make_bar(c, "210", "90", c)).unwrap();
7092        }
7093        assert!(series.above_ema(3));
7094    }
7095
7096    #[test]
7097    fn test_ohlcv_series_bullish_engulfing_count_zero_when_short() {
7098        assert_eq!(OhlcvSeries::new().bullish_engulfing_count(5), 0);
7099    }
7100
7101    #[test]
7102    fn test_ohlcv_series_bullish_engulfing_count_detects_pattern() {
7103        let mut series = OhlcvSeries::new();
7104        // bar1: bearish (open=105, close=95)
7105        series.push(make_bar("105", "110", "90", "95")).unwrap();
7106        // bar2: bullish engulfing: open < prev_close(95), close > prev_open(105)
7107        series.push(make_bar("90", "120", "88", "110")).unwrap();
7108        assert_eq!(series.bullish_engulfing_count(2), 1);
7109    }
7110
7111    #[test]
7112    fn test_ohlcv_series_range_expansion_none_when_insufficient() {
7113        assert!(OhlcvSeries::new().range_expansion(3).is_none());
7114    }
7115
7116    #[test]
7117    fn test_ohlcv_series_range_expansion_constant_returns_one() {
7118        let mut series = OhlcvSeries::new();
7119        for _ in 0..5 {
7120            series.push(make_bar("100", "110", "90", "100")).unwrap();
7121        }
7122        // all bars identical range=20 → current/avg = 1
7123        assert_eq!(series.range_expansion(5).unwrap(), dec!(1));
7124    }
7125
7126    #[test]
7127    fn test_ohlcv_series_bearish_engulfing_count_zero_when_short() {
7128        assert_eq!(OhlcvSeries::new().bearish_engulfing_count(5), 0);
7129    }
7130
7131    #[test]
7132    fn test_ohlcv_series_bearish_engulfing_count_detects_pattern() {
7133        let mut series = OhlcvSeries::new();
7134        // bar1: bullish (open=95, close=105)
7135        series.push(make_bar("95", "110", "90", "105")).unwrap();
7136        // bar2: bearish engulfing: open > prev_close(105), close < prev_open(95)
7137        series.push(make_bar("110", "115", "88", "90")).unwrap();
7138        assert_eq!(series.bearish_engulfing_count(2), 1);
7139    }
7140
7141    #[test]
7142    fn test_ohlcv_series_trend_strength_none_when_insufficient() {
7143        let mut series = OhlcvSeries::new();
7144        series.push(make_bar("100", "110", "90", "100")).unwrap();
7145        assert!(series.trend_strength(2).is_none());
7146    }
7147
7148    #[test]
7149    fn test_ohlcv_series_trend_strength_pure_trend_is_one() {
7150        // straight up trend: each close 10 higher — net = total movement → ratio = 1
7151        let mut series = OhlcvSeries::new();
7152        for c in ["100", "110", "120", "130"] {
7153            series.push(make_bar(c, "135", "95", c)).unwrap();
7154        }
7155        assert_eq!(series.trend_strength(4).unwrap(), dec!(1));
7156    }
7157
7158    #[test]
7159    fn test_ohlcv_series_close_location_value_none_when_insufficient() {
7160        assert!(OhlcvSeries::new().close_location_value(1).is_none());
7161    }
7162
7163    #[test]
7164    fn test_ohlcv_series_close_location_value_close_at_high() {
7165        // close == high → CLV = ((h-l)-(0))/(h-l) = 1
7166        let mut series = OhlcvSeries::new();
7167        series.push(make_bar("100", "110", "90", "110")).unwrap();
7168        assert_eq!(series.close_location_value(1).unwrap(), dec!(1));
7169    }
7170
7171    #[test]
7172    fn test_ohlcv_series_close_location_value_close_at_midpoint() {
7173        // close = 100 = midpoint of [90,110] → CLV = 0
7174        let mut series = OhlcvSeries::new();
7175        series.push(make_bar("100", "110", "90", "100")).unwrap();
7176        assert_eq!(series.close_location_value(1).unwrap(), dec!(0));
7177    }
7178
7179    #[test]
7180    fn test_ohlcv_series_mean_close_empty_returns_none() {
7181        assert!(OhlcvSeries::new().mean_close(5).is_none());
7182    }
7183
7184    #[test]
7185    fn test_ohlcv_series_mean_close_equal_prices() {
7186        let mut series = OhlcvSeries::new();
7187        series.push(make_bar("100", "110", "90", "100")).unwrap();
7188        series.push(make_bar("100", "110", "90", "100")).unwrap();
7189        series.push(make_bar("100", "110", "90", "100")).unwrap();
7190        assert_eq!(series.mean_close(3).unwrap(), dec!(100));
7191    }
7192
7193    #[test]
7194    fn test_ohlcv_series_mean_close_windowed() {
7195        // 3 bars with closes 100, 110, 120 → mean of last 2 = (110+120)/2 = 115
7196        let mut series = OhlcvSeries::new();
7197        series.push(make_bar("100", "100", "100", "100")).unwrap();
7198        series.push(make_bar("110", "110", "110", "110")).unwrap();
7199        series.push(make_bar("120", "120", "120", "120")).unwrap();
7200        assert_eq!(series.mean_close(2).unwrap(), dec!(115));
7201    }
7202
7203    #[test]
7204    fn test_ohlcv_series_std_dev_less_than_two_bars_returns_none() {
7205        let mut series = OhlcvSeries::new();
7206        series.push(make_bar("100", "110", "90", "100")).unwrap();
7207        assert!(series.std_dev(5).is_none());
7208    }
7209
7210    #[test]
7211    fn test_ohlcv_series_std_dev_constant_prices_is_zero() {
7212        let mut series = OhlcvSeries::new();
7213        for _ in 0..4 {
7214            series.push(make_bar("100", "100", "100", "100")).unwrap();
7215        }
7216        assert_eq!(series.std_dev(4).unwrap(), dec!(0));
7217    }
7218
7219    #[test]
7220    fn test_ohlcv_bar_gap_pct_upward_gap() {
7221        let prev = make_bar("100", "110", "90", "100");
7222        let curr = make_bar("110", "120", "105", "115");
7223        // gap_pct = (110 - 100) / 100 * 100 = 10
7224        assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(10));
7225    }
7226
7227    #[test]
7228    fn test_ohlcv_bar_gap_pct_downward_gap() {
7229        let prev = make_bar("100", "110", "90", "100");
7230        let curr = make_bar("90", "95", "85", "92");
7231        // gap_pct = (90 - 100) / 100 * 100 = -10
7232        assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(-10));
7233    }
7234
7235    #[test]
7236    fn test_ohlcv_bar_gap_pct_no_gap() {
7237        let prev = make_bar("100", "110", "90", "100");
7238        let curr = make_bar("100", "110", "90", "105");
7239        assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(0));
7240    }
7241
7242    #[test]
7243    fn test_ohlcv_series_n_bars_ago_returns_correct_bar() {
7244        let mut series = OhlcvSeries::new();
7245        series.push(make_bar("100", "110", "90", "105")).unwrap();
7246        series.push(make_bar("105", "115", "95", "110")).unwrap();
7247        series.push(make_bar("110", "120", "100", "115")).unwrap();
7248        assert_eq!(series.n_bars_ago(0).unwrap().close.value(), dec!(115));
7249        assert_eq!(series.n_bars_ago(1).unwrap().close.value(), dec!(110));
7250        assert_eq!(series.n_bars_ago(2).unwrap().close.value(), dec!(105));
7251    }
7252
7253    #[test]
7254    fn test_ohlcv_series_n_bars_ago_out_of_bounds() {
7255        let mut series = OhlcvSeries::new();
7256        series.push(make_bar("100", "110", "90", "105")).unwrap();
7257        assert!(series.n_bars_ago(1).is_none());
7258        assert!(OhlcvSeries::new().n_bars_ago(0).is_none());
7259    }
7260
7261    #[test]
7262    fn test_ohlcv_bar_is_outside_bar_true() {
7263        let prev = make_bar("100", "110", "90", "105");
7264        let outside = make_bar("100", "120", "80", "110");
7265        assert!(outside.is_outside_bar(&prev));
7266    }
7267
7268    #[test]
7269    fn test_ohlcv_bar_is_outside_bar_false_for_inside() {
7270        let prev = make_bar("100", "120", "80", "110");
7271        let inside = make_bar("100", "110", "90", "105");
7272        assert!(!inside.is_outside_bar(&prev));
7273    }
7274
7275    #[test]
7276    fn test_ohlcv_bar_is_outside_bar_false_partial() {
7277        let prev = make_bar("100", "110", "90", "105");
7278        let partial = make_bar("100", "115", "92", "110");
7279        assert!(!partial.is_outside_bar(&prev));
7280    }
7281
7282    #[test]
7283    fn test_ohlcv_series_from_bars_valid() {
7284        let bars = vec![
7285            make_bar("100", "110", "90", "105"),
7286            make_bar("105", "115", "95", "110"),
7287        ];
7288        let series = OhlcvSeries::from_bars(bars).unwrap();
7289        assert_eq!(series.len(), 2);
7290    }
7291
7292    #[test]
7293    fn test_ohlcv_series_from_bars_empty() {
7294        let series = OhlcvSeries::from_bars(vec![]).unwrap();
7295        assert!(series.is_empty());
7296    }
7297
7298    #[test]
7299    fn test_ohlcv_series_count_bullish() {
7300        let mut series = OhlcvSeries::new();
7301        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
7302        series.push(make_bar("105", "115", "95", "100")).unwrap(); // bearish
7303        series.push(make_bar("100", "110", "90", "108")).unwrap(); // bullish
7304        assert_eq!(series.count_bullish(3), 2);
7305        assert_eq!(series.count_bullish(1), 1); // last bar only
7306    }
7307
7308    #[test]
7309    fn test_ohlcv_series_count_bearish() {
7310        let mut series = OhlcvSeries::new();
7311        series.push(make_bar("110", "115", "90", "100")).unwrap(); // bearish
7312        series.push(make_bar("105", "115", "95", "110")).unwrap(); // bullish
7313        assert_eq!(series.count_bearish(2), 1);
7314        assert_eq!(series.count_bearish(1), 0); // last bar is bullish
7315    }
7316
7317    #[test]
7318    fn test_ohlcv_series_count_bullish_exceeds_len() {
7319        let mut series = OhlcvSeries::new();
7320        series.push(make_bar("100", "110", "90", "105")).unwrap();
7321        assert_eq!(series.count_bullish(100), 1);
7322    }
7323
7324    #[test]
7325    fn test_ohlcv_series_median_close_empty() {
7326        assert!(OhlcvSeries::new().median_close(5).is_none());
7327    }
7328
7329    #[test]
7330    fn test_ohlcv_series_median_close_odd_count() {
7331        // closes: 100, 110, 120 → sorted: [100, 110, 120] → median = 110
7332        let mut series = OhlcvSeries::new();
7333        series.push(make_bar("100", "100", "100", "100")).unwrap();
7334        series.push(make_bar("110", "110", "110", "110")).unwrap();
7335        series.push(make_bar("120", "120", "120", "120")).unwrap();
7336        assert_eq!(series.median_close(3).unwrap(), dec!(110));
7337    }
7338
7339    #[test]
7340    fn test_ohlcv_series_median_close_even_count() {
7341        // closes: 100, 110 → median = (100+110)/2 = 105
7342        let mut series = OhlcvSeries::new();
7343        series.push(make_bar("100", "100", "100", "100")).unwrap();
7344        series.push(make_bar("110", "110", "110", "110")).unwrap();
7345        assert_eq!(series.median_close(2).unwrap(), dec!(105));
7346    }
7347
7348    #[test]
7349    fn test_ohlcv_series_percentile_rank_empty() {
7350        assert!(OhlcvSeries::new().percentile_rank(dec!(100), 5).is_none());
7351    }
7352
7353    #[test]
7354    fn test_ohlcv_series_percentile_rank_above_all() {
7355        // all closes = 100, value = 101 → all below → percentile = 100
7356        let mut series = OhlcvSeries::new();
7357        for _ in 0..4 {
7358            series.push(make_bar("100", "100", "100", "100")).unwrap();
7359        }
7360        assert_eq!(series.percentile_rank(dec!(101), 4).unwrap(), dec!(100));
7361    }
7362
7363    #[test]
7364    fn test_ohlcv_series_percentile_rank_below_all() {
7365        // all closes = 100, value = 99 → none below → percentile = 0
7366        let mut series = OhlcvSeries::new();
7367        for _ in 0..4 {
7368            series.push(make_bar("100", "100", "100", "100")).unwrap();
7369        }
7370        assert_eq!(series.percentile_rank(dec!(99), 4).unwrap(), dec!(0));
7371    }
7372
7373    #[test]
7374    fn test_ohlcv_series_consecutive_ups_empty() {
7375        assert_eq!(OhlcvSeries::new().consecutive_ups(), 0);
7376    }
7377
7378    #[test]
7379    fn test_ohlcv_series_consecutive_ups_all_bullish() {
7380        let mut series = OhlcvSeries::new();
7381        // bullish bar: open < close, make_bar(o, h, l, c)
7382        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
7383        series.push(make_bar("105", "115", "95", "110")).unwrap(); // bullish
7384        assert_eq!(series.consecutive_ups(), 2);
7385    }
7386
7387    #[test]
7388    fn test_ohlcv_series_consecutive_ups_broken_by_bearish() {
7389        let mut series = OhlcvSeries::new();
7390        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
7391        series.push(make_bar("110", "115", "95", "108")).unwrap(); // bearish
7392        series.push(make_bar("108", "115", "100", "112")).unwrap(); // bullish
7393        assert_eq!(series.consecutive_ups(), 1);
7394    }
7395
7396    #[test]
7397    fn test_ohlcv_series_consecutive_downs_counts_bearish_tail() {
7398        let mut series = OhlcvSeries::new();
7399        series.push(make_bar("100", "110", "90", "105")).unwrap(); // bullish
7400        series.push(make_bar("105", "110", "90", "100")).unwrap(); // bearish
7401        series.push(make_bar("100", "105", "85", "95")).unwrap(); // bearish
7402        assert_eq!(series.consecutive_downs(), 2);
7403        assert_eq!(series.consecutive_ups(), 0);
7404    }
7405
7406    #[test]
7407    fn test_ohlcv_bar_is_marubozu_full_body() {
7408        // open=100, high=110, low=100, close=110 → no shadows
7409        let bar = make_bar("100", "110", "100", "110");
7410        assert!(bar.is_marubozu());
7411    }
7412
7413    #[test]
7414    fn test_ohlcv_bar_is_marubozu_false_with_shadows() {
7415        let bar = make_bar("100", "115", "95", "110");
7416        assert!(!bar.is_marubozu());
7417    }
7418
7419    #[test]
7420    fn test_ohlcv_bar_is_spinning_top_true() {
7421        // range=40, body=2, upper=18, lower=20
7422        let bar = make_bar("100", "120", "80", "102");
7423        assert!(bar.is_spinning_top());
7424    }
7425
7426    #[test]
7427    fn test_ohlcv_bar_is_spinning_top_false_large_body() {
7428        // body=14, range=20 → body_ratio=0.70 > 0.30
7429        let bar = make_bar("100", "115", "95", "114");
7430        assert!(!bar.is_spinning_top());
7431    }
7432
7433    #[test]
7434    fn test_ohlcv_series_average_volume_all_same() {
7435        // make_bar always sets volume = 100
7436        let mut series = OhlcvSeries::new();
7437        series.push(make_bar("100", "110", "90", "105")).unwrap();
7438        series.push(make_bar("105", "115", "95", "110")).unwrap();
7439        assert_eq!(series.average_volume(2).unwrap(), dec!(100));
7440    }
7441
7442    #[test]
7443    fn test_ohlcv_series_average_range() {
7444        let mut series = OhlcvSeries::new();
7445        series.push(make_bar("100", "120", "80", "110")).unwrap(); // range=40
7446        series.push(make_bar("110", "125", "100", "115")).unwrap(); // range=25
7447        assert_eq!(series.average_range(2).unwrap(), dec!(32.5));
7448    }
7449
7450    #[test]
7451    fn test_ohlcv_series_average_volume_empty_returns_none() {
7452        let series = OhlcvSeries::new();
7453        assert!(series.average_volume(5).is_none());
7454    }
7455
7456    #[test]
7457    fn test_ohlcv_series_typical_price_mean_single_bar() {
7458        let mut series = OhlcvSeries::new();
7459        // typical = (120+80+110)/3 ≈ 103.333...
7460        let bar = make_bar("100", "120", "80", "110");
7461        series.push(bar).unwrap();
7462        let tp = series.typical_price_mean(1).unwrap();
7463        // (120+80+110)/3
7464        let expected = (dec!(120) + dec!(80) + dec!(110)) / dec!(3);
7465        assert_eq!(tp, expected);
7466    }
7467
7468    #[test]
7469    fn test_ohlcv_series_below_sma_zero_when_all_above() {
7470        let mut series = OhlcvSeries::new();
7471        for _ in 0..3 { series.push(make_bar("100", "110", "90", "100")).unwrap(); }
7472        // SMA(3) = 100, close=100, not strictly below → 0
7473        assert_eq!(series.below_sma(3, 3), 0);
7474    }
7475
7476    #[test]
7477    fn test_ohlcv_series_sortino_ratio_insufficient_data() {
7478        let mut series = OhlcvSeries::new();
7479        series.push(make_bar("100", "110", "90", "105")).unwrap();
7480        assert!(series.sortino_ratio(0.0, 252.0).is_none());
7481    }
7482
7483    #[test]
7484    fn test_ohlcv_bar_weighted_close_equals_hlcc4() {
7485        let bar = make_bar("100", "120", "80", "110");
7486        assert_eq!(bar.weighted_close(), bar.hlcc4());
7487    }
7488
7489    #[test]
7490    fn test_ohlcv_bar_weighted_close_value() {
7491        // (high + low + close*2) / 4 = (120 + 80 + 110 + 110) / 4 = 420/4 = 105
7492        let bar = make_bar("100", "120", "80", "110");
7493        assert_eq!(bar.weighted_close(), dec!(105));
7494    }
7495
7496    #[test]
7497    fn test_close_above_open_streak_three_bullish() {
7498        let mut series = OhlcvSeries::new();
7499        series.push(make_bar("100", "110", "90", "95")).unwrap();   // bearish
7500        series.push(make_bar("95", "110", "90", "105")).unwrap();   // bullish
7501        series.push(make_bar("105", "115", "100", "112")).unwrap(); // bullish
7502        series.push(make_bar("112", "120", "108", "118")).unwrap(); // bullish
7503        assert_eq!(series.close_above_open_streak(), 3);
7504    }
7505
7506    #[test]
7507    fn test_close_above_open_streak_last_bearish_returns_zero() {
7508        let mut series = OhlcvSeries::new();
7509        series.push(make_bar("105", "110", "100", "102")).unwrap(); // bullish
7510        series.push(make_bar("102", "108", "98", "99")).unwrap();   // bearish (close < open)
7511        assert_eq!(series.close_above_open_streak(), 0);
7512    }
7513
7514    #[test]
7515    fn test_close_above_open_streak_empty_series_returns_zero() {
7516        assert_eq!(OhlcvSeries::new().close_above_open_streak(), 0);
7517    }
7518
7519    #[test]
7520    fn test_max_drawdown_pct_declining_series() {
7521        let mut series = OhlcvSeries::new();
7522        series.push(make_bar("100", "110", "90", "100")).unwrap();
7523        series.push(make_bar("100", "105", "75", "80")).unwrap();  // 20% drawdown from 100
7524        series.push(make_bar("80", "85", "75", "84")).unwrap();
7525        let dd = series.max_drawdown_pct(10).unwrap();
7526        assert!((dd - 20.0).abs() < 1e-6, "expected ~20, got {dd}");
7527    }
7528
7529    #[test]
7530    fn test_max_drawdown_pct_flat_returns_zero() {
7531        let mut series = OhlcvSeries::new();
7532        series.push(make_bar("100", "110", "90", "100")).unwrap();
7533        series.push(make_bar("100", "110", "90", "100")).unwrap();
7534        assert_eq!(series.max_drawdown_pct(10).unwrap(), 0.0);
7535    }
7536
7537    #[test]
7538    fn test_max_drawdown_pct_single_bar_returns_none() {
7539        let mut series = OhlcvSeries::new();
7540        series.push(make_bar("100", "110", "90", "100")).unwrap();
7541        assert!(series.max_drawdown_pct(10).is_none());
7542    }
7543
7544    #[test]
7545    fn test_ohlcv_bar_gap_up_from_prev() {
7546        let prev = make_bar("100", "105", "95", "103");
7547        let curr = make_bar("107", "115", "106", "112"); // low(106) > prev.high(105)
7548        assert!(curr.gap_up_from(&prev));
7549    }
7550
7551    #[test]
7552    fn test_ohlcv_bar_no_gap_up() {
7553        let prev = make_bar("100", "110", "90", "105");
7554        let curr = make_bar("105", "112", "104", "108"); // low(104) < prev.high(110)
7555        assert!(!curr.gap_up_from(&prev));
7556    }
7557
7558    #[test]
7559    fn test_ohlcv_bar_gap_down_from_prev() {
7560        let prev = make_bar("100", "105", "95", "97");
7561        let curr = make_bar("93", "94", "88", "90"); // high(94) < prev.low(95)
7562        assert!(curr.gap_down_from(&prev));
7563    }
7564
7565    #[test]
7566    fn test_ohlcv_bar_no_gap_down() {
7567        let prev = make_bar("100", "110", "90", "95");
7568        let curr = make_bar("96", "100", "92", "98"); // high(100) > prev.low(90)
7569        assert!(!curr.gap_down_from(&prev));
7570    }
7571
7572    #[test]
7573    fn test_ohlcv_series_last_n_closes_returns_n() {
7574        let mut series = OhlcvSeries::new();
7575        for close in &["100", "102", "104", "106", "108"] {
7576            series.push(make_bar(close, "115", "95", close)).unwrap();
7577        }
7578        let closes = series.last_n_closes(3);
7579        assert_eq!(closes.len(), 3);
7580        assert_eq!(closes[2], dec!(108));
7581    }
7582
7583    #[test]
7584    fn test_ohlcv_series_last_n_closes_fewer_than_n() {
7585        let mut series = OhlcvSeries::new();
7586        series.push(make_bar("100", "110", "90", "100")).unwrap();
7587        let closes = series.last_n_closes(5);
7588        assert_eq!(closes.len(), 1);
7589    }
7590
7591    #[test]
7592    fn test_ohlcv_series_volume_spike_detects_spike() {
7593        use crate::types::{NanoTimestamp, Quantity, Symbol};
7594        let sym = Symbol::new("X").unwrap();
7595        let p = crate::types::Price::new(dec!(100)).unwrap();
7596        let mut series = OhlcvSeries::new();
7597        // Add 3 bars with low volume
7598        for _ in 0..3 {
7599            series.push(OhlcvBar {
7600                symbol: sym.clone(), open: p, high: p, low: p, close: p,
7601                volume: Quantity::new(dec!(100)).unwrap(),
7602                ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
7603            }).unwrap();
7604        }
7605        // Add a spike bar with 5× volume
7606        series.push(OhlcvBar {
7607            symbol: sym.clone(), open: p, high: p, low: p, close: p,
7608            volume: Quantity::new(dec!(500)).unwrap(),
7609            ts_open: NanoTimestamp::new(2), ts_close: NanoTimestamp::new(3), tick_count: 1,
7610        }).unwrap();
7611        assert!(series.volume_spike(3, dec!(3)));
7612    }
7613
7614    #[test]
7615    fn test_ohlcv_series_volume_spike_false_for_normal_volume() {
7616        use crate::types::{NanoTimestamp, Quantity, Symbol};
7617        let sym = Symbol::new("X").unwrap();
7618        let p = crate::types::Price::new(dec!(100)).unwrap();
7619        let mut series = OhlcvSeries::new();
7620        for _ in 0..4 {
7621            series.push(OhlcvBar {
7622                symbol: sym.clone(), open: p, high: p, low: p, close: p,
7623                volume: Quantity::new(dec!(100)).unwrap(),
7624                ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
7625            }).unwrap();
7626        }
7627        assert!(!series.volume_spike(3, dec!(3)));
7628    }
7629
7630    #[test]
7631    fn test_efficiency_ratio_trending() {
7632        let mut series = OhlcvSeries::new();
7633        // Strictly rising prices → direction == path → ER = 1
7634        for i in 0..6u32 {
7635            series.push(make_bar(&format!("{}", 100 + i), &format!("{}", 105 + i), &format!("{}", 99 + i), &format!("{}", 100 + i))).unwrap();
7636        }
7637        let er = series.efficiency_ratio(5).unwrap();
7638        assert_eq!(er, dec!(1));
7639    }
7640
7641    #[test]
7642    fn test_efficiency_ratio_none_when_not_enough_bars() {
7643        let mut series = OhlcvSeries::new();
7644        series.push(make_bar("100", "110", "90", "100")).unwrap();
7645        assert!(series.efficiency_ratio(5).is_none());
7646    }
7647
7648    #[test]
7649    fn test_efficiency_ratio_zero_period_returns_none() {
7650        let series = OhlcvSeries::new();
7651        assert!(series.efficiency_ratio(0).is_none());
7652    }
7653
7654    #[test]
7655    fn test_body_pct_series_full_body() {
7656        let mut series = OhlcvSeries::new();
7657        // Bar: open=90, close=110, high=110, low=90 → body=20, range=20 → 100%
7658        series.push(make_bar("90", "110", "90", "110")).unwrap();
7659        let v = series.body_pct_series(1);
7660        assert_eq!(v.len(), 1);
7661        assert_eq!(v[0], Some(dec!(100)));
7662    }
7663
7664    #[test]
7665    fn test_body_pct_series_zero_range_returns_none() {
7666        let mut series = OhlcvSeries::new();
7667        series.push(make_bar("100", "100", "100", "100")).unwrap();
7668        let v = series.body_pct_series(1);
7669        assert_eq!(v[0], None);
7670    }
7671
7672    #[test]
7673    fn test_candle_color_changes_alternating() {
7674        let mut series = OhlcvSeries::new();
7675        // Bullish, Bearish, Bullish → 2 changes
7676        series.push(make_bar("95", "110", "90", "105")).unwrap();  // bull
7677        series.push(make_bar("105", "115", "100", "102")).unwrap(); // bear
7678        series.push(make_bar("102", "115", "98", "110")).unwrap();  // bull
7679        assert_eq!(series.candle_color_changes(3), 2);
7680    }
7681
7682    #[test]
7683    fn test_candle_color_changes_no_changes() {
7684        let mut series = OhlcvSeries::new();
7685        // All bullish → 0 changes
7686        for _ in 0..3 {
7687            series.push(make_bar("95", "110", "90", "105")).unwrap();
7688        }
7689        assert_eq!(series.candle_color_changes(3), 0);
7690    }
7691
7692    #[test]
7693    fn test_typical_price_series_values() {
7694        let mut series = OhlcvSeries::new();
7695        // H=110, L=90, C=100 → tp = (110+90+100)/3 = 100
7696        series.push(make_bar("95", "110", "90", "100")).unwrap();
7697        let v = series.typical_price_series(1);
7698        assert_eq!(v.len(), 1);
7699        assert_eq!(v[0], dec!(100));
7700    }
7701
7702    #[test]
7703    fn test_typical_price_series_empty_series_returns_empty() {
7704        let series = OhlcvSeries::new();
7705        assert!(series.typical_price_series(3).is_empty());
7706    }
7707
7708    #[test]
7709    fn test_bar_at_index_valid() {
7710        let bars = vec![bar("100"), bar("101"), bar("102")];
7711        let series = OhlcvSeries::from_bars(bars).unwrap();
7712        assert!(series.bar_at_index(0).is_some());
7713        assert_eq!(series.bar_at_index(2).unwrap().close.value(), dec!(102));
7714    }
7715
7716    #[test]
7717    fn test_bar_at_index_out_of_bounds() {
7718        let bars = vec![bar("100")];
7719        let series = OhlcvSeries::from_bars(bars).unwrap();
7720        assert!(series.bar_at_index(5).is_none());
7721    }
7722
7723    #[test]
7724    fn test_rolling_close_std_returns_none_for_fewer_than_two() {
7725        let bars = vec![bar("100")];
7726        let series = OhlcvSeries::from_bars(bars).unwrap();
7727        assert!(series.rolling_close_std(1).is_none());
7728    }
7729
7730    #[test]
7731    fn test_rolling_close_std_constant_prices_is_zero() {
7732        let bars = vec![bar("100"), bar("100"), bar("100")];
7733        let series = OhlcvSeries::from_bars(bars).unwrap();
7734        let std = series.rolling_close_std(3).unwrap();
7735        assert_eq!(std, Decimal::ZERO);
7736    }
7737
7738    #[test]
7739    fn test_rolling_close_std_varying_prices_positive() {
7740        let bars = vec![bar("100"), bar("110"), bar("120"), bar("130")];
7741        let series = OhlcvSeries::from_bars(bars).unwrap();
7742        let std = series.rolling_close_std(4).unwrap();
7743        assert!(std > Decimal::ZERO);
7744    }
7745
7746    #[test]
7747    fn test_gap_direction_series_empty_for_single_bar() {
7748        let bars = vec![bar("100")];
7749        let series = OhlcvSeries::from_bars(bars).unwrap();
7750        assert!(series.gap_direction_series(3).is_empty());
7751    }
7752
7753    #[test]
7754    fn test_gap_direction_series_flat_on_equal_prices() {
7755        let bars = vec![bar("100"), bar("100"), bar("100")];
7756        let series = OhlcvSeries::from_bars(bars).unwrap();
7757        let gaps = series.gap_direction_series(3);
7758        assert!(gaps.iter().all(|&g| g == 0));
7759    }
7760
7761    #[test]
7762    fn test_gap_direction_series_detects_gap_up() {
7763        // bar opens 5 above prior close
7764        let p1 = Price::new(dec!(100)).unwrap();
7765        let p2 = Price::new(dec!(110)).unwrap();
7766        let b1 = OhlcvBar {
7767            symbol: Symbol::new("X").unwrap(),
7768            open: p1, high: p1, low: p1, close: p1,
7769            volume: Quantity::zero(),
7770            ts_open: NanoTimestamp::new(0),
7771            ts_close: NanoTimestamp::new(1),
7772            tick_count: 1,
7773        };
7774        let b2 = OhlcvBar {
7775            symbol: Symbol::new("X").unwrap(),
7776            open: p2, high: p2, low: p2, close: p2,
7777            volume: Quantity::zero(),
7778            ts_open: NanoTimestamp::new(2),
7779            ts_close: NanoTimestamp::new(3),
7780            tick_count: 1,
7781        };
7782        let series = OhlcvSeries::from_bars(vec![b1, b2]).unwrap();
7783        let gaps = series.gap_direction_series(2);
7784        assert_eq!(gaps, vec![1i8]);
7785    }
7786
7787    #[test]
7788    fn test_bullish_candle_pct_all_bullish() {
7789        // open < close for all bars → 100 %
7790        let bars = vec![
7791            make_bar("95", "105", "94", "100"),
7792            make_bar("99", "110", "98", "108"),
7793            make_bar("107", "115", "106", "112"),
7794        ];
7795        let series = OhlcvSeries::from_bars(bars).unwrap();
7796        assert_eq!(series.bullish_candle_pct(3).unwrap(), 1.0);
7797    }
7798
7799    #[test]
7800    fn test_bullish_candle_pct_none_for_zero_n() {
7801        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7802        assert!(series.bullish_candle_pct(0).is_none());
7803    }
7804
7805    #[test]
7806    fn test_price_above_ma_pct_all_above() {
7807        // Rising prices: every close will be above the 2-bar SMA of the prior window.
7808        let bars = vec![
7809            bar("100"), bar("102"), bar("104"), bar("106"), bar("108"),
7810        ];
7811        let series = OhlcvSeries::from_bars(bars).unwrap();
7812        // n=3, period=2 → need at least 4 bars
7813        let pct = series.price_above_ma_pct(3, 2).unwrap();
7814        assert!(pct > 0.0);
7815    }
7816
7817    #[test]
7818    fn test_price_above_ma_pct_insufficient_bars() {
7819        let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
7820        assert!(series.price_above_ma_pct(2, 3).is_none());
7821    }
7822
7823    #[test]
7824    fn test_avg_body_size_flat() {
7825        // open == close → body = 0
7826        let bars = vec![bar("100"), bar("100"), bar("100")];
7827        let series = OhlcvSeries::from_bars(bars).unwrap();
7828        assert_eq!(series.avg_body_size(3).unwrap(), dec!(0));
7829    }
7830
7831    #[test]
7832    fn test_avg_body_size_none_for_zero_n() {
7833        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7834        assert!(series.avg_body_size(0).is_none());
7835    }
7836
7837    #[test]
7838    fn test_true_range_series_flat() {
7839        let bars = vec![bar("100"), bar("100"), bar("100")];
7840        let series = OhlcvSeries::from_bars(bars).unwrap();
7841        let trs = series.true_range_series(3).unwrap();
7842        assert_eq!(trs.len(), 3);
7843        // All flat bars → true range = 0
7844        for tr in trs {
7845            assert_eq!(tr, dec!(0));
7846        }
7847    }
7848
7849    #[test]
7850    fn test_true_range_series_none_when_insufficient() {
7851        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7852        assert!(series.true_range_series(0).is_none());
7853        assert!(series.true_range_series(2).is_none());
7854    }
7855
7856    #[test]
7857    fn test_intraday_return_pct_positive() {
7858        // bar() uses same price for open and close, so use custom bars
7859        let make_bar = |o: &str, c: &str| {
7860            let op = Price::new(o.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
7861            let cl = Price::new(c.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
7862            OhlcvBar {
7863                symbol: Symbol::new("X").unwrap(),
7864                open: op, high: cl, low: op, close: cl,
7865                volume: Quantity::zero(),
7866                ts_open: NanoTimestamp::new(0),
7867                ts_close: NanoTimestamp::new(1),
7868                tick_count: 1,
7869            }
7870        };
7871        let series = OhlcvSeries::from_bars(vec![make_bar("100", "110")]).unwrap();
7872        // (110 - 100) / 100 * 100 = 10%
7873        assert_eq!(series.intraday_return_pct().unwrap(), dec!(10));
7874    }
7875
7876    #[test]
7877    fn test_intraday_return_pct_empty() {
7878        assert!(OhlcvSeries::new().intraday_return_pct().is_none());
7879    }
7880
7881    #[test]
7882    fn test_bearish_bar_count_all_flat() {
7883        let bars = vec![bar("100"), bar("100"), bar("100")];
7884        let series = OhlcvSeries::from_bars(bars).unwrap();
7885        // flat bars (open == close) are not bearish
7886        assert_eq!(series.bearish_bar_count(3).unwrap(), 0);
7887    }
7888
7889    #[test]
7890    fn test_bearish_bar_count_none_insufficient() {
7891        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7892        assert!(series.bearish_bar_count(0).is_none());
7893        assert!(series.bearish_bar_count(2).is_none());
7894    }
7895
7896    #[test]
7897    fn test_hl_midpoint_flat() {
7898        let bars = vec![bar("100"), bar("100"), bar("100")];
7899        let series = OhlcvSeries::from_bars(bars).unwrap();
7900        assert_eq!(series.hl_midpoint(3).unwrap(), dec!(100));
7901    }
7902
7903    #[test]
7904    fn test_hl_midpoint_none_when_insufficient() {
7905        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7906        assert!(series.hl_midpoint(0).is_none());
7907        assert!(series.hl_midpoint(2).is_none());
7908    }
7909
7910    #[test]
7911    fn test_up_volume_ratio_flat_bars() {
7912        // flat bars (close == open) → no up-volume → ratio = 0
7913        let bars = vec![bar("100"), bar("100"), bar("100")];
7914        let series = OhlcvSeries::from_bars(bars).unwrap();
7915        // bars have non-zero volume (make_bar uses qty 100); flat → up_vol = 0
7916        let ratio = series.up_volume_ratio(3);
7917        if let Some(r) = ratio {
7918            assert_eq!(r, dec!(0));
7919        }
7920        // None is also valid if volume were truly zero
7921    }
7922
7923    #[test]
7924    fn test_price_efficiency_trending() {
7925        // Monotonically rising prices → path equals net → efficiency = 1
7926        let bars: Vec<_> = (100..106u32).map(|i| bar(&i.to_string())).collect();
7927        let series = OhlcvSeries::from_bars(bars).unwrap();
7928        assert_eq!(series.price_efficiency(5).unwrap(), dec!(1));
7929    }
7930
7931    #[test]
7932    fn test_price_efficiency_none_insufficient() {
7933        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7934        assert!(series.price_efficiency(1).is_none());
7935        assert!(series.price_efficiency(3).is_none());
7936    }
7937
7938    #[test]
7939    fn test_avg_gap_zero_when_no_jumps() {
7940        let bars = vec![bar("100"), bar("100"), bar("100")];
7941        let series = OhlcvSeries::from_bars(bars).unwrap();
7942        assert_eq!(series.avg_gap(2).unwrap(), dec!(0));
7943    }
7944
7945    #[test]
7946    fn test_avg_gap_none_when_insufficient() {
7947        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7948        assert!(series.avg_gap(0).is_none());
7949        assert!(series.avg_gap(1).is_none());
7950    }
7951
7952    #[test]
7953    fn test_largest_gap_pct_no_gap() {
7954        // all bars open == prev close → gap = 0
7955        let bars: Vec<_> = (0..5).map(|_| bar("100")).collect();
7956        let series = OhlcvSeries::from_bars(bars).unwrap();
7957        assert_eq!(series.largest_gap_pct(4).unwrap(), dec!(0));
7958    }
7959
7960    #[test]
7961    fn test_largest_gap_pct_none_insufficient() {
7962        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
7963        assert!(series.largest_gap_pct(2).is_none());
7964        assert!(series.largest_gap_pct(1).is_none());
7965    }
7966
7967    #[test]
7968    fn test_close_momentum_flat_zero() {
7969        let bars: Vec<_> = (0..6).map(|_| bar("100")).collect();
7970        let series = OhlcvSeries::from_bars(bars).unwrap();
7971        assert_eq!(series.close_momentum(3).unwrap(), dec!(0));
7972    }
7973
7974    #[test]
7975    fn test_close_momentum_none_insufficient() {
7976        // close_momentum(n) needs n+1 bars; with 2 bars, n=1 is valid but n=2 is not
7977        let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
7978        assert!(series.close_momentum(2).is_none()); // need 3 bars
7979        assert!(series.close_momentum(0).is_none());
7980    }
7981
7982    #[test]
7983    fn test_swing_high_count_none_when_insufficient() {
7984        let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
7985        assert!(series.swing_high_count(5, 1).is_none()); // fewer than n bars
7986        assert!(series.swing_high_count(0, 1).is_none()); // n=0
7987        assert!(series.swing_high_count(2, 0).is_none()); // lookback=0
7988    }
7989
7990    #[test]
7991    fn test_swing_high_count_detects_peak() {
7992        // Pattern: 100, 110, 100, 100, 100 — middle bar is a swing high with lookback=1
7993        let bars = vec![bar("100"), bar("110"), bar("100"), bar("100"), bar("100")];
7994        let series = OhlcvSeries::from_bars(bars).unwrap();
7995        let count = series.swing_high_count(5, 1).unwrap();
7996        assert_eq!(count, 1);
7997    }
7998
7999    #[test]
8000    fn test_swing_high_count_flat_no_highs() {
8001        let bars: Vec<_> = (0..7).map(|_| bar("100")).collect();
8002        let series = OhlcvSeries::from_bars(bars).unwrap();
8003        assert_eq!(series.swing_high_count(7, 1).unwrap(), 0);
8004    }
8005
8006    #[test]
8007    fn test_avg_wick_pct_none_when_zero_range() {
8008        let bars = vec![bar("100"), bar("100")];
8009        let series = OhlcvSeries::from_bars(bars).unwrap();
8010        assert!(series.avg_wick_pct(2).is_none()); // zero-range bars → None
8011    }
8012
8013    #[test]
8014    fn test_avg_wick_pct_none_insufficient() {
8015        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8016        assert!(series.avg_wick_pct(0).is_none());
8017        assert!(series.avg_wick_pct(2).is_none());
8018    }
8019
8020    #[test]
8021    fn test_trend_continuation_pct_none_insufficient() {
8022        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8023        assert!(series.trend_continuation_pct(0).is_none());
8024        assert!(series.trend_continuation_pct(1).is_none()); // need n+1=2 bars
8025    }
8026
8027    fn make_bar_vol(o: &str, h: &str, l: &str, c: &str, vol: &str) -> OhlcvBar {
8028        OhlcvBar {
8029            symbol: Symbol::new("X").unwrap(),
8030            open: make_price(o),
8031            high: make_price(h),
8032            low: make_price(l),
8033            close: make_price(c),
8034            volume: make_qty(vol),
8035            ts_open: NanoTimestamp::new(0),
8036            ts_close: NanoTimestamp::new(1),
8037            tick_count: 1,
8038        }
8039    }
8040
8041    #[test]
8042    fn test_close_to_open_ratio_bullish() {
8043        // close > open → ratio > 1
8044        let bars = vec![
8045            make_bar_vol("100", "110", "95", "110", "1000"),  // close/open = 1.1
8046            make_bar_vol("105", "115", "100", "115", "1000"), // close/open ≈ 1.095
8047        ];
8048        let series = OhlcvSeries::from_bars(bars).unwrap();
8049        let ratio = series.close_to_open_ratio(2).unwrap();
8050        assert!(ratio > dec!(1), "bullish bars: ratio > 1, got {ratio}");
8051    }
8052
8053    #[test]
8054    fn test_close_to_open_ratio_none_zero_n() {
8055        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8056        assert!(series.close_to_open_ratio(0).is_none());
8057    }
8058
8059    #[test]
8060    fn test_volume_trend_rising() {
8061        let bars: Vec<OhlcvBar> = (1..=5u32).map(|i| {
8062            make_bar_vol("100", "100", "100", "100", &(i * 100).to_string())
8063        }).collect();
8064        let series = OhlcvSeries::from_bars(bars).unwrap();
8065        let slope = series.volume_trend(5).unwrap();
8066        assert!(slope > 0.0_f64, "rising volume: positive slope, got {slope}");
8067    }
8068
8069    #[test]
8070    fn test_volume_trend_none_insufficient() {
8071        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8072        assert!(series.volume_trend(0).is_none());
8073        assert!(series.volume_trend(2).is_none()); // only 1 bar, need >= 2
8074    }
8075
8076    #[test]
8077    fn test_high_volume_price_returns_close_of_max_vol_bar() {
8078        let bars = vec![
8079            make_bar_vol("100", "100", "100", "100", "500"),
8080            make_bar_vol("200", "200", "200", "200", "1000"), // highest volume
8081            make_bar_vol("150", "150", "150", "150", "300"),
8082        ];
8083        let series = OhlcvSeries::from_bars(bars).unwrap();
8084        assert_eq!(series.high_volume_price(3), Some(dec!(200)));
8085    }
8086
8087    #[test]
8088    fn test_high_volume_price_none_zero_n() {
8089        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8090        assert!(series.high_volume_price(0).is_none());
8091    }
8092
8093    #[test]
8094    fn test_avg_close_minus_open_bullish() {
8095        let bars = vec![
8096            make_bar_vol("100", "110", "95", "105", "1000"), // +5
8097            make_bar_vol("105", "115", "100", "108", "1000"), // +3
8098        ];
8099        let series = OhlcvSeries::from_bars(bars).unwrap();
8100        let avg = series.avg_close_minus_open(2).unwrap();
8101        assert_eq!(avg, dec!(4)); // (5 + 3) / 2 = 4
8102    }
8103
8104    #[test]
8105    fn test_avg_close_minus_open_none_zero_n() {
8106        let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
8107        assert!(series.avg_close_minus_open(0).is_none());
8108    }
8109}