wickra_core/indicators/
fry_pan_bottom.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
44pub struct FryPanBottom {
45 period: usize,
46 closes: VecDeque<f64>,
47 last: Option<f64>,
48}
49
50impl FryPanBottom {
51 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 pub const fn period(&self) -> usize {
77 self.period
78 }
79
80 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 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 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 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}