Skip to main content

wickra_core/indicators/
average_daily_range.rs

1//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
2//! completed calendar-day sessions.
3
4use std::collections::VecDeque;
5
6use crate::calendar::civil_from_timestamp;
7use crate::error::{Error, Result};
8use crate::ohlcv::Candle;
9use crate::traits::Indicator;
10
11/// Average Daily Range over the last `period` completed sessions.
12///
13/// The indicator tracks the running high / low of the current session (the
14/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
15/// `utc_offset_minutes`). When a new day begins, the just-finished session's
16/// range (`high - low`) joins a rolling window of the last `period` completed
17/// days, and the reported value is their mean. The current, still-forming day is
18/// excluded until it closes. No value is produced until the first session
19/// completes.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Candle, Indicator, AverageDailyRange};
25///
26/// let hour = 3_600_000;
27/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
28/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
29/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
30/// // First bar of day 2 closes day 1: ADR = 10.
31/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
32/// assert!((v - 10.0).abs() < 1e-9);
33/// ```
34#[derive(Debug, Clone)]
35pub struct AverageDailyRange {
36    period: usize,
37    utc_offset_minutes: i32,
38    day_key: Option<(i64, u32, u32)>,
39    cur_high: f64,
40    cur_low: f64,
41    completed: VecDeque<f64>,
42    sum: f64,
43}
44
45impl AverageDailyRange {
46    ///
47    /// The offset is a constant and does not follow daylight saving: for a
48    /// venue that observes it, one value is correct for part of the year and an
49    /// hour out for the rest, which shifts every session boundary by an hour.
50    /// Either pass the offset in force for the span being analysed and keep
51    /// spans that cross a transition apart, or convert the timestamps to the
52    /// venue's wall clock upstream and pass `0`.
53    /// Construct an ADR indicator over `period` completed days.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period == 0`.
58    pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
59        if period == 0 {
60            return Err(Error::PeriodZero);
61        }
62        if period > crate::error::MAX_PERIOD {
63            return Err(Error::InvalidPeriod {
64                message: crate::error::PERIOD_ABOVE_MAX,
65            });
66        }
67        Ok(Self {
68            period,
69            utc_offset_minutes,
70            day_key: None,
71            cur_high: f64::NEG_INFINITY,
72            cur_low: f64::INFINITY,
73            completed: VecDeque::with_capacity(period),
74            sum: 0.0,
75        })
76    }
77
78    /// Configured `(period, utc_offset_minutes)`.
79    pub const fn params(&self) -> (usize, i32) {
80        (self.period, self.utc_offset_minutes)
81    }
82
83    /// Most recent ADR if at least one session has completed.
84    pub fn value(&self) -> Option<f64> {
85        if self.completed.is_empty() {
86            None
87        } else {
88            Some(self.sum / self.completed.len() as f64)
89        }
90    }
91}
92
93impl Indicator for AverageDailyRange {
94    type Input = Candle;
95    type Output = f64;
96
97    #[inline]
98    fn update(&mut self, candle: Candle) -> Option<f64> {
99        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
100        let key = (civil.year, civil.month, civil.day);
101        match self.day_key {
102            Some(prev) if prev == key => {
103                if candle.high > self.cur_high {
104                    self.cur_high = candle.high;
105                }
106                if candle.low < self.cur_low {
107                    self.cur_low = candle.low;
108                }
109            }
110            Some(_) => {
111                let range = self.cur_high - self.cur_low;
112                self.completed.push_back(range);
113                self.sum += range;
114                if self.completed.len() > self.period {
115                    self.sum -= self
116                        .completed
117                        .pop_front()
118                        .expect("len > period implies a front element");
119                }
120                self.day_key = Some(key);
121                self.cur_high = candle.high;
122                self.cur_low = candle.low;
123            }
124            None => {
125                self.day_key = Some(key);
126                self.cur_high = candle.high;
127                self.cur_low = candle.low;
128            }
129        }
130        self.value()
131    }
132
133    fn reset(&mut self) {
134        self.day_key = None;
135        self.cur_high = f64::NEG_INFINITY;
136        self.cur_low = f64::INFINITY;
137        self.completed.clear();
138        self.sum = 0.0;
139    }
140
141    #[inline]
142    fn warmup_period(&self) -> usize {
143        self.period
144    }
145
146    #[inline]
147    fn is_ready(&self) -> bool {
148        !self.completed.is_empty()
149    }
150
151    #[inline]
152    fn name(&self) -> &'static str {
153        "AverageDailyRange"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    const HOUR: i64 = 3_600_000;
164    const DAY: i64 = 24 * HOUR;
165
166    fn c(high: f64, low: f64, ts: i64) -> Candle {
167        let mid = f64::midpoint(high, low);
168        Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
169    }
170
171    #[test]
172    fn rejects_zero_period() {
173        assert!(matches!(
174            AverageDailyRange::new(0, 0),
175            Err(Error::PeriodZero)
176        ));
177    }
178
179    #[test]
180    fn metadata_and_accessors() {
181        let adr = AverageDailyRange::new(5, -60).unwrap();
182        assert_eq!(adr.params(), (5, -60));
183        assert_eq!(adr.name(), "AverageDailyRange");
184        assert_eq!(adr.warmup_period(), 5);
185        assert!(!adr.is_ready());
186        assert!(adr.value().is_none());
187    }
188
189    #[test]
190    fn averages_completed_day_ranges() {
191        let mut adr = AverageDailyRange::new(3, 0).unwrap();
192        // Day 1: range 10.
193        assert!(adr.update(c(110.0, 100.0, 0)).is_none());
194        assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
195        // Day 2 opens -> day 1 (range 10) completes.
196        let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
197        assert_relative_eq!(v, 10.0);
198        assert!(adr.is_ready());
199        // Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
200        let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
201        assert_relative_eq!(v, 10.0);
202    }
203
204    #[test]
205    fn rolls_off_oldest_day_beyond_period() {
206        let mut adr = AverageDailyRange::new(2, 0).unwrap();
207        adr.update(c(110.0, 100.0, 0)); // day 1 range 10
208        let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
209        assert_relative_eq!(v, 10.0);
210        // Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
211        let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
212        assert_relative_eq!(v, 12.5);
213        // Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
214        let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
215        assert_relative_eq!(v, 17.5);
216    }
217
218    #[test]
219    fn reset_clears_state() {
220        let mut adr = AverageDailyRange::new(2, 0).unwrap();
221        adr.update(c(110.0, 100.0, 0));
222        adr.update(c(120.0, 110.0, DAY));
223        adr.reset();
224        assert!(!adr.is_ready());
225        assert!(adr.value().is_none());
226        assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
227    }
228
229    #[test]
230    fn batch_equals_streaming() {
231        let candles: Vec<Candle> = (0..60)
232            .map(|i| {
233                c(
234                    110.0 + f64::from(i % 5),
235                    100.0 - f64::from(i % 3),
236                    i64::from(i) * 6 * HOUR,
237                )
238            })
239            .collect();
240        let mut a = AverageDailyRange::new(4, 0).unwrap();
241        let mut b = AverageDailyRange::new(4, 0).unwrap();
242        assert_eq!(
243            a.batch(&candles),
244            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
245        );
246    }
247}