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