wickra_core/indicators/
yoyo_exit.rs1use crate::error::{Error, Result};
4use crate::indicators::atr::Atr;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
44pub struct YoyoExit {
45 atr: Atr,
46 atr_period: usize,
47 multiplier: f64,
48 trail: Option<f64>,
49 in_trade: bool,
52}
53
54impl YoyoExit {
55 pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
62 if !multiplier.is_finite() || multiplier <= 0.0 {
63 return Err(Error::NonPositiveMultiplier);
64 }
65 Ok(Self {
66 atr: Atr::new(atr_period)?,
67 atr_period,
68 multiplier,
69 trail: None,
70 in_trade: true,
71 })
72 }
73
74 pub fn classic() -> Self {
76 Self::new(14, 2.0).expect("classic Yo-Yo Exit params are valid")
77 }
78
79 pub const fn params(&self) -> (usize, f64) {
81 (self.atr_period, self.multiplier)
82 }
83
84 pub const fn in_trade(&self) -> bool {
86 self.in_trade
87 }
88}
89
90impl Indicator for YoyoExit {
91 type Input = Candle;
92 type Output = f64;
93
94 #[inline]
95 fn update(&mut self, candle: Candle) -> Option<f64> {
96 let atr = self.atr.update(candle)?;
97 let band = self.multiplier * atr;
98 let close = candle.close;
99
100 let trail = match self.trail {
101 Some(prev) => {
102 if self.in_trade {
103 if close < prev {
104 self.in_trade = false;
106 prev
107 } else {
108 prev.max(close - band)
110 }
111 } else if close > prev + band {
112 self.in_trade = true;
114 close - band
115 } else {
116 prev
117 }
118 }
119 None => close - band,
121 };
122 self.trail = Some(trail);
123 Some(trail)
124 }
125
126 fn reset(&mut self) {
127 self.atr.reset();
128 self.trail = None;
129 self.in_trade = true;
130 }
131
132 #[inline]
133 fn warmup_period(&self) -> usize {
134 self.atr_period
135 }
136
137 #[inline]
138 fn is_ready(&self) -> bool {
139 self.trail.is_some()
140 }
141
142 #[inline]
143 fn name(&self) -> &'static str {
144 "YoyoExit"
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::traits::BatchExt;
152 use approx::assert_relative_eq;
153
154 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
155 Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
156 }
157
158 #[test]
159 fn rejects_invalid_params() {
160 assert!(YoyoExit::new(0, 2.0).is_err());
161 assert!(YoyoExit::new(14, 0.0).is_err());
162 assert!(YoyoExit::new(14, -1.0).is_err());
163 assert!(YoyoExit::new(14, f64::NAN).is_err());
164 }
165
166 #[test]
167 fn accessors_and_metadata() {
168 let s = YoyoExit::classic();
169 let (p, m) = s.params();
170 assert_eq!(p, 14);
171 assert_relative_eq!(m, 2.0, epsilon = 1e-12);
172 assert_eq!(s.warmup_period(), 14);
173 assert_eq!(s.name(), "YoyoExit");
174 assert!(s.in_trade());
175 }
176
177 #[test]
178 fn first_emission_matches_warmup() {
179 let candles: Vec<Candle> = (0..20)
180 .map(|i| {
181 let base = 100.0 + i as f64;
182 c(base + 1.0, base - 1.0, base, i)
183 })
184 .collect();
185 let mut s = YoyoExit::new(8, 2.0).unwrap();
186 let out = s.batch(&candles);
187 for (i, v) in out.iter().enumerate().take(7) {
188 assert!(v.is_none(), "index {i} must be None during warmup");
189 }
190 assert!(out[7].is_some());
191 }
192
193 #[test]
194 fn reference_values_flat_market() {
195 let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
197 let mut s = YoyoExit::new(5, 2.0).unwrap();
198 for v in s.batch(&candles).into_iter().flatten() {
199 assert_relative_eq!(v, 6.0, epsilon = 1e-12);
200 }
201 }
202
203 #[test]
204 fn uptrend_trail_ratchets_up() {
205 let candles: Vec<Candle> = (0..40)
206 .map(|i| {
207 let base = 100.0 + i as f64;
208 c(base + 1.0, base - 1.0, base, i)
209 })
210 .collect();
211 let mut s = YoyoExit::new(14, 3.0).unwrap();
212 let emitted: Vec<f64> = s.batch(&candles).into_iter().flatten().collect();
213 for w in emitted.windows(2) {
214 assert!(w[1] >= w[0] - 1e-9, "trail must not loosen in an uptrend");
215 }
216 }
217
218 #[test]
219 fn reentry_after_stop_out() {
220 let mut candles: Vec<Candle> = (0..30)
222 .map(|i| {
223 let base = 100.0 + i as f64;
224 c(base + 1.0, base - 1.0, base, i)
225 })
226 .collect();
227 candles.push(c(60.0, 40.0, 50.0, 30)); candles.push(c(60.0, 50.0, 55.0, 31)); candles.push(c(200.0, 100.0, 200.0, 32)); let mut s = YoyoExit::new(14, 3.0).unwrap();
231 for c in &candles {
234 let _ = s.update(*c);
235 }
236 assert!(s.is_ready());
237 assert!(s.in_trade());
239 }
240
241 #[test]
242 fn reset_clears_state() {
243 let candles: Vec<Candle> = (0..40)
244 .map(|i| {
245 let base = 100.0 + i as f64;
246 c(base + 1.0, base - 1.0, base, i)
247 })
248 .collect();
249 let mut s = YoyoExit::classic();
250 s.batch(&candles);
251 assert!(s.is_ready());
252 s.reset();
253 assert!(!s.is_ready());
254 assert!(s.in_trade());
255 assert_eq!(s.update(candles[0]), None);
256 }
257
258 #[test]
259 fn batch_equals_streaming() {
260 let candles: Vec<Candle> = (0..80)
261 .map(|i| {
262 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
263 c(mid + 1.5, mid - 1.5, mid + 0.5, i)
264 })
265 .collect();
266 let mut a = YoyoExit::classic();
267 let mut b = YoyoExit::classic();
268 assert_eq!(
269 a.batch(&candles),
270 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
271 );
272 }
273}