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