Skip to main content

wickra_core/indicators/
intraday_volatility_profile.rs

1//! Intraday Volatility Profile — the return volatility in each intraday bucket.
2
3use crate::calendar::civil_from_timestamp;
4use crate::error::{Error, Result};
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Intraday Volatility Profile output: the per-bucket return standard deviation.
9///
10/// `bins[i]` is the sample standard deviation of the simple returns of all bars
11/// whose local time-of-day fell in bucket `i`. Buckets with fewer than two
12/// samples read `0.0`.
13#[derive(Debug, Clone, PartialEq)]
14pub struct IntradayVolatilityProfileOutput {
15    /// Per-bucket return standard deviation, earliest bucket first.
16    pub bins: Vec<f64>,
17}
18
19/// Return volatility bucketed by local time of day.
20///
21/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
22/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
23/// bar's simple return `close / previous_close - 1` updates the per-bucket
24/// running variance (Welford), and the profile reports the per-bucket sample
25/// standard deviation. The first bar produces no output.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, IntradayVolatilityProfile};
31///
32/// let hour = 3_600_000;
33/// let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
34/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
35/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap();
36/// assert_eq!(out.bins.len(), 24);
37/// ```
38#[derive(Debug, Clone)]
39pub struct IntradayVolatilityProfile {
40    buckets: usize,
41    utc_offset_minutes: i32,
42    prev_close: Option<f64>,
43    count: Vec<u64>,
44    mean: Vec<f64>,
45    m2: Vec<f64>,
46    last: Option<IntradayVolatilityProfileOutput>,
47}
48
49impl IntradayVolatilityProfile {
50    ///
51    /// The offset is a constant and does not follow daylight saving: for a
52    /// venue that observes it, one value is correct for part of the year and an
53    /// hour out for the rest, which shifts every session boundary by an hour.
54    /// Either pass the offset in force for the span being analysed and keep
55    /// spans that cross a transition apart, or convert the timestamps to the
56    /// venue's wall clock upstream and pass `0`.
57    /// Construct an Intraday Volatility Profile with `buckets` intraday slices.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `buckets == 0`.
62    pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
63        if buckets == 0 {
64            return Err(Error::PeriodZero);
65        }
66        if buckets > crate::error::MAX_PERIOD {
67            return Err(Error::InvalidPeriod {
68                message: crate::error::PERIOD_ABOVE_MAX,
69            });
70        }
71        Ok(Self {
72            buckets,
73            utc_offset_minutes,
74            prev_close: None,
75            count: vec![0; buckets],
76            mean: vec![0.0; buckets],
77            m2: vec![0.0; buckets],
78            last: None,
79        })
80    }
81
82    /// Configured `(buckets, utc_offset_minutes)`.
83    pub const fn params(&self) -> (usize, i32) {
84        (self.buckets, self.utc_offset_minutes)
85    }
86
87    /// How many values every emitted profile carries -- the bucket count fixed at construction.
88    ///
89    /// Fixed for the lifetime of the indicator, so a caller can size a
90    /// buffer once instead of guessing.
91    pub const fn width(&self) -> usize {
92        self.buckets
93    }
94
95    /// Most recent profile if at least one return has been recorded.
96    pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> {
97        self.last.as_ref()
98    }
99
100    fn bucket_of(&self, minute_of_day: u32) -> usize {
101        let raw = (minute_of_day as usize * self.buckets) / 1440;
102        raw.min(self.buckets - 1)
103    }
104
105    fn snapshot(&self) -> IntradayVolatilityProfileOutput {
106        let bins = self
107            .count
108            .iter()
109            .zip(&self.m2)
110            .map(|(n, m2)| {
111                if *n >= 2 {
112                    (m2 / (*n - 1) as f64).sqrt()
113                } else {
114                    0.0
115                }
116            })
117            .collect();
118        IntradayVolatilityProfileOutput { bins }
119    }
120}
121
122impl Indicator for IntradayVolatilityProfile {
123    type Input = Candle;
124    type Output = IntradayVolatilityProfileOutput;
125
126    #[inline]
127    fn update(&mut self, candle: Candle) -> Option<IntradayVolatilityProfileOutput> {
128        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
129        let result = if let Some(prev) = self.prev_close {
130            let ret = if prev == 0.0 {
131                0.0
132            } else {
133                candle.close / prev - 1.0
134            };
135            let bucket = self.bucket_of(civil.minute_of_day());
136            self.count[bucket] += 1;
137            let delta = ret - self.mean[bucket];
138            self.mean[bucket] += delta / self.count[bucket] as f64;
139            let delta2 = ret - self.mean[bucket];
140            self.m2[bucket] += delta * delta2;
141            let out = self.snapshot();
142            self.last = Some(out.clone());
143            Some(out)
144        } else {
145            None
146        };
147        self.prev_close = Some(candle.close);
148        result
149    }
150
151    fn reset(&mut self) {
152        self.prev_close = None;
153        self.count.iter_mut().for_each(|x| *x = 0);
154        self.mean.iter_mut().for_each(|x| *x = 0.0);
155        self.m2.iter_mut().for_each(|x| *x = 0.0);
156        self.last = None;
157    }
158
159    #[inline]
160    fn warmup_period(&self) -> usize {
161        2
162    }
163
164    #[inline]
165    fn is_ready(&self) -> bool {
166        self.last.is_some()
167    }
168
169    #[inline]
170    fn name(&self) -> &'static str {
171        "IntradayVolatilityProfile"
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn width_matches_the_emitted_payload() {
181        // The point of `width` is that a caller can size a buffer before
182        // the first value arrives, so it has to equal what update emits.
183        let mut ind = IntradayVolatilityProfile::new(12, 0).unwrap();
184        let width = ind.width();
185        let mut emitted = 0;
186        for i in 0..40 {
187            #[allow(clippy::cast_precision_loss)]
188            let step = i as f64;
189            let price = 100.0 + step;
190            let candle =
191                Candle::new(price, price + 1.0, price - 1.0, price, 10.0, i * 3_600_000).unwrap();
192            if let Some(out) = ind.update(candle) {
193                assert_eq!(out.bins.len(), width);
194                emitted += 1;
195            }
196        }
197        assert!(emitted > 0, "the fixture must clear warmup");
198    }
199    use crate::traits::BatchExt;
200    use approx::assert_relative_eq;
201
202    const HOUR: i64 = 3_600_000;
203    const DAY: i64 = 24 * HOUR;
204
205    fn c(close: f64, ts: i64) -> Candle {
206        Candle::new(close, close, close, close, 1.0, ts).unwrap()
207    }
208
209    #[test]
210    fn rejects_zero_buckets() {
211        assert!(matches!(
212            IntradayVolatilityProfile::new(0, 0),
213            Err(Error::PeriodZero)
214        ));
215    }
216
217    #[test]
218    fn metadata_and_accessors() {
219        let prof = IntradayVolatilityProfile::new(24, 90).unwrap();
220        assert_eq!(prof.params(), (24, 90));
221        assert_eq!(prof.name(), "IntradayVolatilityProfile");
222        assert_eq!(prof.warmup_period(), 2);
223        assert!(!prof.is_ready());
224        assert!(prof.value().is_none());
225    }
226
227    #[test]
228    fn single_sample_bucket_has_zero_vol() {
229        let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
230        assert!(prof.update(c(100.0, 0)).is_none());
231        let out = prof.update(c(101.0, HOUR)).unwrap();
232        assert_eq!(out.bins.len(), 24);
233        assert_relative_eq!(out.bins[1], 0.0); // only one sample in bucket 1
234        assert!(prof.is_ready());
235    }
236
237    #[test]
238    fn std_matches_manual_two_samples() {
239        let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
240        prof.update(c(100.0, 0)); // 00:00
241        prof.update(c(101.0, HOUR)); // 01:00 r=0.01 into bucket 1
242                                     // Next day 01:00, r2 = 0.03 into bucket 1.
243        let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
244        // sample std of {0.01, 0.03} = sqrt(((.01-.02)^2+(.03-.02)^2)/1) = 0.01414..
245        let mean = 0.02;
246        let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt();
247        assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9);
248    }
249
250    #[test]
251    fn zero_prev_close_uses_zero_return() {
252        let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap();
253        prof.update(c(0.0, 0));
254        let out = prof.update(c(5.0, HOUR)).unwrap();
255        assert_relative_eq!(out.bins[0], 0.0);
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
261        prof.update(c(100.0, 0));
262        prof.update(c(101.0, HOUR));
263        prof.reset();
264        assert!(!prof.is_ready());
265        assert!(prof.value().is_none());
266        assert!(prof.update(c(100.0, DAY)).is_none());
267    }
268
269    #[test]
270    fn batch_equals_streaming() {
271        let candles: Vec<Candle> = (0..50)
272            .map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR))
273            .collect();
274        let mut a = IntradayVolatilityProfile::new(12, 0).unwrap();
275        let mut b = IntradayVolatilityProfile::new(12, 0).unwrap();
276        assert_eq!(
277            a.batch(&candles),
278            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
279        );
280    }
281}