opendeviationbar-core 13.75.1

Core open deviation bar construction algorithm with temporal integrity guarantees
Documentation
//! Streaming O(1)-memory accumulator for intra-bar drawdown / runup.
//!
//! #363 follow-up: the `MAX_ACCUMULATED_TRADES` cap added to
//! `OpenDeviationBarState::accumulated_trades` for OOM protection silently
//! truncated the input fed to `bull_ith` / `bear_ith`, which caused
//! `intra_max_drawdown` and `intra_max_runup` to drift on bars exceeding
//! the cap (golden snapshot bar[16]: 103,465 agg trades > 100,000).
//!
//! This accumulator runs unconditionally on every trade in the bar,
//! computing the same `running_max` / `running_min` / `max_drawdown` /
//! `max_runup` quantities that `bull_ith` / `bear_ith` compute internally
//! — but with O(1) memory regardless of bar size. Result: bit-exact
//! drawdown / runup values without re-introducing the OOM risk.
//!
//! The bull/bear ITH state machines (epoch density, excess gain, CV) still
//! consume the capped buffer; those features remain bounded-degraded for
//! outlier bars per the original #363 design.
//!
//! ## Bit-exactness contract
//!
//! Values match `bull_ith(nav, _).max_drawdown` and `bear_ith(nav, _).max_runup`
//! where `nav[i] = price[i] * (1.0 / price[0])`. The same multiplication
//! order (`price * inv_first_price`, not `price / first_price`) is used
//! to preserve floating-point identity with the existing pipeline.

/// Streaming drawdown / runup accumulator.
///
/// Mirrors the `running_max` / `running_min` tracking inside
/// `bull_ith` / `bear_ith` so we can recover their `max_drawdown` /
/// `max_runup` outputs without buffering trades.
#[derive(Debug, Clone, Default)]
pub struct IntraStreamAccumulator {
    inv_first_price: Option<f64>,
    running_max: f64,
    running_min: f64,
    max_drawdown: f64,
    max_runup: f64,
    trade_count: usize,
}

impl IntraStreamAccumulator {
    #[inline]
    pub fn new() -> Self {
        Self::default()
    }

    /// Feed the next trade's raw price. No-op for non-positive / non-finite prices.
    ///
    /// Algorithmic parity with `bull_ith` / `bear_ith`:
    /// - First valid price: initialize `running_max = running_min = price * inv`
    ///   (matches `nav[0]` in the existing pipeline).
    /// - Subsequent prices: update extrema, compute drawdown / runup using the
    ///   same `1.0 - val / running_max` and `1.0 - running_min / val` forms
    ///   (no `is_finite()` gate — only `> 0.0`, matching the existing impls).
    #[inline]
    pub fn update(&mut self, price: f64) {
        if !price.is_finite() || price <= 0.0 {
            return;
        }
        match self.inv_first_price {
            None => {
                let inv = 1.0 / price;
                self.inv_first_price = Some(inv);
                let first_nav = price * inv;
                self.running_max = first_nav;
                self.running_min = first_nav;
                // bull_ith / bear_ith init max_drawdown / max_runup at 0.0
                // (no f64::EPSILON clamp — that lives in the standalone
                // compute_max_drawdown / compute_max_runup helpers, NOT in
                // bull_ith.max_drawdown / bear_ith.max_runup which feed
                // intra_max_drawdown / intra_max_runup downstream).
                self.trade_count = 1;
            }
            Some(inv) => {
                let nav = price * inv;
                if nav > self.running_max {
                    self.running_max = nav;
                }
                if nav < self.running_min {
                    self.running_min = nav;
                }
                if self.running_max > 0.0 {
                    let dd = 1.0 - nav / self.running_max;
                    if dd > self.max_drawdown {
                        self.max_drawdown = dd;
                    }
                }
                if nav > 0.0 {
                    let ru = 1.0 - self.running_min / nav;
                    if ru > self.max_runup {
                        self.max_runup = ru;
                    }
                }
                self.trade_count += 1;
            }
        }
    }

    /// `(max_drawdown, max_runup)` if at least 2 trades have been observed.
    /// Returns `None` for 0/1 trade bars (matching `compute_intra_bar_features`'s
    /// early-exit which leaves these fields as `None`).
    #[inline]
    pub fn finalize(&self) -> Option<(f64, f64)> {
        if self.trade_count >= 2 {
            Some((self.max_drawdown, self.max_runup))
        } else {
            None
        }
    }

