Skip to main content

indicators/
primitives.rs

1//! Technical Indicators for Regime Detection
2//!
3//! Self-contained indicator implementations used by the regime detection system.
4//! Provides EMA, ATR, ADX, and Bollinger Bands calculations optimized for
5//! market regime classification.
6//!
7//! These are intentionally kept within the regime crate rather than depending on
8//! `indicators`, because:
9//! 1. The regime crate needs specific indicator semantics (e.g., ADX with DI crossover)
10//! 2. Keeps the crate self-contained with zero internal dependencies
11//! 3. `indicators` can later delegate to these if desired
12
13use super::types::TrendDirection;
14use std::collections::VecDeque;
15
16// ============================================================================
17// Exponential Moving Average (EMA)
18// ============================================================================
19
20/// Exponential Moving Average calculator
21///
22/// Uses the standard EMA formula: EMA_t = price * k + EMA_{t-1} * (1 - k)
23/// where k = 2 / (period + 1)
24#[derive(Debug, Clone)]
25pub struct EMA {
26    period: usize,
27    multiplier: f64,
28    current_value: Option<f64>,
29    initialized: bool,
30    warmup_count: usize,
31}
32
33impl EMA {
34    /// Create a new EMA with the given period
35    pub fn new(period: usize) -> Self {
36        let multiplier = 2.0 / (period as f64 + 1.0);
37        Self {
38            period,
39            multiplier,
40            current_value: None,
41            initialized: false,
42            warmup_count: 0,
43        }
44    }
45
46    /// Update with a new price value, returning the EMA if warmed up
47    pub fn update(&mut self, price: f64) -> Option<f64> {
48        self.warmup_count += 1;
49
50        match self.current_value {
51            Some(prev_ema) => {
52                let new_ema = (price - prev_ema) * self.multiplier + prev_ema;
53                self.current_value = Some(new_ema);
54
55                if self.warmup_count >= self.period {
56                    self.initialized = true;
57                }
58            }
59            None => {
60                self.current_value = Some(price);
61            }
62        }
63
64        if self.initialized {
65            self.current_value
66        } else {
67            None
68        }
69    }
70
71    /// Get the current EMA value (None if not yet warmed up)
72    pub fn value(&self) -> Option<f64> {
73        if self.initialized {
74            self.current_value
75        } else {
76            None
77        }
78    }
79
80    /// Check if the EMA has enough data to produce valid values
81    pub fn is_ready(&self) -> bool {
82        self.initialized
83    }
84
85    /// Get the period
86    pub fn period(&self) -> usize {
87        self.period
88    }
89
90    /// Reset the EMA state
91    pub fn reset(&mut self) {
92        self.current_value = None;
93        self.initialized = false;
94        self.warmup_count = 0;
95    }
96}
97
98// ============================================================================
99// Average True Range (ATR)
100// ============================================================================
101
102/// Average True Range (ATR) calculator
103///
104/// Uses Wilder's smoothing method for the ATR calculation.
105/// True Range = max(High - Low, |High - PrevClose|, |Low - PrevClose|)
106#[derive(Debug, Clone)]
107pub struct ATR {
108    period: usize,
109    values: VecDeque<f64>,
110    prev_close: Option<f64>,
111    current_atr: Option<f64>,
112}
113
114impl ATR {
115    /// Create a new ATR with the given period
116    pub fn new(period: usize) -> Self {
117        Self {
118            period,
119            values: VecDeque::with_capacity(period),
120            prev_close: None,
121            current_atr: None,
122        }
123    }
124
125    /// Update with OHLC data, returning the ATR if warmed up
126    pub fn update(&mut self, high: f64, low: f64, close: f64) -> Option<f64> {
127        let true_range = match self.prev_close {
128            Some(prev_c) => {
129                let hl = high - low;
130                let hc = (high - prev_c).abs();
131                let lc = (low - prev_c).abs();
132                hl.max(hc).max(lc)
133            }
134            None => high - low,
135        };
136
137        self.prev_close = Some(close);
138        self.values.push_back(true_range);
139
140        if self.values.len() > self.period {
141            self.values.pop_front();
142        }
143
144        if self.values.len() >= self.period {
145            // Use Wilder's smoothing method
146            if let Some(prev_atr) = self.current_atr {
147                let new_atr =
148                    (prev_atr * (self.period - 1) as f64 + true_range) / self.period as f64;
149                self.current_atr = Some(new_atr);
150            } else {
151                let sum: f64 = self.values.iter().sum();
152                self.current_atr = Some(sum / self.period as f64);
153            }
154        }
155
156        self.current_atr
157    }
158
159    /// Get the current ATR value
160    pub fn value(&self) -> Option<f64> {
161        self.current_atr
162    }
163
164    /// Check if the ATR has enough data
165    pub fn is_ready(&self) -> bool {
166        self.current_atr.is_some()
167    }
168
169    /// Get the period
170    pub fn period(&self) -> usize {
171        self.period
172    }
173
174    /// Reset the ATR state
175    pub fn reset(&mut self) {
176        self.values.clear();
177        self.prev_close = None;
178        self.current_atr = None;
179    }
180}
181
182// ============================================================================
183// Average Directional Index (ADX)
184// ============================================================================
185
186/// Average Directional Index (ADX) calculator
187///
188/// Measures trend strength (not direction). Values above 25 typically indicate
189/// a strong trend, while values below 20 suggest a ranging market.
190///
191/// Also provides +DI and -DI for trend direction via `trend_direction()`.
192#[derive(Debug, Clone)]
193pub struct ADX {
194    period: usize,
195    atr: ATR,
196    plus_dm_ema: EMA,
197    minus_dm_ema: EMA,
198    dx_values: VecDeque<f64>,
199    prev_high: Option<f64>,
200    prev_low: Option<f64>,
201    current_adx: Option<f64>,
202    plus_dir_index: Option<f64>,
203    minus_dir_index: Option<f64>,
204}
205
206impl ADX {
207    /// Create a new ADX with the given period
208    pub fn new(period: usize) -> Self {
209        Self {
210            period,
211            atr: ATR::new(period),
212            plus_dm_ema: EMA::new(period),
213            minus_dm_ema: EMA::new(period),
214            dx_values: VecDeque::with_capacity(period),
215            prev_high: None,
216            prev_low: None,
217            current_adx: None,
218            plus_dir_index: None,
219            minus_dir_index: None,
220        }
221    }
222
223    /// Update with HLC data, returning the ADX value if warmed up
224    pub fn update(&mut self, high: f64, low: f64, close: f64) -> Option<f64> {
225        // Calculate directional movement
226        let (plus_dm, minus_dm) = match (self.prev_high, self.prev_low) {
227            (Some(prev_h), Some(prev_l)) => {
228                let up_move = high - prev_h;
229                let down_move = prev_l - low;
230
231                let plus = if up_move > down_move && up_move > 0.0 {
232                    up_move
233                } else {
234                    0.0
235                };
236
237                let minus = if down_move > up_move && down_move > 0.0 {
238                    down_move
239                } else {
240                    0.0
241                };
242
243                (plus, minus)
244            }
245            _ => (0.0, 0.0),
246        };
247
248        self.prev_high = Some(high);
249        self.prev_low = Some(low);
250
251        // Update ATR
252        let atr = self.atr.update(high, low, close);
253
254        // Smooth directional movement
255        let smoothed_plus_dm = self.plus_dm_ema.update(plus_dm);
256        let smoothed_minus_dm = self.minus_dm_ema.update(minus_dm);
257
258        // Calculate DI values
259        if let (Some(atr_val), Some(plus_dm_smooth), Some(minus_dm_smooth)) =
260            (atr, smoothed_plus_dm, smoothed_minus_dm)
261            && atr_val > 0.0
262        {
263            let plus_dir_index = (plus_dm_smooth / atr_val) * 100.0;
264            let minus_dir_index = (minus_dm_smooth / atr_val) * 100.0;
265            self.plus_dir_index = Some(plus_dir_index);
266            self.minus_dir_index = Some(minus_dir_index);
267
268            // Calculate DX
269            let di_sum = plus_dir_index + minus_dir_index;
270            if di_sum > 0.0 {
271                let di_diff = (plus_dir_index - minus_dir_index).abs();
272                let dx = (di_diff / di_sum) * 100.0;
273
274                self.dx_values.push_back(dx);
275                if self.dx_values.len() > self.period {
276                    self.dx_values.pop_front();
277                }
278
279                // Calculate ADX as smoothed DX
280                if self.dx_values.len() >= self.period {
281                    if let Some(prev_adx) = self.current_adx {
282                        let new_adx =
283                            (prev_adx * (self.period - 1) as f64 + dx) / self.period as f64;
284                        self.current_adx = Some(new_adx);
285                    } else {
286                        let sum: f64 = self.dx_values.iter().sum();
287                        self.current_adx = Some(sum / self.period as f64);
288                    }
289                }
290            }
291        }
292
293        self.current_adx
294    }
295
296    /// Get the current ADX value
297    pub fn value(&self) -> Option<f64> {
298        self.current_adx
299    }
300
301    /// Get the +DI value
302    pub fn plus_dir_index(&self) -> Option<f64> {
303        self.plus_dir_index
304    }
305
306    /// Get the -DI value
307    pub fn minus_dir_index(&self) -> Option<f64> {
308        self.minus_dir_index
309    }
310
311    /// Returns trend direction based on DI crossover.
312    ///
313    /// - `+DI > -DI` → Bullish
314    /// - `-DI > +DI` → Bearish
315    pub fn trend_direction(&self) -> Option<TrendDirection> {
316        match (self.plus_dir_index, self.minus_dir_index) {
317            (Some(plus), Some(minus)) => {
318                if plus > minus {
319                    Some(TrendDirection::Bullish)
320                } else {
321                    Some(TrendDirection::Bearish)
322                }
323            }
324            _ => None,
325        }
326    }
327
328    /// Check if the ADX has enough data
329    pub fn is_ready(&self) -> bool {
330        self.current_adx.is_some()
331    }
332
333    /// Get the period
334    pub fn period(&self) -> usize {
335        self.period
336    }
337
338    /// Reset the ADX state
339    pub fn reset(&mut self) {
340        self.atr.reset();
341        self.plus_dm_ema.reset();
342        self.minus_dm_ema.reset();
343        self.dx_values.clear();
344        self.prev_high = None;
345        self.prev_low = None;
346        self.current_adx = None;
347        self.plus_dir_index = None;
348        self.minus_dir_index = None;
349    }
350}
351
352// ============================================================================
353// Bollinger Bands
354// ============================================================================
355
356/// Bollinger Bands output values
357#[derive(Debug, Clone, Copy)]
358pub struct BollingerBandsValues {
359    /// Upper band (SMA + n * σ)
360    pub upper: f64,
361    /// Middle band (SMA)
362    pub middle: f64,
363    /// Lower band (SMA - n * σ)
364    pub lower: f64,
365    /// Band width as percentage of price
366    pub width: f64,
367    /// Where current width ranks historically (0–100 percentile)
368    pub width_percentile: f64,
369    /// Where price is within the bands (0.0 = lower, 1.0 = upper)
370    pub percent_b: f64,
371    /// Standard deviation of prices
372    pub std_dev: f64,
373}
374
375impl BollingerBandsValues {
376    /// Is price overbought (near or above upper band)?
377    pub fn is_overbought(&self) -> bool {
378        self.percent_b >= 0.95
379    }
380
381    /// Is price oversold (near or below lower band)?
382    pub fn is_oversold(&self) -> bool {
383        self.percent_b <= 0.05
384    }
385
386    /// Are bands wide (high volatility)?
387    pub fn is_high_volatility(&self, threshold_percentile: f64) -> bool {
388        self.width_percentile >= threshold_percentile
389    }
390
391    /// Are bands narrow (potential breakout coming)?
392    pub fn is_squeeze(&self, threshold_percentile: f64) -> bool {
393        self.width_percentile <= threshold_percentile
394    }
395}
396
397/// Bollinger Bands calculator
398///
399/// Computes upper, lower, and middle bands along with band width percentile
400/// for volatility regime classification.
401#[derive(Debug, Clone)]
402pub struct BollingerBands {
403    period: usize,
404    std_dev_multiplier: f64,
405    prices: VecDeque<f64>,
406    width_history: VecDeque<f64>,
407    width_history_size: usize,
408}
409
410impl BollingerBands {
411    /// Create a new Bollinger Bands calculator
412    ///
413    /// # Arguments
414    /// * `period` - Lookback period for the SMA (typically 20)
415    /// * `std_dev_multiplier` - Standard deviation multiplier (typically 2.0)
416    pub fn new(period: usize, std_dev_multiplier: f64) -> Self {
417        Self {
418            period,
419            std_dev_multiplier,
420            prices: VecDeque::with_capacity(period),
421            width_history: VecDeque::with_capacity(100),
422            width_history_size: 100, // Keep 100 periods for percentile calc
423        }
424    }
425
426    /// Update with a new price, returning band values if warmed up
427    pub fn update(&mut self, price: f64) -> Option<BollingerBandsValues> {
428        self.prices.push_back(price);
429        if self.prices.len() > self.period {
430            self.prices.pop_front();
431        }
432
433        if self.prices.len() < self.period {
434            return None;
435        }
436
437        // Calculate SMA (middle band)
438        let sum: f64 = self.prices.iter().sum();
439        let sma = sum / self.period as f64;
440
441        // Calculate standard deviation
442        let variance: f64 =
443            self.prices.iter().map(|p| (p - sma).powi(2)).sum::<f64>() / self.period as f64;
444        let std_dev = variance.sqrt();
445
446        // Calculate bands
447        let upper = sma + (std_dev * self.std_dev_multiplier);
448        let lower = sma - (std_dev * self.std_dev_multiplier);
449        let width = if sma > 0.0 {
450            (upper - lower) / sma * 100.0 // Width as percentage of price
451        } else {
452            0.0
453        };
454
455        // Update width history for percentile calculation
456        self.width_history.push_back(width);
457        if self.width_history.len() > self.width_history_size {
458            self.width_history.pop_front();
459        }
460
461        // Calculate width percentile
462        let width_percentile = self.calculate_width_percentile(width);
463
464        // Calculate %B (where price is within bands)
465        let percent_b = if upper - lower > 0.0 {
466            (price - lower) / (upper - lower)
467        } else {
468            0.5
469        };
470
471        Some(BollingerBandsValues {
472            upper,
473            middle: sma,
474            lower,
475            width,
476            width_percentile,
477            percent_b,
478            std_dev,
479        })
480    }
481
482    /// Calculate where the current width ranks in recent history
483    fn calculate_width_percentile(&self, current_width: f64) -> f64 {
484        if self.width_history.len() < 10 {
485            return 50.0; // Not enough data
486        }
487
488        let count_below = self
489            .width_history
490            .iter()
491            .filter(|&&w| w < current_width)
492            .count();
493
494        (count_below as f64 / self.width_history.len() as f64) * 100.0
495    }
496
497    /// Check if the Bollinger Bands have enough data
498    pub fn is_ready(&self) -> bool {
499        self.prices.len() >= self.period
500    }
501
502    /// Get the period
503    pub fn period(&self) -> usize {
504        self.period
505    }
506
507    /// Get the standard deviation multiplier
508    pub fn std_dev_multiplier(&self) -> f64 {
509        self.std_dev_multiplier
510    }
511
512    /// Reset the Bollinger Bands state
513    pub fn reset(&mut self) {
514        self.prices.clear();
515        self.width_history.clear();
516    }
517}
518
519// ============================================================================
520// RSI (Relative Strength Index)
521// ============================================================================
522
523/// Relative Strength Index (RSI) calculator
524///
525/// Uses EMA-smoothed gains and losses for a responsive RSI calculation.
526/// Values above 70 indicate overbought, below 30 indicate oversold.
527#[derive(Debug, Clone)]
528pub struct RSI {
529    period: usize,
530    gains: EMA,
531    losses: EMA,
532    prev_close: Option<f64>,
533    last_rsi: Option<f64>,
534}
535
536impl RSI {
537    /// Create a new RSI with the given period (typically 14)
538    pub fn new(period: usize) -> Self {
539        Self {
540            period,
541            gains: EMA::new(period),
542            losses: EMA::new(period),
543            prev_close: None,
544            last_rsi: None,
545        }
546    }
547
548    /// Update with a new close price, returning the RSI if warmed up
549    pub fn update(&mut self, close: f64) -> Option<f64> {
550        if let Some(prev) = self.prev_close {
551            let change = close - prev;
552            let gain = if change > 0.0 { change } else { 0.0 };
553            let loss = if change < 0.0 { -change } else { 0.0 };
554
555            if let (Some(avg_gain), Some(avg_loss)) =
556                (self.gains.update(gain), self.losses.update(loss))
557            {
558                self.prev_close = Some(close);
559
560                let rsi = if avg_loss == 0.0 {
561                    100.0
562                } else {
563                    let rs = avg_gain / avg_loss;
564                    100.0 - (100.0 / (1.0 + rs))
565                };
566                self.last_rsi = Some(rsi);
567                return self.last_rsi;
568            }
569        }
570
571        self.prev_close = Some(close);
572        None
573    }
574
575    /// Get the most recent RSI value without consuming a new price tick.
576    ///
577    /// Returns `None` until the indicator has completed its warm-up period.
578    pub fn value(&self) -> Option<f64> {
579        self.last_rsi
580    }
581
582    /// Check if RSI has enough data
583    pub fn is_ready(&self) -> bool {
584        self.gains.is_ready() && self.losses.is_ready()
585    }
586
587    /// Get the period
588    pub fn period(&self) -> usize {
589        self.period
590    }
591
592    /// Reset the RSI state
593    pub fn reset(&mut self) {
594        self.gains.reset();
595        self.losses.reset();
596        self.prev_close = None;
597        self.last_rsi = None;
598    }
599}
600
601// ============================================================================
602// Helper Functions
603// ============================================================================
604
605/// Calculate a Simple Moving Average from a slice of values
606pub fn calculate_sma(prices: &[f64]) -> f64 {
607    if prices.is_empty() {
608        return 0.0;
609    }
610    prices.iter().sum::<f64>() / prices.len() as f64
611}
612
613// ============================================================================
614// Tests
615// ============================================================================
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    // --- EMA Tests ---
622
623    #[test]
624    fn test_ema_creation() {
625        let ema = EMA::new(10);
626        assert_eq!(ema.period(), 10);
627        assert!(!ema.is_ready());
628        assert!(ema.value().is_none());
629    }
630
631    #[test]
632    fn test_ema_warmup() {
633        let mut ema = EMA::new(10);
634
635        // Should return None during warmup
636        for i in 1..10 {
637            let result = ema.update(i as f64 * 10.0);
638            assert!(result.is_none(), "Should be None during warmup at step {i}");
639        }
640
641        // Should return Some after warmup
642        let result = ema.update(100.0);
643        assert!(result.is_some(), "Should be ready after {0} updates", 10);
644        assert!(ema.is_ready());
645    }
646
647    #[test]
648    fn test_ema_calculation() {
649        let mut ema = EMA::new(10);
650
651        // Warm up
652        for i in 1..=10 {
653            ema.update(i as f64 * 10.0);
654        }
655
656        assert!(ema.is_ready());
657        let value = ema.value().unwrap();
658        // EMA should be between the min and max input values
659        assert!(value > 10.0 && value <= 100.0);
660    }
661
662    #[test]
663    fn test_ema_tracks_trend() {
664        let mut ema = EMA::new(5);
665
666        // Warm up with constant price
667        for _ in 0..5 {
668            ema.update(100.0);
669        }
670        let stable = ema.value().unwrap();
671
672        // Feed higher prices
673        for _ in 0..10 {
674            ema.update(110.0);
675        }
676        let after_up = ema.value().unwrap();
677
678        assert!(after_up > stable, "EMA should increase with rising prices");
679    }
680
681    #[test]
682    fn test_ema_reset() {
683        let mut ema = EMA::new(5);
684        for _ in 0..10 {
685            ema.update(100.0);
686        }
687        assert!(ema.is_ready());
688
689        ema.reset();
690        assert!(!ema.is_ready());
691        assert!(ema.value().is_none());
692    }
693
694    // --- ATR Tests ---
695
696    #[test]
697    fn test_atr_creation() {
698        let atr = ATR::new(14);
699        assert_eq!(atr.period(), 14);
700        assert!(!atr.is_ready());
701    }
702
703    #[test]
704    fn test_atr_warmup() {
705        let mut atr = ATR::new(14);
706
707        for i in 1..=14 {
708            let base = 100.0 + i as f64;
709            let result = atr.update(base + 1.0, base - 1.0, base);
710            if i < 14 {
711                assert!(result.is_none());
712            }
713        }
714
715        assert!(atr.is_ready());
716    }
717
718    #[test]
719    fn test_atr_increases_with_volatility() {
720        let mut atr = ATR::new(14);
721
722        // Low volatility warmup
723        for i in 1..=14 {
724            let base = 100.0 + i as f64 * 0.1;
725            atr.update(base + 0.5, base - 0.5, base);
726        }
727        let low_vol_atr = atr.value().unwrap();
728
729        // High volatility bars
730        for i in 0..20 {
731            let base = 100.0 + if i % 2 == 0 { 5.0 } else { -5.0 };
732            atr.update(base + 3.0, base - 3.0, base);
733        }
734        let high_vol_atr = atr.value().unwrap();
735
736        assert!(
737            high_vol_atr > low_vol_atr,
738            "ATR should increase with volatility: {high_vol_atr} vs {low_vol_atr}"
739        );
740    }
741
742    #[test]
743    fn test_atr_reset() {
744        let mut atr = ATR::new(14);
745        for i in 0..20 {
746            let base = 100.0 + i as f64;
747            atr.update(base + 1.0, base - 1.0, base);
748        }
749        assert!(atr.is_ready());
750
751        atr.reset();
752        assert!(!atr.is_ready());
753        assert!(atr.value().is_none());
754    }
755
756    // --- ADX Tests ---
757
758    #[test]
759    fn test_adx_creation() {
760        let adx = ADX::new(14);
761        assert_eq!(adx.period(), 14);
762        assert!(!adx.is_ready());
763    }
764
765    #[test]
766    fn test_adx_trending_detection() {
767        let mut adx = ADX::new(14);
768
769        // Simulate strong uptrend (prices going up steadily)
770        for i in 1..=50 {
771            let high = 100.0 + i as f64 * 2.0;
772            let low = 100.0 + i as f64 * 2.0 - 1.0;
773            let close = 100.0 + i as f64 * 2.0 - 0.5;
774            adx.update(high, low, close);
775        }
776
777        if let Some(adx_value) = adx.value() {
778            assert!(
779                adx_value > 20.0,
780                "ADX should indicate trend in strong uptrend: {adx_value}"
781            );
782        }
783    }
784
785    #[test]
786    fn test_adx_trend_direction() {
787        let mut adx = ADX::new(14);
788
789        // Strong uptrend
790        for i in 1..=50 {
791            let high = 100.0 + i as f64 * 2.0;
792            let low = 100.0 + i as f64 * 2.0 - 1.0;
793            let close = 100.0 + i as f64 * 2.0 - 0.5;
794            adx.update(high, low, close);
795        }
796
797        if let Some(dir) = adx.trend_direction() {
798            assert_eq!(
799                dir,
800                TrendDirection::Bullish,
801                "Should detect bullish direction in uptrend"
802            );
803        }
804    }
805
806    #[test]
807    fn test_adx_di_values() {
808        let mut adx = ADX::new(14);
809
810        for i in 1..=50 {
811            let high = 100.0 + i as f64 * 2.0;
812            let low = 100.0 + i as f64 * 2.0 - 1.0;
813            let close = 100.0 + i as f64 * 2.0 - 0.5;
814            adx.update(high, low, close);
815        }
816
817        // In an uptrend, +DI should be higher than -DI
818        if let (Some(plus), Some(minus)) = (adx.plus_dir_index(), adx.minus_dir_index()) {
819            assert!(
820                plus > minus,
821                "+DI ({plus}) should be > -DI ({minus}) in uptrend"
822            );
823        }
824    }
825
826    #[test]
827    fn test_adx_reset() {
828        let mut adx = ADX::new(14);
829        for i in 1..=50 {
830            let base = 100.0 + i as f64;
831            adx.update(base + 1.0, base - 1.0, base);
832        }
833        assert!(adx.is_ready());
834
835        adx.reset();
836        assert!(!adx.is_ready());
837        assert!(adx.value().is_none());
838        assert!(adx.plus_dir_index().is_none());
839        assert!(adx.minus_dir_index().is_none());
840    }
841
842    // --- Bollinger Bands Tests ---
843
844    #[test]
845    fn test_bb_creation() {
846        let bb = BollingerBands::new(20, 2.0);
847        assert_eq!(bb.period(), 20);
848        assert_eq!(bb.std_dev_multiplier(), 2.0);
849        assert!(!bb.is_ready());
850    }
851
852    #[test]
853    fn test_bb_warmup() {
854        let mut bb = BollingerBands::new(20, 2.0);
855
856        for i in 1..20 {
857            let result = bb.update(100.0 + i as f64 * 0.1);
858            assert!(result.is_none());
859        }
860
861        let result = bb.update(102.0);
862        assert!(result.is_some());
863        assert!(bb.is_ready());
864    }
865
866    #[test]
867    fn test_bb_band_ordering() {
868        let mut bb = BollingerBands::new(20, 2.0);
869
870        for i in 1..=25 {
871            let price = 100.0 + (i as f64 % 5.0);
872            bb.update(price);
873        }
874
875        let result = bb.update(102.0).unwrap();
876        assert!(
877            result.upper > result.middle,
878            "Upper band ({}) should be > middle ({})",
879            result.upper,
880            result.middle
881        );
882        assert!(
883            result.middle > result.lower,
884            "Middle ({}) should be > lower ({})",
885            result.middle,
886            result.lower
887        );
888    }
889
890    #[test]
891    fn test_bb_percent_b() {
892        let mut bb = BollingerBands::new(20, 2.0);
893
894        // Build some history with variance
895        for i in 1..=20 {
896            bb.update(100.0 + (i as f64 % 3.0));
897        }
898
899        // Price at middle should give %B near 0.5
900        let values = bb.update(100.0 + 1.0);
901        if let Some(v) = values {
902            // %B should be between 0 and 1 for normal prices
903            assert!(
904                v.percent_b >= 0.0 && v.percent_b <= 1.0,
905                "%B should be in [0,1]: {}",
906                v.percent_b
907            );
908        }
909    }
910
911    #[test]
912    fn test_bb_squeeze_detection() {
913        let mut bb = BollingerBands::new(20, 2.0);
914
915        // First, create wide bands with volatile data
916        for i in 0..50 {
917            let price = 100.0 + if i % 2 == 0 { 10.0 } else { -10.0 };
918            bb.update(price);
919        }
920
921        // Then tighten with constant price
922        for _ in 0..50 {
923            bb.update(100.0);
924        }
925
926        let result = bb.update(100.0).unwrap();
927        // After constant prices, width percentile should be low
928        assert!(
929            result.width_percentile < 50.0,
930            "Constant prices should produce low width percentile: {}",
931            result.width_percentile
932        );
933    }
934
935    #[test]
936    fn test_bb_overbought_oversold() {
937        let mut bb = BollingerBands::new(20, 2.0);
938
939        // Build history around 100
940        for _ in 0..20 {
941            bb.update(100.0);
942        }
943
944        // Price far above should be overbought
945        let result = bb.update(110.0).unwrap();
946        assert!(
947            result.is_overbought(),
948            "Price far above bands should be overbought, %B = {}",
949            result.percent_b
950        );
951    }
952
953    #[test]
954    fn test_bb_reset() {
955        let mut bb = BollingerBands::new(20, 2.0);
956        for i in 0..25 {
957            bb.update(100.0 + i as f64);
958        }
959        assert!(bb.is_ready());
960
961        bb.reset();
962        assert!(!bb.is_ready());
963    }
964
965    // --- RSI Tests ---
966
967    #[test]
968    fn test_rsi_creation() {
969        let rsi = RSI::new(14);
970        assert_eq!(rsi.period(), 14);
971        assert!(!rsi.is_ready());
972    }
973
974    #[test]
975    fn test_rsi_bullish_market() {
976        let mut rsi = RSI::new(14);
977
978        // Consistently rising prices
979        let mut last_rsi = None;
980        for i in 0..30 {
981            let price = 100.0 + i as f64;
982            if let Some(val) = rsi.update(price) {
983                last_rsi = Some(val);
984            }
985        }
986
987        if let Some(val) = last_rsi {
988            assert!(
989                val > 50.0,
990                "RSI should be above 50 in bullish market: {val}"
991            );
992        }
993    }
994
995    #[test]
996    fn test_rsi_bearish_market() {
997        let mut rsi = RSI::new(14);
998
999        // Consistently falling prices
1000        let mut last_rsi = None;
1001        for i in 0..30 {
1002            let price = 200.0 - i as f64;
1003            if let Some(val) = rsi.update(price) {
1004                last_rsi = Some(val);
1005            }
1006        }
1007
1008        if let Some(val) = last_rsi {
1009            assert!(
1010                val < 50.0,
1011                "RSI should be below 50 in bearish market: {val}"
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn test_rsi_range() {
1018        let mut rsi = RSI::new(14);
1019
1020        for i in 0..50 {
1021            let price = 100.0 + (i as f64 * 0.7).sin() * 10.0;
1022            if let Some(val) = rsi.update(price) {
1023                assert!(
1024                    (0.0..=100.0).contains(&val),
1025                    "RSI should be in [0, 100]: {val}"
1026                );
1027            }
1028        }
1029    }
1030
1031    #[test]
1032    fn test_rsi_value_cached() {
1033        let mut rsi = RSI::new(14);
1034        assert!(
1035            rsi.value().is_none(),
1036            "value() should be None before warmup"
1037        );
1038
1039        let mut last_from_update = None;
1040        for i in 0..30 {
1041            let price = 100.0 + i as f64;
1042            if let Some(v) = rsi.update(price) {
1043                last_from_update = Some(v);
1044            }
1045        }
1046
1047        // value() must equal the last result returned by update()
1048        assert_eq!(
1049            rsi.value(),
1050            last_from_update,
1051            "value() must equal the last update() result"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_rsi_reset_clears_value() {
1057        let mut rsi = RSI::new(14);
1058        for i in 0..30 {
1059            rsi.update(100.0 + i as f64);
1060        }
1061        assert!(rsi.value().is_some());
1062        rsi.reset();
1063        assert!(rsi.value().is_none(), "value() should be None after reset");
1064    }
1065
1066    // --- SMA Helper Test ---
1067
1068    #[test]
1069    fn test_calculate_sma() {
1070        assert_eq!(calculate_sma(&[1.0, 2.0, 3.0, 4.0, 5.0]), 3.0);
1071        assert_eq!(calculate_sma(&[100.0]), 100.0);
1072        assert_eq!(calculate_sma(&[]), 0.0);
1073    }
1074
1075    #[test]
1076    fn test_calculate_sma_precision() {
1077        let prices = vec![10.0, 20.0, 30.0];
1078        let sma = calculate_sma(&prices);
1079        assert!((sma - 20.0).abs() < f64::EPSILON);
1080    }
1081}