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 fn update(&mut self, candle: Candle) -> Option<f64> {
95 let atr = self.atr.update(candle)?;
96 let band = self.multiplier * atr;
97 let close = candle.close;
98
99 let trail = match self.trail {
100 Some(prev) => {
101 if self.in_trade {
102 if close < prev {
103 self.in_trade = false;
105 prev
106 } else {
107 prev.max(close - band)
109 }
110 } else if close > prev + band {
111 self.in_trade = true;
113 close - band
114 } else {
115 prev
116 }
117 }
118 None => close - band,
120 };
121 self.trail = Some(trail);
122 Some(trail)
123 }
124
125 fn reset(&mut self) {
126 self.atr.reset();
127 self.trail = None;
128 self.in_trade = true;
129 }
130
131 fn warmup_period(&self) -> usize {
132 self.atr_period
133 }
134
135 fn is_ready(&self) -> bool {
136 self.trail.is_some()
137 }
138
139 fn name(&self) -> &'static str {
140 "YoyoExit"
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::traits::BatchExt;
148 use approx::assert_relative_eq;
149
150 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
151 Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
152 }
153
154 #[test]
155 fn rejects_invalid_params() {
156 assert!(YoyoExit::new(0, 2.0).is_err());
157 assert!(YoyoExit::new(14, 0.0).is_err());
158 assert!(YoyoExit::new(14, -1.0).is_err());
159 assert!(YoyoExit::new(14, f64::NAN).is_err());
160 }
161
162 #[test]
163 fn accessors_and_metadata() {
164 let s = YoyoExit::classic();
165 let (p, m) = s.params();
166 assert_eq!(p, 14);
167 assert_relative_eq!(m, 2.0, epsilon = 1e-12);
168 assert_eq!(s.warmup_period(), 14);
169 assert_eq!(s.name(), "YoyoExit");
170 assert!(s.in_trade());
171 }
172
173 #[test]
174 fn first_emission_matches_warmup() {
175 let candles: Vec<Candle> = (0..20)
176 .map(|i| {
177 let base = 100.0 + i as f64;
178 c(base + 1.0, base - 1.0, base, i)
179 })
180 .collect();
181 let mut s = YoyoExit::new(8, 2.0).unwrap();
182 let out = s.batch(&candles);
183 for (i, v) in out.iter().enumerate().take(7) {
184 assert!(v.is_none(), "index {i} must be None during warmup");
185 }
186 assert!(out[7].is_some());
187 }
188
189 #[test]
190 fn reference_values_flat_market() {
191 let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
193 let mut s = YoyoExit::new(5, 2.0).unwrap();
194 for v in s.batch(&candles).into_iter().flatten() {
195 assert_relative_eq!(v, 6.0, epsilon = 1e-12);
196 }
197 }
198
199 #[test]
200 fn uptrend_trail_ratchets_up() {
201 let candles: Vec<Candle> = (0..40)
202 .map(|i| {
203 let base = 100.0 + i as f64;
204 c(base + 1.0, base - 1.0, base, i)
205 })
206 .collect();
207 let mut s = YoyoExit::new(14, 3.0).unwrap();
208 let emitted: Vec<f64> = s.batch(&candles).into_iter().flatten().collect();
209 for w in emitted.windows(2) {
210 assert!(w[1] >= w[0] - 1e-9, "trail must not loosen in an uptrend");
211 }
212 }
213
214 #[test]
215 fn reentry_after_stop_out() {
216 let mut candles: Vec<Candle> = (0..30)
218 .map(|i| {
219 let base = 100.0 + i as f64;
220 c(base + 1.0, base - 1.0, base, i)
221 })
222 .collect();
223 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();
227 for c in &candles {
230 let _ = s.update(*c);
231 }
232 assert!(s.is_ready());
233 assert!(s.in_trade());
235 }
236
237 #[test]
238 fn reset_clears_state() {
239 let candles: Vec<Candle> = (0..40)
240 .map(|i| {
241 let base = 100.0 + i as f64;
242 c(base + 1.0, base - 1.0, base, i)
243 })
244 .collect();
245 let mut s = YoyoExit::classic();
246 s.batch(&candles);
247 assert!(s.is_ready());
248 s.reset();
249 assert!(!s.is_ready());
250 assert!(s.in_trade());
251 assert_eq!(s.update(candles[0]), None);
252 }
253
254 #[test]
255 fn batch_equals_streaming() {
256 let candles: Vec<Candle> = (0..80)
257 .map(|i| {
258 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
259 c(mid + 1.5, mid - 1.5, mid + 0.5, i)
260 })
261 .collect();
262 let mut a = YoyoExit::classic();
263 let mut b = YoyoExit::classic();
264 assert_eq!(
265 a.batch(&candles),
266 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
267 );
268 }
269}