nautilus_indicators/volatility/
fuzzy.rs

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