Skip to main content

nautilus_indicators/volatility/
fuzzy.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::{Debug, Display};
17
18use arraydeque::{ArrayDeque, Wrapping};
19use nautilus_core::correctness::FAILED;
20use nautilus_model::data::Bar;
21use strum::Display;
22
23use crate::indicator::Indicator;
24
25#[repr(C)]
26#[derive(Debug, Display, Clone, Hash, PartialEq, Eq, Copy)]
27#[strum(ascii_case_insensitive)]
28#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
29#[cfg_attr(
30    feature = "python",
31    pyo3::pyclass(
32        frozen,
33        eq,
34        eq_int,
35        module = "nautilus_trader.indicators",
36        from_py_object,
37    )
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.indicators")
42)]
43pub enum CandleBodySize {
44    None = 0,
45    Small = 1,
46    Medium = 2,
47    Large = 3,
48    Trend = 4,
49}
50
51#[repr(C)]
52#[derive(Debug, Display, Clone, Hash, PartialEq, Eq, Copy)]
53#[strum(ascii_case_insensitive)]
54#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
55#[cfg_attr(
56    feature = "python",
57    pyo3::pyclass(
58        frozen,
59        eq,
60        eq_int,
61        module = "nautilus_trader.indicators",
62        from_py_object,
63    )
64)]
65#[cfg_attr(
66    feature = "python",
67    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.indicators")
68)]
69pub enum CandleDirection {
70    Bull = 1,
71    None = 0,
72    Bear = -1,
73}
74
75#[repr(C)]
76#[derive(Debug, Display, Clone, Hash, PartialEq, Eq, Copy)]
77#[strum(ascii_case_insensitive)]
78#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
79#[cfg_attr(
80    feature = "python",
81    pyo3::pyclass(
82        frozen,
83        eq,
84        eq_int,
85        module = "nautilus_trader.indicators",
86        from_py_object,
87    )
88)]
89#[cfg_attr(
90    feature = "python",
91    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.indicators")
92)]
93pub enum CandleSize {
94    None = 0,
95    VerySmall = 1,
96    Small = 2,
97    Medium = 3,
98    Large = 4,
99    VeryLarge = 5,
100    ExtremelyLarge = 6,
101}
102
103#[repr(C)]
104#[derive(Debug, Display, Clone, Hash, PartialEq, Eq, Copy)]
105#[strum(ascii_case_insensitive)]
106#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
107#[cfg_attr(
108    feature = "python",
109    pyo3::pyclass(
110        frozen,
111        eq,
112        eq_int,
113        module = "nautilus_trader.indicators",
114        from_py_object,
115    )
116)]
117#[cfg_attr(
118    feature = "python",
119    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.indicators")
120)]
121pub enum CandleWickSize {
122    None = 0,
123    Small = 1,
124    Medium = 2,
125    Large = 3,
126}
127
128#[repr(C)]
129#[derive(Debug, Clone, Copy)]
130#[cfg_attr(
131    feature = "python",
132    pyo3::pyclass(module = "nautilus_trader.indicators", from_py_object)
133)]
134#[cfg_attr(
135    feature = "python",
136    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
137)]
138pub struct FuzzyCandle {
139    pub direction: CandleDirection,
140    pub size: CandleSize,
141    pub body_size: CandleBodySize,
142    pub upper_wick_size: CandleWickSize,
143    pub lower_wick_size: CandleWickSize,
144}
145
146impl Display for FuzzyCandle {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(
149            f,
150            "{}({},{},{},{})",
151            self.direction, self.size, self.body_size, self.upper_wick_size, self.lower_wick_size
152        )
153    }
154}
155
156impl FuzzyCandle {
157    #[must_use]
158    pub const fn new(
159        direction: CandleDirection,
160        size: CandleSize,
161        body_size: CandleBodySize,
162        upper_wick_size: CandleWickSize,
163        lower_wick_size: CandleWickSize,
164    ) -> Self {
165        Self {
166            direction,
167            size,
168            body_size,
169            upper_wick_size,
170            lower_wick_size,
171        }
172    }
173}
174
175const MAX_CAPACITY: usize = 1024;
176
177#[repr(C)]
178#[derive(Debug)]
179#[cfg_attr(
180    feature = "python",
181    pyo3::pyclass(module = "nautilus_trader.indicators")
182)]
183#[cfg_attr(
184    feature = "python",
185    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
186)]
187pub struct FuzzyCandlesticks {
188    pub period: usize,
189    pub threshold1: f64,
190    pub threshold2: f64,
191    pub threshold3: f64,
192    pub threshold4: f64,
193    pub vector: Vec<i32>,
194    pub value: FuzzyCandle,
195    pub initialized: bool,
196    has_inputs: bool,
197    lengths: ArrayDeque<f64, MAX_CAPACITY, Wrapping>,
198    body_percents: ArrayDeque<f64, MAX_CAPACITY, Wrapping>,
199    upper_wick_percents: ArrayDeque<f64, MAX_CAPACITY, Wrapping>,
200    lower_wick_percents: ArrayDeque<f64, MAX_CAPACITY, Wrapping>,
201    last_open: f64,
202    last_high: f64,
203    last_low: f64,
204    last_close: f64,
205}
206
207impl Display for FuzzyCandlesticks {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        write!(
210            f,
211            "{}({},{},{},{},{})",
212            self.name(),
213            self.period,
214            self.threshold1,
215            self.threshold2,
216            self.threshold3,
217            self.threshold4
218        )
219    }
220}
221
222impl Indicator for FuzzyCandlesticks {
223    fn name(&self) -> String {
224        stringify!(FuzzyCandlesticks).to_string()
225    }
226
227    fn has_inputs(&self) -> bool {
228        self.has_inputs
229    }
230
231    fn initialized(&self) -> bool {
232        self.initialized
233    }
234
235    fn handle_bar(&mut self, bar: &Bar) {
236        self.update_raw(
237            (&bar.open).into(),
238            (&bar.high).into(),
239            (&bar.low).into(),
240            (&bar.close).into(),
241        );
242    }
243
244    fn reset(&mut self) {
245        self.lengths.clear();
246        self.body_percents.clear();
247        self.upper_wick_percents.clear();
248        self.lower_wick_percents.clear();
249        self.value = FuzzyCandle::new(
250            CandleDirection::None,
251            CandleSize::None,
252            CandleBodySize::None,
253            CandleWickSize::None,
254            CandleWickSize::None,
255        );
256        self.vector = Vec::new();
257        self.last_open = 0.0;
258        self.last_high = 0.0;
259        self.last_close = 0.0;
260        self.last_low = 0.0;
261        self.has_inputs = false;
262        self.initialized = false;
263    }
264}
265
266impl FuzzyCandlesticks {
267    /// Creates a new [`FuzzyCandlesticks`] instance.
268    ///
269    /// A zero period is accepted.
270    ///
271    /// # Panics
272    ///
273    /// Panics if `period` exceeds the fixed window capacity.
274    #[must_use]
275    pub fn new(
276        period: usize,
277        threshold1: f64,
278        threshold2: f64,
279        threshold3: f64,
280        threshold4: f64,
281    ) -> Self {
282        Self::new_checked(period, threshold1, threshold2, threshold3, threshold4).expect(FAILED)
283    }
284
285    pub(crate) fn new_checked(
286        period: usize,
287        threshold1: f64,
288        threshold2: f64,
289        threshold3: f64,
290        threshold4: f64,
291    ) -> anyhow::Result<Self> {
292        anyhow::ensure!(
293            period <= MAX_CAPACITY,
294            "period cannot exceed {MAX_CAPACITY}"
295        );
296        Ok(Self {
297            period,
298            threshold1,
299            threshold2,
300            threshold3,
301            threshold4,
302            vector: Vec::new(),
303            value: FuzzyCandle::new(
304                CandleDirection::None,
305                CandleSize::None,
306                CandleBodySize::None,
307                CandleWickSize::None,
308                CandleWickSize::None,
309            ),
310            has_inputs: false,
311            initialized: false,
312            lengths: ArrayDeque::new(),
313            body_percents: ArrayDeque::new(),
314            upper_wick_percents: ArrayDeque::new(),
315            lower_wick_percents: ArrayDeque::new(),
316            last_open: 0.0,
317            last_high: 0.0,
318            last_low: 0.0,
319            last_close: 0.0,
320        })
321    }
322
323    pub fn update_raw(&mut self, open: f64, high: f64, low: f64, close: f64) {
324        if !self.has_inputs {
325            self.last_close = close;
326            self.last_open = open;
327            self.last_high = high;
328            self.last_low = low;
329            self.has_inputs = true;
330        }
331
332        self.last_close = close;
333        self.last_open = open;
334        self.last_high = high;
335        self.last_low = low;
336
337        let total = (high - low).abs();
338
339        // Bound the rolling windows to `period`. Without this the fixed-capacity deques
340        // grow to their 1024 capacity, so the means (sum / period) and standard
341        // deviations are computed over far more than `period` candles.
342        if self.lengths.len() == self.period {
343            self.lengths.pop_front();
344            self.body_percents.pop_front();
345            self.upper_wick_percents.pop_front();
346            self.lower_wick_percents.pop_front();
347        }
348
349        let _ = self.lengths.push_back(total);
350
351        if total == 0.0 {
352            let _ = self.body_percents.push_back(0.0);
353            let _ = self.upper_wick_percents.push_back(0.0);
354            let _ = self.lower_wick_percents.push_back(0.0);
355        } else {
356            let body = (close - open).abs();
357            let upper_wick = high - f64::max(open, close);
358            let lower_wick = f64::min(open, close) - low;
359
360            let _ = self.body_percents.push_back(body / total);
361            let _ = self.upper_wick_percents.push_back(upper_wick / total);
362            let _ = self.lower_wick_percents.push_back(lower_wick / total);
363        }
364
365        if self.lengths.len() >= self.period {
366            self.initialized = true;
367        }
368
369        // not enough data to compute stddev, will div self.period later
370        if !self.initialized {
371            return;
372        }
373
374        let mean_length = self.lengths.iter().sum::<f64>() / (self.period as f64);
375        let mean_body_percent = self.body_percents.iter().sum::<f64>() / (self.period as f64);
376        let mean_upper_percent =
377            self.upper_wick_percents.iter().sum::<f64>() / (self.period as f64);
378        let mean_lower_percent =
379            self.lower_wick_percents.iter().sum::<f64>() / (self.period as f64);
380
381        let sd_length = Self::std_dev(&self.lengths, mean_length);
382        let sd_body = Self::std_dev(&self.body_percents, mean_body_percent);
383        let sd_upper = Self::std_dev(&self.upper_wick_percents, mean_upper_percent);
384        let sd_lower = Self::std_dev(&self.lower_wick_percents, mean_lower_percent);
385        let latest_body = *self.body_percents.back().unwrap_or(&0.0);
386        let latest_upper = *self.upper_wick_percents.back().unwrap_or(&0.0);
387        let latest_lower = *self.lower_wick_percents.back().unwrap_or(&0.0);
388
389        self.value = FuzzyCandle::new(
390            Self::fuzzify_direction(open, close),
391            self.fuzzify_size(total, mean_length, sd_length),
392            self.fuzzify_body_size(latest_body, mean_body_percent, sd_body),
393            self.fuzzify_wick_size(latest_upper, mean_upper_percent, sd_upper),
394            self.fuzzify_wick_size(latest_lower, mean_lower_percent, sd_lower),
395        );
396
397        self.vector = vec![
398            self.value.direction as i32,
399            self.value.size as i32,
400            self.value.body_size as i32,
401            self.value.upper_wick_size as i32,
402            self.value.lower_wick_size as i32,
403        ];
404    }
405
406    pub fn reset(&mut self) {
407        Indicator::reset(self);
408    }
409
410    fn fuzzify_direction(open: f64, close: f64) -> CandleDirection {
411        if close > open {
412            CandleDirection::Bull
413        } else if close < open {
414            CandleDirection::Bear
415        } else {
416            CandleDirection::None
417        }
418    }
419
420    fn fuzzify_size(&self, length: f64, mean_length: f64, sd_lengths: f64) -> CandleSize {
421        if !length.is_finite() || length == 0.0 {
422            return CandleSize::None;
423        }
424
425        let thresholds = [
426            mean_length - self.threshold2 * sd_lengths, // VerySmall
427            mean_length - self.threshold1 * sd_lengths, // Small
428            mean_length + self.threshold1 * sd_lengths, // Medium
429            mean_length + self.threshold2 * sd_lengths, // Large
430            mean_length + self.threshold3 * sd_lengths, // VeryLarge
431        ];
432
433        if length <= thresholds[0] {
434            CandleSize::VerySmall
435        } else if length <= thresholds[1] {
436            CandleSize::Small
437        } else if length <= thresholds[2] {
438            CandleSize::Medium
439        } else if length <= thresholds[3] {
440            CandleSize::Large
441        } else if length <= thresholds[4] {
442            CandleSize::VeryLarge
443        } else {
444            CandleSize::ExtremelyLarge
445        }
446    }
447
448    fn fuzzify_body_size(
449        &self,
450        body_percent: f64,
451        mean_body_percent: f64,
452        sd_body_percent: f64,
453    ) -> CandleBodySize {
454        if body_percent == 0.0 {
455            return CandleBodySize::None;
456        }
457
458        let mut x;
459
460        x = sd_body_percent.mul_add(-self.threshold1, mean_body_percent);
461        if body_percent <= x {
462            return CandleBodySize::Small;
463        }
464
465        x = sd_body_percent.mul_add(self.threshold1, mean_body_percent);
466        if body_percent <= x {
467            return CandleBodySize::Medium;
468        }
469
470        x = sd_body_percent.mul_add(self.threshold2, mean_body_percent);
471        if body_percent <= x {
472            return CandleBodySize::Large;
473        }
474
475        CandleBodySize::Trend
476    }
477
478    fn fuzzify_wick_size(
479        &self,
480        wick_percent: f64,
481        mean_wick_percent: f64,
482        sd_wick_percents: f64,
483    ) -> CandleWickSize {
484        if wick_percent == 0.0 {
485            return CandleWickSize::None;
486        }
487
488        let mut x;
489        x = sd_wick_percents.mul_add(-self.threshold1, mean_wick_percent);
490        if wick_percent <= x {
491            return CandleWickSize::Small;
492        }
493
494        x = sd_wick_percents.mul_add(self.threshold2, mean_wick_percent);
495        if wick_percent <= x {
496            return CandleWickSize::Medium;
497        }
498
499        CandleWickSize::Large
500    }
501
502    fn std_dev<const CAP: usize>(buffer: &ArrayDeque<f64, CAP, Wrapping>, mean: f64) -> f64 {
503        if buffer.is_empty() {
504            return 0.0;
505        }
506        let variance = buffer
507            .iter()
508            .map(|v| {
509                let d = v - mean;
510                d * d
511            })
512            .sum::<f64>()
513            / (buffer.len() as f64);
514        variance.sqrt()
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use rstest::rstest;
521
522    use super::*;
523    use crate::{
524        stubs::{fuzzy_candlesticks_1, fuzzy_candlesticks_3, fuzzy_candlesticks_10},
525        volatility::fuzzy::FuzzyCandlesticks,
526    };
527
528    #[rstest]
529    #[case(MAX_CAPACITY + 1)]
530    #[case(usize::MAX)]
531    fn test_checked_constructor_rejects_invalid_periods(#[case] period: usize) {
532        let error = FuzzyCandlesticks::new_checked(period, 0.1, 0.2, 0.3, 0.4).unwrap_err();
533        assert_eq!(
534            error.to_string(),
535            format!("period cannot exceed {MAX_CAPACITY}")
536        );
537    }
538
539    #[rstest]
540    #[case(0)]
541    #[case(MAX_CAPACITY)]
542    fn test_checked_constructor_preserves_period_bounds(#[case] period: usize) {
543        let ind = FuzzyCandlesticks::new_checked(period, 0.1, 0.2, 0.3, 0.4).unwrap();
544        assert_eq!(ind.period, period);
545        assert_eq!(
546            (
547                ind.threshold1,
548                ind.threshold2,
549                ind.threshold3,
550                ind.threshold4
551            ),
552            (0.1, 0.2, 0.3, 0.4)
553        );
554        assert!(!ind.initialized());
555        assert!(!ind.has_inputs());
556    }
557
558    #[rstest]
559    fn test_fuzzy_candle_display_orders_wicks_upper_then_lower() {
560        // Regression: `Display` emitted the wick sizes in the opposite order to the
561        // struct definition, the constructor and `__repr__`, so an upper-heavy candle
562        // rendered as a lower-heavy one.
563        let candle = FuzzyCandle::new(
564            CandleDirection::Bull,
565            CandleSize::Medium,
566            CandleBodySize::Small,
567            CandleWickSize::Large,
568            CandleWickSize::None,
569        );
570
571        assert_eq!(format!("{candle}"), "BULL(MEDIUM,SMALL,LARGE,NONE)");
572    }
573
574    #[rstest]
575    fn test_psl_initialized(fuzzy_candlesticks_10: FuzzyCandlesticks) {
576        let display_str = format!("{fuzzy_candlesticks_10}");
577        assert_eq!(display_str, "FuzzyCandlesticks(10,0.1,0.15,0.2,0.3)");
578        assert_eq!(fuzzy_candlesticks_10.period, 10);
579        assert!(!fuzzy_candlesticks_10.initialized);
580        assert!(!fuzzy_candlesticks_10.has_inputs);
581    }
582
583    #[rstest]
584    fn test_value_with_one_input(mut fuzzy_candlesticks_1: FuzzyCandlesticks) {
585        //fix: When period = 1, the standard deviation is 0, and all fuzzy divisions based on mean ± threshold * sd become invalid.
586        fuzzy_candlesticks_1.update_raw(123.90, 135.79, 117.09, 125.09);
587        assert_eq!(fuzzy_candlesticks_1.value.direction, CandleDirection::Bull);
588        assert_eq!(fuzzy_candlesticks_1.value.size, CandleSize::VerySmall);
589        assert_eq!(fuzzy_candlesticks_1.value.body_size, CandleBodySize::Small);
590        assert_eq!(
591            fuzzy_candlesticks_1.value.upper_wick_size,
592            CandleWickSize::Small
593        );
594        assert_eq!(
595            fuzzy_candlesticks_1.value.lower_wick_size,
596            CandleWickSize::Small
597        );
598
599        let expected_vec = vec![1, 1, 1, 1, 1];
600        assert_eq!(fuzzy_candlesticks_1.vector, expected_vec);
601    }
602
603    #[rstest]
604    fn test_value_with_three_inputs(mut fuzzy_candlesticks_3: FuzzyCandlesticks) {
605        // fix: self.lengths[0] : ArrayDeque is oldest value, old test is not right
606        fuzzy_candlesticks_3.update_raw(142.35, 145.82, 141.20, 144.75);
607        fuzzy_candlesticks_3.update_raw(144.75, 144.93, 103.55, 108.22);
608        fuzzy_candlesticks_3.update_raw(108.22, 120.15, 105.01, 119.89);
609        assert_eq!(fuzzy_candlesticks_3.value.direction, CandleDirection::Bull);
610        assert_eq!(fuzzy_candlesticks_3.value.size, CandleSize::VerySmall);
611        assert_eq!(fuzzy_candlesticks_3.value.body_size, CandleBodySize::Trend);
612        assert_eq!(
613            fuzzy_candlesticks_3.value.upper_wick_size,
614            CandleWickSize::Small
615        );
616        assert_eq!(
617            fuzzy_candlesticks_3.value.lower_wick_size,
618            CandleWickSize::Large
619        );
620
621        let expected_vec = vec![1, 1, 4, 1, 3];
622        assert_eq!(fuzzy_candlesticks_3.vector, expected_vec);
623    }
624
625    #[rstest]
626    fn test_value_not_updated_before_initialization(mut fuzzy_candlesticks_10: FuzzyCandlesticks) {
627        //fix: period not reached, should not update value
628        fuzzy_candlesticks_10.update_raw(100.0, 105.0, 95.0, 102.0);
629        fuzzy_candlesticks_10.update_raw(102.0, 108.0, 100.0, 98.0);
630        fuzzy_candlesticks_10.update_raw(98.0, 101.0, 96.0, 100.0);
631
632        assert_eq!(fuzzy_candlesticks_10.vector.len(), 0);
633        assert!(
634            !fuzzy_candlesticks_10.initialized,
635            "Should not be initialized before period"
636        );
637        assert!(fuzzy_candlesticks_10.has_inputs, "Should  has inputs");
638        assert_eq!(fuzzy_candlesticks_10.lengths.len(), 3);
639        assert_eq!(fuzzy_candlesticks_10.body_percents.len(), 3);
640    }
641
642    #[rstest]
643    fn test_value_with_ten_inputs(mut fuzzy_candlesticks_10: FuzzyCandlesticks) {
644        fuzzy_candlesticks_10.update_raw(150.25, 153.4, 148.1, 152.75);
645        fuzzy_candlesticks_10.update_raw(152.8, 155.2, 151.3, 151.95);
646        fuzzy_candlesticks_10.update_raw(151.9, 152.85, 147.6, 148.2);
647        fuzzy_candlesticks_10.update_raw(148.3, 150.75, 146.9, 150.4);
648        fuzzy_candlesticks_10.update_raw(150.5, 154.3, 149.8, 153.9);
649        fuzzy_candlesticks_10.update_raw(153.95, 155.8, 152.2, 152.6);
650        fuzzy_candlesticks_10.update_raw(152.7, 153.4, 148.5, 149.1);
651        fuzzy_candlesticks_10.update_raw(149.2, 151.9, 147.3, 151.5);
652        fuzzy_candlesticks_10.update_raw(151.6, 156.4, 151.0, 155.8);
653        fuzzy_candlesticks_10.update_raw(155.9, 157.2, 153.7, 154.3);
654
655        assert_eq!(fuzzy_candlesticks_10.value.direction, CandleDirection::Bear);
656        assert_eq!(fuzzy_candlesticks_10.value.size, CandleSize::VerySmall);
657        assert_eq!(fuzzy_candlesticks_10.value.body_size, CandleBodySize::Small);
658        assert_eq!(
659            fuzzy_candlesticks_10.value.upper_wick_size,
660            CandleWickSize::Large
661        );
662        assert_eq!(
663            fuzzy_candlesticks_10.value.lower_wick_size,
664            CandleWickSize::Small
665        );
666
667        let expected_vec = vec![-1, 1, 1, 3, 1];
668        assert_eq!(fuzzy_candlesticks_10.vector, expected_vec);
669    }
670
671    #[rstest]
672    fn test_windows_bounded_to_period(mut fuzzy_candlesticks_10: FuzzyCandlesticks) {
673        // Regression: the four rolling windows must stay bounded to `period`. Previously
674        // the fixed-capacity deques grew to their 1024 capacity, so the means
675        // (sum / period) and standard deviations were computed over far more than
676        // `period` candles.
677        let bars = [
678            (150.25, 153.4, 148.1, 152.75),
679            (152.8, 155.2, 151.3, 151.95),
680            (151.9, 152.85, 147.6, 148.2),
681            (148.3, 150.75, 146.9, 150.4),
682            (150.5, 154.3, 149.8, 153.9),
683            (153.95, 155.8, 152.2, 152.6),
684            (152.7, 153.4, 148.5, 149.1),
685            (149.2, 151.9, 147.3, 151.5),
686            (151.6, 156.4, 151.0, 155.8),
687            (155.9, 157.2, 153.7, 154.3),
688            (154.3, 158.0, 153.0, 157.2),
689            (157.2, 159.5, 155.1, 156.0),
690            (156.0, 156.9, 152.4, 153.1),
691            (153.1, 155.0, 150.2, 154.8),
692            (154.8, 157.7, 154.0, 156.9),
693        ];
694
695        for (open, high, low, close) in bars {
696            fuzzy_candlesticks_10.update_raw(open, high, low, close);
697        }
698
699        assert!(fuzzy_candlesticks_10.initialized());
700        assert_eq!(fuzzy_candlesticks_10.lengths.len(), 10);
701        assert_eq!(fuzzy_candlesticks_10.body_percents.len(), 10);
702        assert_eq!(fuzzy_candlesticks_10.upper_wick_percents.len(), 10);
703        assert_eq!(fuzzy_candlesticks_10.lower_wick_percents.len(), 10);
704    }
705
706    #[rstest]
707    #[case::inherent(FuzzyCandlesticks::reset)]
708    #[case::indicator(<FuzzyCandlesticks as Indicator>::reset)]
709    fn test_reset(
710        #[case] reset: fn(&mut FuzzyCandlesticks),
711        mut fuzzy_candlesticks_10: FuzzyCandlesticks,
712    ) {
713        for _ in 0..10 {
714            fuzzy_candlesticks_10.update_raw(151.6, 156.4, 151.0, 155.8);
715        }
716        assert!(fuzzy_candlesticks_10.initialized);
717        assert!(!fuzzy_candlesticks_10.vector.is_empty());
718
719        reset(&mut fuzzy_candlesticks_10);
720
721        assert_eq!(fuzzy_candlesticks_10.lengths.len(), 0);
722        assert_eq!(fuzzy_candlesticks_10.body_percents.len(), 0);
723        assert_eq!(fuzzy_candlesticks_10.upper_wick_percents.len(), 0);
724        assert_eq!(fuzzy_candlesticks_10.lower_wick_percents.len(), 0);
725        assert_eq!(fuzzy_candlesticks_10.value.direction, CandleDirection::None);
726        assert_eq!(fuzzy_candlesticks_10.value.size, CandleSize::None);
727        assert_eq!(fuzzy_candlesticks_10.value.body_size, CandleBodySize::None);
728        assert_eq!(
729            fuzzy_candlesticks_10.value.upper_wick_size,
730            CandleWickSize::None
731        );
732        assert_eq!(
733            fuzzy_candlesticks_10.value.lower_wick_size,
734            CandleWickSize::None
735        );
736        assert_eq!(fuzzy_candlesticks_10.vector.len(), 0);
737        assert_eq!(fuzzy_candlesticks_10.last_open, 0.0);
738        assert_eq!(fuzzy_candlesticks_10.last_low, 0.0);
739        assert_eq!(fuzzy_candlesticks_10.last_high, 0.0);
740        assert_eq!(fuzzy_candlesticks_10.last_close, 0.0);
741        assert!(!fuzzy_candlesticks_10.has_inputs);
742        assert!(!fuzzy_candlesticks_10.initialized);
743    }
744    #[rstest]
745    fn test_zero_length_candle(mut fuzzy_candlesticks_1: FuzzyCandlesticks) {
746        fuzzy_candlesticks_1.update_raw(100.0, 100.0, 100.0, 100.0); // high == low
747        assert_eq!(fuzzy_candlesticks_1.value.size, CandleSize::None);
748        assert_eq!(fuzzy_candlesticks_1.value.body_size, CandleBodySize::None);
749        assert_eq!(
750            fuzzy_candlesticks_1.value.upper_wick_size,
751            CandleWickSize::None
752        );
753        assert_eq!(
754            fuzzy_candlesticks_1.value.lower_wick_size,
755            CandleWickSize::None
756        );
757        assert_eq!(fuzzy_candlesticks_1.value.direction, CandleDirection::None);
758    }
759
760    #[rstest]
761    fn test_constant_input_stddev_zero(mut fuzzy_candlesticks_1: FuzzyCandlesticks) {
762        for _ in 0..10 {
763            fuzzy_candlesticks_1.update_raw(100.0, 110.0, 90.0, 105.0);
764        }
765        assert!(fuzzy_candlesticks_1.lengths.iter().all(|&v| v == 20.0));
766        assert!(matches!(
767            fuzzy_candlesticks_1.value.size,
768            CandleSize::VerySmall | CandleSize::Small | CandleSize::Medium
769        ));
770    }
771
772    #[rstest]
773    fn test_nan_inf_safety(mut fuzzy_candlesticks_1: FuzzyCandlesticks) {
774        fuzzy_candlesticks_1.update_raw(f64::INFINITY, f64::INFINITY, f64::INFINITY, f64::INFINITY);
775        fuzzy_candlesticks_1.update_raw(f64::NAN, f64::NAN, f64::NAN, f64::NAN);
776        assert_eq!(fuzzy_candlesticks_1.value.direction, CandleDirection::None);
777    }
778
779    #[rstest]
780    fn test_direction_cases(mut fuzzy_candlesticks_1: FuzzyCandlesticks) {
781        fuzzy_candlesticks_1.update_raw(100.0, 105.0, 95.0, 110.0); // Bull
782        assert_eq!(fuzzy_candlesticks_1.value.direction, CandleDirection::Bull);
783
784        fuzzy_candlesticks_1.update_raw(110.0, 115.0, 105.0, 100.0); // Bear
785        assert_eq!(fuzzy_candlesticks_1.value.direction, CandleDirection::Bear);
786
787        fuzzy_candlesticks_1.update_raw(100.0, 110.0, 90.0, 100.0); // None
788        assert_eq!(fuzzy_candlesticks_1.value.direction, CandleDirection::None);
789    }
790
791    #[rstest]
792    fn test_body_and_wick_percentages(mut fuzzy_candlesticks_1: FuzzyCandlesticks) {
793        let open: f64 = 100.0;
794        let close: f64 = 110.0;
795        let high: f64 = 120.0;
796        let low: f64 = 90.0;
797
798        let total = high - low; // 30
799        let expected_body = (close - open).abs() / total; // 10 / 30 = 0.3333
800        let expected_upper_wick = (high - close.max(open)) / total; // (120 - 110) / 30 = 0.3333
801        let expected_lower_wick = (open.min(close) - low) / total; // (100 - 90) / 30 = 0.3333
802
803        fuzzy_candlesticks_1.update_raw(open, high, low, close);
804
805        let actual_body = fuzzy_candlesticks_1.body_percents[0];
806        let actual_upper = fuzzy_candlesticks_1.upper_wick_percents[0];
807        let actual_lower = fuzzy_candlesticks_1.lower_wick_percents[0];
808
809        assert!(
810            (actual_body - expected_body).abs() < 1e-6,
811            "Body percent mismatch"
812        );
813        assert!(
814            (actual_upper - expected_upper_wick).abs() < 1e-6,
815            "Upper wick percent mismatch"
816        );
817        assert!(
818            (actual_lower - expected_lower_wick).abs() < 1e-6,
819            "Lower wick percent mismatch"
820        );
821    }
822
823    #[rstest]
824    fn test_body_size_large(mut fuzzy_candlesticks_3: FuzzyCandlesticks) {
825        // K1: Almost no body (open == close)
826        fuzzy_candlesticks_3.update_raw(100.0, 101.0, 99.0, 100.0);
827        // body = 0.0 → body% = 0.0 / 2.0 = 0.0%
828
829        // K2: Small body
830        fuzzy_candlesticks_3.update_raw(100.0, 102.0, 98.0, 100.5);
831        // body = 0.5 → body% = 0.5 / 4.0 = 12.5%
832
833        // K3: Large body, nearly fills the range
834        fuzzy_candlesticks_3.update_raw(101.0, 105.0, 100.0, 104.8);
835        // body = |104.8 - 101.0| = 3.8
836        // length = 5.0
837        // body_percent = 3.8 / 5.0 = 76.0%
838
839        // Due to high deviation from mean, should be classified as Large
840        assert_eq!(fuzzy_candlesticks_3.value.body_size, CandleBodySize::Trend);
841    }
842
843    #[rstest]
844    fn test_lower_wick_size_large(mut fuzzy_candlesticks_3: FuzzyCandlesticks) {
845        // K1: No lower wick (low == close)
846        fuzzy_candlesticks_3.update_raw(100.0, 101.0, 100.0, 101.0);
847        // lower_wick = min(open, close) - low = 100 - 100 = 0 → 0%
848
849        // K2: Short lower wick
850        fuzzy_candlesticks_3.update_raw(102.0, 103.0, 101.5, 102.5);
851        // min(open, close) = 102.0
852        // lower_wick = 102.0 - 101.5 = 0.5
853        // length = 1.5
854        // lower_wick_percent = 0.5 / 1.5 ≈ 33.3%
855
856        // K3: Long lower wick, strong rebound from low
857        fuzzy_candlesticks_3.update_raw(110.0, 115.0, 100.0, 114.0);
858        // min(open, close) = 110.0
859        // lower_wick = 110.0 - 100.0 = 10.0
860        // length = 15.0
861        // lower_wick_percent = 10.0 / 15.0 ≈ 66.7%
862
863        // Value is significantly above mean + 0.15*sd → should be Large
864        assert_eq!(
865            fuzzy_candlesticks_3.value.lower_wick_size,
866            CandleWickSize::Large
867        );
868    }
869
870    #[rstest]
871    fn test_upper_wick_size_large(mut fuzzy_candlesticks_3: FuzzyCandlesticks) {
872        // K1: No upper wick (high == open/close)
873        fuzzy_candlesticks_3.update_raw(100.0, 100.0, 99.0, 100.0);
874        // upper_wick = 0
875
876        // K2: Short upper wick
877        fuzzy_candlesticks_3.update_raw(101.0, 102.0, 100.0, 101.5);
878        // max(open, close) = 102.0? No: max is 102.0 (high), close=101.5
879        // upper_wick = 102.0 - 101.5 = 0.5
880        // length = 2.0 → percent = 25.0%
881
882        // K3: Long upper wick, price rejected from high
883        fuzzy_candlesticks_3.update_raw(105.0, 115.0, 104.0, 106.0);
884        // max(open, close) = max(105.0, 106.0) = 106.0
885        // upper_wick = 115.0 - 106.0 = 9.0
886        // length = 11.0
887        // upper_wick_percent = 9.0 / 11.0 ≈ 81.8%
888
889        // Should be classified as Large due to high relative size
890        assert_eq!(
891            fuzzy_candlesticks_3.value.upper_wick_size,
892            CandleWickSize::Large
893        );
894    }
895}