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    /// Construct an ADR indicator over `period` completed days.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::PeriodZero`] if `period == 0`.
51    pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
52        if period == 0 {
53            return Err(Error::PeriodZero);
54        }
55        Ok(Self {
56            period,
57            utc_offset_minutes,
58            day_key: None,
59            cur_high: f64::NEG_INFINITY,
60            cur_low: f64::INFINITY,
61            completed: VecDeque::with_capacity(period),
62            sum: 0.0,
63        })
64    }
65
66    /// Configured `(period, utc_offset_minutes)`.
67    pub const fn params(&self) -> (usize, i32) {
68        (self.period, self.utc_offset_minutes)
69    }
70
71    /// Most recent ADR if at least one session has completed.
72    pub fn value(&self) -> Option<f64> {
73        if self.completed.is_empty() {
74            None
75        } else {
76            Some(self.sum / self.completed.len() as f64)
77        }
78    }
79}
80
81impl Indicator for AverageDailyRange {
82    type Input = Candle;
83    type Output = f64;
84
85    fn update(&mut self, candle: Candle) -> Option<f64> {
86        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
87        let key = (civil.year, civil.month, civil.day);
88        match self.day_key {
89            Some(prev) if prev == key => {
90                if candle.high > self.cur_high {
91                    self.cur_high = candle.high;
92                }
93                if candle.low < self.cur_low {
94                    self.cur_low = candle.low;
95                }
96            }
97            Some(_) => {
98                let range = self.cur_high - self.cur_low;
99                self.completed.push_back(range);
100                self.sum += range;
101                if self.completed.len() > self.period {
102                    self.sum -= self
103                        .completed
104                        .pop_front()
105                        .expect("len > period implies a front element");
106                }
107                self.day_key = Some(key);
108                self.cur_high = candle.high;
109                self.cur_low = candle.low;
110            }
111            None => {
112                self.day_key = Some(key);
113                self.cur_high = candle.high;
114                self.cur_low = candle.low;
115            }
116        }
117        self.value()
118    }
119
120    fn reset(&mut self) {
121        self.day_key = None;
122        self.cur_high = f64::NEG_INFINITY;
123        self.cur_low = f64::INFINITY;
124        self.completed.clear();
125        self.sum = 0.0;
126    }
127
128    fn warmup_period(&self) -> usize {
129        self.period
130    }
131
132    fn is_ready(&self) -> bool {
133        !self.completed.is_empty()
134    }
135
136    fn name(&self) -> &'static str {
137        "AverageDailyRange"
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::traits::BatchExt;
145    use approx::assert_relative_eq;
146
147    const HOUR: i64 = 3_600_000;
148    const DAY: i64 = 24 * HOUR;
149
150    fn c(high: f64, low: f64, ts: i64) -> Candle {
151        let mid = f64::midpoint(high, low);
152        Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
153    }
154
155    #[test]
156    fn rejects_zero_period() {
157        assert!(matches!(
158            AverageDailyRange::new(0, 0),
159            Err(Error::PeriodZero)
160        ));
161    }
162
163    #[test]
164    fn metadata_and_accessors() {
165        let adr = AverageDailyRange::new(5, -60).unwrap();
166        assert_eq!(adr.params(), (5, -60));
167        assert_eq!(adr.name(), "AverageDailyRange");
168        assert_eq!(adr.warmup_period(), 5);
169        assert!(!adr.is_ready());
170        assert!(adr.value().is_none());
171    }
172
173    #[test]
174    fn averages_completed_day_ranges() {
175        let mut adr = AverageDailyRange::new(3, 0).unwrap();
176        // Day 1: range 10.
177        assert!(adr.update(c(110.0, 100.0, 0)).is_none());
178        assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
179        // Day 2 opens -> day 1 (range 10) completes.
180        let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
181        assert_relative_eq!(v, 10.0);
182        assert!(adr.is_ready());
183        // Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
184        let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
185        assert_relative_eq!(v, 10.0);
186    }
187
188    #[test]
189    fn rolls_off_oldest_day_beyond_period() {
190        let mut adr = AverageDailyRange::new(2, 0).unwrap();
191        adr.update(c(110.0, 100.0, 0)); // day 1 range 10
192        let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
193        assert_relative_eq!(v, 10.0);
194        // Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
195        let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
196        assert_relative_eq!(v, 12.5);
197        // Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
198        let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
199        assert_relative_eq!(v, 17.5);
200    }
201
202    #[test]
203    fn reset_clears_state() {
204        let mut adr = AverageDailyRange::new(2, 0).unwrap();
205        adr.update(c(110.0, 100.0, 0));
206        adr.update(c(120.0, 110.0, DAY));
207        adr.reset();
208        assert!(!adr.is_ready());
209        assert!(adr.value().is_none());
210        assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
211    }
212
213    #[test]
214    fn batch_equals_streaming() {
215        let candles: Vec<Candle> = (0..60)
216            .map(|i| {
217                c(
218                    110.0 + f64::from(i % 5),
219                    100.0 - f64::from(i % 3),
220                    i64::from(i) * 6 * HOUR,
221                )
222            })
223            .collect();
224        let mut a = AverageDailyRange::new(4, 0).unwrap();
225        let mut b = AverageDailyRange::new(4, 0).unwrap();
226        assert_eq!(
227            a.batch(&candles),
228            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
229        );
230    }
231}