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