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