1use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6#[derive(Debug, Clone)]
50pub struct MaxDrawdown {
51 period: usize,
52 front: Vec<FrontEntry>,
54 back: Vec<f64>,
56 records: Vec<Record>,
58 cursor: usize,
61 closed_worst: f64,
64 scratch: Vec<Segment>,
66 last: Option<f64>,
67}
68
69#[derive(Debug, Clone, Copy)]
71struct FrontEntry {
72 peak: f64,
74 worst: f64,
76}
77
78#[derive(Debug, Clone, Copy)]
80struct Record {
81 peak: f64,
82 low: f64,
84 low_so_far: f64,
86}
87
88#[derive(Debug, Clone, Copy)]
90struct Segment {
91 peak: f64,
92 low: f64,
93 worst: f64,
95}
96
97fn drawdown(peak: f64, low: f64) -> f64 {
99 if peak > 0.0 {
100 (peak - low) / peak
101 } else {
102 0.0
103 }
104}
105
106impl MaxDrawdown {
107 pub fn new(period: usize) -> Result<Self> {
112 if period == 0 {
113 return Err(Error::PeriodZero);
114 }
115 if period > crate::error::MAX_PERIOD {
116 return Err(Error::InvalidPeriod {
117 message: crate::error::PERIOD_ABOVE_MAX,
118 });
119 }
120 Ok(Self {
121 period,
122 front: Vec::new(),
123 back: Vec::new(),
124 records: Vec::new(),
125 cursor: 0,
126 closed_worst: 0.0,
127 scratch: Vec::new(),
128 last: None,
129 })
130 }
131
132 pub const fn period(&self) -> usize {
134 self.period
135 }
136
137 pub const fn value(&self) -> Option<f64> {
139 self.last
140 }
141
142 fn push_back(&mut self, value: f64) {
143 self.back.push(value);
144 if let Some(open) = self.records.last_mut().filter(|r| value <= r.peak) {
145 open.low = open.low.min(value);
146 open.low_so_far = open.low_so_far.min(value);
147 return;
148 }
149 let mut low_so_far = value;
150 if let Some(closed) = self.records.last() {
151 low_so_far = low_so_far.min(closed.low_so_far);
152 if self.cursor < self.records.len() {
153 self.closed_worst = self.closed_worst.max(drawdown(closed.peak, closed.low));
154 }
155 }
156 self.records.push(Record {
157 peak: value,
158 low: value,
159 low_so_far,
160 });
161 }
162
163 fn refill_front(&mut self) {
165 self.scratch.clear();
166 let mut peak = f64::NEG_INFINITY;
167 for &value in self.back.iter().rev() {
168 let mut low = value;
170 while let Some(seg) = self.scratch.last().copied().filter(|s| s.peak <= value) {
171 low = low.min(seg.low);
172 self.scratch.pop();
173 }
174 let worst = self
175 .scratch
176 .last()
177 .map_or(0.0, |s| s.worst)
178 .max(drawdown(value, low));
179 self.scratch.push(Segment {
180 peak: value,
181 low,
182 worst,
183 });
184 peak = peak.max(value);
185 self.front.push(FrontEntry { peak, worst });
186 }
187 self.back.clear();
188 self.records.clear();
189 self.cursor = 0;
190 self.closed_worst = 0.0;
191 }
192
193 fn worst(&mut self) -> f64 {
194 let (peak, front_worst) = self
195 .front
196 .last()
197 .map_or((f64::NEG_INFINITY, 0.0), |e| (e.peak, e.worst));
198 while self.cursor < self.records.len() && self.records[self.cursor].peak <= peak {
201 self.cursor += 1;
202 }
203 while self.cursor > 0 && self.records[self.cursor - 1].peak > peak {
204 self.cursor -= 1;
205 if self.cursor + 1 < self.records.len() {
206 let r = self.records[self.cursor];
207 self.closed_worst = self.closed_worst.max(drawdown(r.peak, r.low));
208 }
209 }
210 let mut worst = front_worst.max(self.closed_worst);
211 if self.cursor < self.records.len() {
212 let open = self.records[self.records.len() - 1];
213 worst = worst.max(drawdown(open.peak, open.low));
214 }
215 if self.cursor > 0 {
216 worst = worst.max(drawdown(peak, self.records[self.cursor - 1].low_so_far));
218 }
219 worst
220 }
221}
222
223impl Indicator for MaxDrawdown {
224 type Input = f64;
225 type Output = f64;
226
227 fn update(&mut self, input: f64) -> Option<f64> {
228 if !input.is_finite() {
229 return None;
230 }
231 self.push_back(input);
232 if self.front.len() + self.back.len() > self.period {
233 if self.front.is_empty() {
234 self.refill_front();
235 }
236 self.front.pop();
237 }
238 if self.front.len() + self.back.len() < self.period {
239 return None;
240 }
241 let worst = self.worst();
242 self.last = Some(worst);
243 Some(worst)
244 }
245
246 fn reset(&mut self) {
247 self.front.clear();
248 self.back.clear();
249 self.records.clear();
250 self.cursor = 0;
251 self.closed_worst = 0.0;
252 self.last = None;
253 }
254
255 #[inline]
256 fn warmup_period(&self) -> usize {
257 self.period
258 }
259
260 #[inline]
261 fn is_ready(&self) -> bool {
262 self.last.is_some()
263 }
264
265 #[inline]
266 fn name(&self) -> &'static str {
267 "MaxDrawdown"
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::traits::BatchExt;
275 use approx::assert_relative_eq;
276
277 fn rescan(prices: &[f64], period: usize) -> Vec<Option<f64>> {
279 (0..prices.len())
280 .map(|i| {
281 if i + 1 < period {
282 return None;
283 }
284 let mut peak = f64::NEG_INFINITY;
285 let mut worst = 0.0_f64;
286 for &v in &prices[i + 1 - period..=i] {
287 if v > peak {
288 peak = v;
289 }
290 if peak > 0.0 {
291 let dd = (peak - v) / peak;
292 if dd > worst {
293 worst = dd;
294 }
295 }
296 }
297 Some(worst)
298 })
299 .collect()
300 }
301
302 fn bits(out: &[Option<f64>]) -> Vec<Option<u64>> {
303 out.iter().map(|v| v.map(f64::to_bits)).collect()
304 }
305
306 #[test]
307 fn new_rejects_zero_period() {
308 assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero)));
309 }
310
311 #[test]
312 fn accessors_and_metadata() {
313 let mut mdd = MaxDrawdown::new(10).unwrap();
314 assert_eq!(mdd.period(), 10);
315 assert_eq!(mdd.name(), "MaxDrawdown");
316 assert_eq!(mdd.value(), None);
317 assert_eq!(mdd.warmup_period(), 10);
318 for v in 1..=10 {
319 mdd.update(f64::from(v));
320 }
321 assert!(mdd.value().is_some());
322 }
323
324 #[test]
325 fn pure_uptrend_yields_zero() {
326 let mut mdd = MaxDrawdown::new(5).unwrap();
327 let out = mdd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
328 for v in out.into_iter().flatten() {
329 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
330 }
331 }
332
333 #[test]
334 fn reference_drawdown() {
335 let mut mdd = MaxDrawdown::new(3).unwrap();
337 let out = mdd.batch(&[100.0, 120.0, 90.0]);
338 assert_eq!(out[0], None);
339 assert_eq!(out[1], None);
340 assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
341 }
342
343 #[test]
344 fn constant_series_yields_zero() {
345 let mut mdd = MaxDrawdown::new(4).unwrap();
346 let out = mdd.batch(&[50.0; 12]);
347 for v in out.into_iter().flatten() {
348 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
349 }
350 }
351
352 #[test]
353 fn ignores_non_finite_input() {
354 let mut mdd = MaxDrawdown::new(3).unwrap();
355 mdd.batch(&[100.0, 90.0, 80.0]);
356 let last = mdd.value();
357 assert_eq!(mdd.update(f64::NAN), None);
358 assert_eq!(mdd.update(f64::INFINITY), None);
359 assert_eq!(mdd.value(), last);
361 }
362
363 #[test]
364 fn reset_clears_state() {
365 let mut mdd = MaxDrawdown::new(3).unwrap();
366 mdd.batch(&[100.0, 90.0, 80.0]);
367 assert!(mdd.is_ready());
368 mdd.reset();
369 assert!(!mdd.is_ready());
370 assert_eq!(mdd.update(100.0), None);
371 }
372
373 #[test]
374 fn reset_then_replay_matches_fresh() {
375 let prices: Vec<f64> = (0..90)
376 .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 15.0)
377 .collect();
378 let mut mdd = MaxDrawdown::new(8).unwrap();
379 let first = mdd.batch(&prices);
380 mdd.reset();
381 assert_eq!(bits(&mdd.batch(&prices)), bits(&first));
382 }
383
384 #[test]
385 fn batch_equals_streaming() {
386 let prices: Vec<f64> = (0..60)
387 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
388 .collect();
389 let batch = MaxDrawdown::new(10).unwrap().batch(&prices);
390 let mut s = MaxDrawdown::new(10).unwrap();
391 let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
392 assert_eq!(batch, streamed);
393 }
394
395 #[test]
396 fn non_positive_peak_yields_zero() {
397 let mut mdd = MaxDrawdown::new(3).unwrap();
399 let out = mdd.batch(&[0.0_f64; 6]);
400 for v in out.into_iter().flatten() {
401 assert_eq!(v, 0.0);
402 }
403 }
404
405 #[test]
406 fn matches_rescan_on_long_series() {
407 let mut prices: Vec<f64> = (0..1500)
410 .map(|i| 100.0 + (f64::from(i) * 0.05).sin() * 40.0 + (f64::from(i) * 0.9).cos() * 3.0)
411 .collect();
412 prices.extend((0..400).map(|i| 500.0 - f64::from(i)));
413 prices.extend((0..200).map(|i| -20.0 + (f64::from(i) * 0.4).sin() * 25.0));
414 prices.extend([42.0; 50]);
415 for period in [1, 2, 3, 7, 64, 250, 701] {
416 let got = MaxDrawdown::new(period).unwrap().batch(&prices);
417 assert_eq!(
418 bits(&got),
419 bits(&rescan(&prices, period)),
420 "period {period}"
421 );
422 }
423 }
424
425 proptest::proptest! {
426 #![proptest_config(proptest::test_runner::Config::with_cases(256))]
427 #[test]
428 fn matches_rescan_bit_for_bit(
429 period in 1usize..24,
430 ticks in proptest::collection::vec(-30i32..90, 0..300),
432 scale in proptest::sample::select(vec![1.0_f64, 0.37, 1e-3, 1e6]),
433 ) {
434 let prices: Vec<f64> = ticks.iter().map(|&t| f64::from(t) * scale).collect();
435 let got = MaxDrawdown::new(period).unwrap().batch(&prices);
436 proptest::prop_assert_eq!(bits(&got), bits(&rescan(&prices, period)));
437 }
438 }
439
440 #[test]
441 fn rejects_period_above_maximum() {
442 assert!(matches!(
443 MaxDrawdown::new(crate::error::MAX_PERIOD + 1),
444 Err(Error::InvalidPeriod { .. })
445 ));
446 }
447}