1use crate::error::{Error, Result};
4use crate::indicators::atr::Atr;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
45pub struct VoltyStop {
46 atr: Atr,
47 atr_period: usize,
48 multiplier: f64,
49 anchor: Option<f64>,
50 long: bool,
51}
52
53impl VoltyStop {
54 pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
61 if !multiplier.is_finite() || multiplier <= 0.0 {
62 return Err(Error::NonPositiveMultiplier);
63 }
64 Ok(Self {
65 atr: Atr::new(atr_period)?,
66 atr_period,
67 multiplier,
68 anchor: None,
69 long: true,
70 })
71 }
72
73 pub fn classic() -> Self {
75 Self::new(14, 2.0).expect("classic Volty Stop params are valid")
76 }
77
78 pub const fn params(&self) -> (usize, f64) {
80 (self.atr_period, self.multiplier)
81 }
82}
83
84impl Indicator for VoltyStop {
85 type Input = Candle;
86 type Output = f64;
87
88 #[inline]
89 fn update(&mut self, candle: Candle) -> Option<f64> {
90 let atr = self.atr.update(candle)?;
91 let band = self.multiplier * atr;
92 let close = candle.close;
93
94 let (anchor, long) = match (self.anchor, self.long) {
95 (Some(prev_anchor), true) => {
96 let stop = prev_anchor - band;
97 if close < stop {
98 (close, false)
100 } else {
101 (prev_anchor.max(close), true)
103 }
104 }
105 (Some(prev_anchor), false) => {
106 let stop = prev_anchor + band;
107 if close > stop {
108 (close, true)
109 } else {
110 (prev_anchor.min(close), false)
111 }
112 }
113 (None, _) => (close, true),
115 };
116 self.anchor = Some(anchor);
117 self.long = long;
118 let stop = if long { anchor - band } else { anchor + band };
119 Some(stop)
120 }
121
122 fn reset(&mut self) {
123 self.atr.reset();
124 self.anchor = None;
125 self.long = true;
126 }
127
128 #[inline]
129 fn warmup_period(&self) -> usize {
130 self.atr_period
131 }
132
133 #[inline]
134 fn is_ready(&self) -> bool {
135 self.anchor.is_some()
136 }
137
138 #[inline]
139 fn name(&self) -> &'static str {
140 "VoltyStop"
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!(VoltyStop::new(0, 2.0).is_err());
157 assert!(VoltyStop::new(14, 0.0).is_err());
158 assert!(VoltyStop::new(14, -1.0).is_err());
159 assert!(VoltyStop::new(14, f64::NAN).is_err());
160 }
161
162 #[test]
163 fn accessors_and_metadata() {
164 let s = VoltyStop::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(), "VoltyStop");
170 }
171
172 #[test]
173 fn first_emission_matches_warmup() {
174 let candles: Vec<Candle> = (0..20)
175 .map(|i| {
176 let base = 100.0 + i as f64;
177 c(base + 1.0, base - 1.0, base, i)
178 })
179 .collect();
180 let mut s = VoltyStop::new(8, 2.0).unwrap();
181 let out = s.batch(&candles);
182 for (i, v) in out.iter().enumerate().take(7) {
183 assert!(v.is_none(), "index {i} must be None during warmup");
184 }
185 assert!(out[7].is_some());
186 }
187
188 #[test]
189 fn reference_values_flat_market() {
190 let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
192 let mut s = VoltyStop::new(5, 2.0).unwrap();
193 for v in s.batch(&candles).into_iter().flatten() {
194 assert_relative_eq!(v, 6.0, epsilon = 1e-12);
195 }
196 }
197
198 #[test]
199 fn uptrend_anchor_ratchets_up_with_close() {
200 let candles: Vec<Candle> = (0..40)
201 .map(|i| {
202 let base = 100.0 + i as f64;
203 c(base + 1.0, base - 1.0, base, i)
204 })
205 .collect();
206 let mut s = VoltyStop::new(14, 3.0).unwrap();
207 let emitted: Vec<(f64, f64)> = s
208 .batch(&candles)
209 .into_iter()
210 .zip(candles.iter())
211 .filter_map(|(o, c)| o.map(|v| (v, c.close)))
212 .collect();
213 for w in emitted.windows(2) {
214 assert!(
215 w[1].0 >= w[0].0 - 1e-9,
216 "stop must not loosen in an uptrend"
217 );
218 }
219 for &(stop, close) in &emitted {
220 assert!(stop < close, "uptrend stop should sit below the close");
221 }
222 }
223
224 #[test]
225 fn stop_flips_on_reversal() {
226 let mut candles: Vec<Candle> = (0..40)
227 .map(|i| {
228 let base = 100.0 + i as f64;
229 c(base + 1.0, base - 1.0, base, i)
230 })
231 .collect();
232 candles.extend((0..40).map(|i| {
233 let base = 140.0 - 3.0 * i as f64;
234 c(base + 1.0, base - 1.0, base, 40 + i)
235 }));
236 let mut s = VoltyStop::new(14, 3.0).unwrap();
237 let paired: Vec<(f64, f64)> = s
238 .batch(&candles)
239 .into_iter()
240 .zip(candles.iter())
241 .filter_map(|(o, c)| o.map(|v| (v, c.close)))
242 .collect();
243 assert!(paired.iter().any(|&(stop, close)| stop < close));
244 assert!(paired.iter().any(|&(stop, close)| stop > close));
245 }
246
247 #[test]
248 fn reset_clears_state() {
249 let candles: Vec<Candle> = (0..40)
250 .map(|i| {
251 let base = 100.0 + i as f64;
252 c(base + 1.0, base - 1.0, base, i)
253 })
254 .collect();
255 let mut s = VoltyStop::classic();
256 s.batch(&candles);
257 assert!(s.is_ready());
258 s.reset();
259 assert!(!s.is_ready());
260 assert_eq!(s.update(candles[0]), None);
261 }
262
263 #[test]
264 fn batch_equals_streaming() {
265 let candles: Vec<Candle> = (0..80)
266 .map(|i| {
267 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
268 c(mid + 1.5, mid - 1.5, mid + 0.5, i)
269 })
270 .collect();
271 let mut a = VoltyStop::classic();
272 let mut b = VoltyStop::classic();
273 assert_eq!(
274 a.batch(&candles),
275 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
276 );
277 }
278}