Skip to main content

wickra_core/indicators/
dumpling_top.rs

1//! Dumpling Top — a rounded top (dome) confirmed by a breakdown.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Dumpling Top — the bearish mirror of the [`FryPanBottom`](crate::FryPanBottom):
10/// a gently rounded **top** (dome) across the window, confirmed by a close back
11/// below where it started.
12///
13/// ```text
14/// over the last `period` closes:
15///   the maximum close sits in the middle third of the window (the "dome")
16///   the latest close is below the first close (the breakdown)
17/// signal = −1 when both hold, else 0
18/// ```
19///
20/// The dumpling top is a distribution pattern: price rounds over at the top as
21/// buying fades, then rolls down through the level it rose from. Detection requires
22/// a *central* high (a symmetric dome, not a one-sided spike) and a close below the
23/// window's opening level. The output is `−1.0` (pattern) or `0.0`.
24///
25/// The first value lands after `period` inputs; each `update` scans the window in
26/// O(`period`).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Candle, Indicator, DumplingTop};
32///
33/// let mut indicator = DumplingTop::new(9).unwrap();
34/// let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
35/// let mut last = None;
36/// for &cl in &closes {
37///     let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
38///     last = indicator.update(c);
39/// }
40/// assert_eq!(last, Some(-1.0));
41/// ```
42#[derive(Debug, Clone)]
43pub struct DumplingTop {
44    period: usize,
45    closes: VecDeque<f64>,
46    last: Option<f64>,
47}
48
49impl DumplingTop {
50    /// Construct a Dumpling Top over `period` bars.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`Error::InvalidPeriod`] if `period < 5`.
55    pub fn new(period: usize) -> Result<Self> {
56        if period < 5 {
57            return Err(Error::InvalidPeriod {
58                message: "dumpling top needs period >= 5",
59            });
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            closes: VecDeque::with_capacity(period),
69            last: None,
70        })
71    }
72
73    /// Configured window period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77
78    /// Current value if available.
79    pub const fn value(&self) -> Option<f64> {
80        self.last
81    }
82}
83
84impl Indicator for DumplingTop {
85    type Input = Candle;
86    type Output = f64;
87
88    #[inline]
89    fn update(&mut self, candle: Candle) -> Option<f64> {
90        if self.closes.len() == self.period {
91            self.closes.pop_front();
92        }
93        self.closes.push_back(candle.close);
94        if self.closes.len() < self.period {
95            return None;
96        }
97        let first = *self.closes.front().expect("non-empty");
98        let last = *self.closes.back().expect("non-empty");
99        let mut max_idx = 0;
100        let mut max_val = f64::NEG_INFINITY;
101        for (i, &v) in self.closes.iter().enumerate() {
102            if v > max_val {
103                max_val = v;
104                max_idx = i;
105            }
106        }
107        let lo = self.period / 4;
108        let hi = self.period - self.period / 4;
109        let dome = max_idx >= lo && max_idx < hi;
110        let broke_down = last < first && last < max_val;
111        let v = if dome && broke_down { -1.0 } else { 0.0 };
112        self.last = Some(v);
113        Some(v)
114    }
115
116    fn reset(&mut self) {
117        self.closes.clear();
118        self.last = None;
119    }
120
121    #[inline]
122    fn warmup_period(&self) -> usize {
123        self.period
124    }
125
126    #[inline]
127    fn is_ready(&self) -> bool {
128        self.last.is_some()
129    }
130
131    #[inline]
132    fn name(&self) -> &'static str {
133        "DumplingTop"
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::traits::BatchExt;
141
142    fn c(close: f64) -> Candle {
143        Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
144    }
145
146    #[test]
147    fn rejects_small_period() {
148        assert!(matches!(
149            DumplingTop::new(4),
150            Err(Error::InvalidPeriod { .. })
151        ));
152        assert!(DumplingTop::new(5).is_ok());
153    }
154
155    #[test]
156    fn accessors_and_metadata() {
157        let d = DumplingTop::new(9).unwrap();
158        assert_eq!(d.period(), 9);
159        assert_eq!(d.warmup_period(), 9);
160        assert_eq!(d.name(), "DumplingTop");
161        assert!(!d.is_ready());
162        assert_eq!(d.value(), None);
163    }
164
165    #[test]
166    fn first_emission_at_warmup_period() {
167        let mut d = DumplingTop::new(5).unwrap();
168        let out = d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0), c(98.0)]);
169        for v in out.iter().take(4) {
170            assert!(v.is_none());
171        }
172        assert!(out[4].is_some());
173    }
174
175    #[test]
176    fn rounded_top_then_breakdown_signals() {
177        let mut d = DumplingTop::new(9).unwrap();
178        let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
179        let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
180        let last = d.batch(&candles).into_iter().flatten().last().unwrap();
181        assert_eq!(last, -1.0);
182    }
183
184    #[test]
185    fn one_sided_rise_is_zero() {
186        let mut d = DumplingTop::new(9).unwrap();
187        let candles: Vec<Candle> = (0..9).map(|i| c(100.0 + f64::from(i))).collect();
188        let last = d.batch(&candles).into_iter().flatten().last().unwrap();
189        assert_eq!(last, 0.0);
190    }
191
192    #[test]
193    fn no_breakdown_is_zero() {
194        let mut d = DumplingTop::new(9).unwrap();
195        let closes = [
196            100.0, 102.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5,
197        ];
198        let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
199        let last = d.batch(&candles).into_iter().flatten().last().unwrap();
200        assert_eq!(last, 0.0);
201    }
202
203    #[test]
204    fn reset_clears_state() {
205        let mut d = DumplingTop::new(5).unwrap();
206        d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0)]);
207        assert!(d.is_ready());
208        d.reset();
209        assert!(!d.is_ready());
210        assert_eq!(d.value(), None);
211        assert_eq!(d.update(c(100.0)), None);
212    }
213
214    #[test]
215    fn batch_equals_streaming() {
216        let candles: Vec<Candle> = (0..60)
217            .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
218            .collect();
219        let batch = DumplingTop::new(9).unwrap().batch(&candles);
220        let mut b = DumplingTop::new(9).unwrap();
221        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
222        assert_eq!(batch, streamed);
223    }
224}