Skip to main content

wickra_core/indicators/
fry_pan_bottom.rs

1//! Frying Pan Bottom — a rounded bottom (U) confirmed by recovery.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Frying Pan Bottom — a gently rounded bottom across the lookback window: prices
10/// decline, flatten near the centre, then recover above where they started.
11///
12/// ```text
13/// over the last `period` closes:
14///   the minimum close sits in the middle third of the window (the "bowl")
15///   the latest close is above the first close (the rim is recovered)
16/// signal = +1 when both hold, else 0
17/// ```
18///
19/// The frying pan is a bullish accumulation pattern: a saucer-shaped base where
20/// selling dries up, the curve flattens, and price lifts off the rim. Detecting it
21/// requires the low point to be central (a symmetric bowl, not a one-sided drop)
22/// and the close to have climbed back above the window's opening level, confirming
23/// the breakout from the base. 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, FryPanBottom};
32///
33/// let mut indicator = FryPanBottom::new(9).unwrap();
34/// // A U-shaped base then recovery.
35/// let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
36/// let mut last = None;
37/// for &cl in &closes {
38///     let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
39///     last = indicator.update(c);
40/// }
41/// assert_eq!(last, Some(1.0));
42/// ```
43#[derive(Debug, Clone)]
44pub struct FryPanBottom {
45    period: usize,
46    closes: VecDeque<f64>,
47    last: Option<f64>,
48}
49
50impl FryPanBottom {
51    /// Construct a Frying Pan Bottom over `period` bars.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`Error::InvalidPeriod`] if `period < 5` (a bowl needs room for a
56    /// central low between recovering sides).
57    pub fn new(period: usize) -> Result<Self> {
58        if period < 5 {
59            return Err(Error::InvalidPeriod {
60                message: "frying pan bottom needs period >= 5",
61            });
62        }
63        if period > crate::error::MAX_PERIOD {
64            return Err(Error::InvalidPeriod {
65                message: crate::error::PERIOD_ABOVE_MAX,
66            });
67        }
68        Ok(Self {
69            period,
70            closes: VecDeque::with_capacity(period),
71            last: None,
72        })
73    }
74
75    /// Configured window period.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79
80    /// Current value if available.
81    pub const fn value(&self) -> Option<f64> {
82        self.last
83    }
84}
85
86impl Indicator for FryPanBottom {
87    type Input = Candle;
88    type Output = f64;
89
90    #[inline]
91    fn update(&mut self, candle: Candle) -> Option<f64> {
92        if self.closes.len() == self.period {
93            self.closes.pop_front();
94        }
95        self.closes.push_back(candle.close);
96        if self.closes.len() < self.period {
97            return None;
98        }
99        let first = *self.closes.front().expect("non-empty");
100        let last = *self.closes.back().expect("non-empty");
101        // Index of the minimum close.
102        let mut min_idx = 0;
103        let mut min_val = f64::INFINITY;
104        for (i, &v) in self.closes.iter().enumerate() {
105            if v < min_val {
106                min_val = v;
107                min_idx = i;
108            }
109        }
110        let lo = self.period / 4;
111        let hi = self.period - self.period / 4;
112        let bowl = min_idx >= lo && min_idx < hi;
113        let recovered = last > first && last > min_val;
114        let v = if bowl && recovered { 1.0 } else { 0.0 };
115        self.last = Some(v);
116        Some(v)
117    }
118
119    fn reset(&mut self) {
120        self.closes.clear();
121        self.last = None;
122    }
123
124    #[inline]
125    fn warmup_period(&self) -> usize {
126        self.period
127    }
128
129    #[inline]
130    fn is_ready(&self) -> bool {
131        self.last.is_some()
132    }
133
134    #[inline]
135    fn name(&self) -> &'static str {
136        "FryPanBottom"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::traits::BatchExt;
144
145    fn c(close: f64) -> Candle {
146        Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
147    }
148
149    #[test]
150    fn rejects_small_period() {
151        assert!(matches!(
152            FryPanBottom::new(4),
153            Err(Error::InvalidPeriod { .. })
154        ));
155        assert!(FryPanBottom::new(5).is_ok());
156    }
157
158    #[test]
159    fn accessors_and_metadata() {
160        let f = FryPanBottom::new(9).unwrap();
161        assert_eq!(f.period(), 9);
162        assert_eq!(f.warmup_period(), 9);
163        assert_eq!(f.name(), "FryPanBottom");
164        assert!(!f.is_ready());
165        assert_eq!(f.value(), None);
166    }
167
168    #[test]
169    fn first_emission_at_warmup_period() {
170        let mut f = FryPanBottom::new(5).unwrap();
171        let out = f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0), c(102.0)]);
172        for v in out.iter().take(4) {
173            assert!(v.is_none());
174        }
175        assert!(out[4].is_some());
176    }
177
178    #[test]
179    fn rounded_bottom_then_recovery_signals() {
180        let mut f = FryPanBottom::new(9).unwrap();
181        let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
182        let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
183        let last = f.batch(&candles).into_iter().flatten().last().unwrap();
184        assert_eq!(last, 1.0);
185    }
186
187    #[test]
188    fn one_sided_drop_is_zero() {
189        // A straight decline (min at the end) is not a bowl.
190        let mut f = FryPanBottom::new(9).unwrap();
191        let candles: Vec<Candle> = (0..9).map(|i| c(100.0 - f64::from(i))).collect();
192        let last = f.batch(&candles).into_iter().flatten().last().unwrap();
193        assert_eq!(last, 0.0);
194    }
195
196    #[test]
197    fn no_recovery_is_zero() {
198        // Bowl shape but the last close never climbs above the first.
199        let mut f = FryPanBottom::new(9).unwrap();
200        let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 97.0, 98.0, 99.0, 99.5];
201        let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
202        let last = f.batch(&candles).into_iter().flatten().last().unwrap();
203        assert_eq!(last, 0.0);
204    }
205
206    #[test]
207    fn reset_clears_state() {
208        let mut f = FryPanBottom::new(5).unwrap();
209        f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0)]);
210        assert!(f.is_ready());
211        f.reset();
212        assert!(!f.is_ready());
213        assert_eq!(f.value(), None);
214        assert_eq!(f.update(c(100.0)), None);
215    }
216
217    #[test]
218    fn batch_equals_streaming() {
219        let candles: Vec<Candle> = (0..60)
220            .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
221            .collect();
222        let batch = FryPanBottom::new(9).unwrap().batch(&candles);
223        let mut b = FryPanBottom::new(9).unwrap();
224        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
225        assert_eq!(batch, streamed);
226    }
227}