    #[inline]
    pub fn trade_count(&self) -> usize {
        self.trade_count
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::intrabar::ith::{bear_ith, bull_ith};

    /// Replicate the existing `nav[i] = price[i] * (1.0 / price[0])` normalization
    /// and run `bull_ith` / `bear_ith` to derive the reference `max_drawdown` /
    /// `max_runup`. The streaming accumulator must produce bit-exact identical
    /// values.
    fn reference_dd_ru(prices: &[f64]) -> (f64, f64) {
        let inv = 1.0 / prices[0];
        let nav: Vec<f64> = prices.iter().map(|p| p * inv).collect();
        // tmaeg unused for max_drawdown / max_runup — the bull_ith state machine's
        // "independent calculation" section ignores tmaeg for those scalars.
        let bull = bull_ith(&nav, 0.0);
        let bear = bear_ith(&nav, 0.0);
        (bull.max_drawdown, bear.max_runup)
    }

    #[test]
    fn matches_bull_ith_drawdown_simple() {
        let prices = vec![100.0, 110.0, 95.0, 105.0, 90.0];
        let (ref_dd, ref_ru) = reference_dd_ru(&prices);
        let mut acc = IntraStreamAccumulator::new();
        for p in &prices {
            acc.update(*p);
        }
        let (dd, ru) = acc.finalize().expect("≥2 trades");
        assert_eq!(dd.to_bits(), ref_dd.to_bits(), "drawdown bit-exact");
        assert_eq!(ru.to_bits(), ref_ru.to_bits(), "runup bit-exact");
    }

    #[test]
    fn matches_bull_ith_drawdown_uptrend() {
        let prices = vec![100.0, 101.0, 102.0, 103.0, 104.0, 105.0];
        let (ref_dd, ref_ru) = reference_dd_ru(&prices);
        let mut acc = IntraStreamAccumulator::new();
        for p in &prices {
            acc.update(*p);
        }
        let (dd, ru) = acc.finalize().expect("≥2 trades");
        assert_eq!(dd.to_bits(), ref_dd.to_bits());
        assert_eq!(ru.to_bits(), ref_ru.to_bits());
    }

    #[test]
    fn matches_bull_ith_large_window() {
        // Synthetic walk that exceeds the buffered path's typical sizes.
        // The whole point of the streaming accumulator is to be bit-exact
        // even when the buffered path would have been truncated.
        let mut prices = Vec::with_capacity(150_000);
        let mut p = 100.0;
        for i in 0..150_000_u32 {
            // Simple deterministic walk — not random, so reference and
            // streaming see the exact same f64 sequence.
            p += if i.is_multiple_of(7) { -0.13 } else { 0.05 };
            prices.push(p);
        }
        let (ref_dd, ref_ru) = reference_dd_ru(&prices);
        let mut acc = IntraStreamAccumulator::new();
        for p in &prices {
            acc.update(*p);
        }
        let (dd, ru) = acc.finalize().expect("≥2 trades");
        assert_eq!(
            dd.to_bits(),
            ref_dd.to_bits(),
            "drawdown bit-exact at 150K trades"
        );
        assert_eq!(
            ru.to_bits(),
            ref_ru.to_bits(),
            "runup bit-exact at 150K trades"
        );
    }

    #[test]
    fn empty_returns_none() {
        let acc = IntraStreamAccumulator::new();
        assert!(acc.finalize().is_none());
    }

    #[test]
    fn single_trade_returns_none() {
        let mut acc = IntraStreamAccumulator::new();
        acc.update(100.0);
        assert!(acc.finalize().is_none());
    }

    #[test]
    fn ignores_invalid_prices() {
        let mut acc = IntraStreamAccumulator::new();
        acc.update(f64::NAN);
        acc.update(0.0);
        acc.update(-1.0);
        acc.update(f64::INFINITY);
        assert!(acc.finalize().is_none());
        acc.update(100.0);
        acc.update(99.0);
        let (dd, _) = acc.finalize().expect("≥2 valid trades");
        assert!(dd > 0.0);
    }
}