wickra_core/indicators/
intraday_volatility_profile.rs1use crate::calendar::civil_from_timestamp;
4use crate::error::{Error, Result};
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone, PartialEq)]
14pub struct IntradayVolatilityProfileOutput {
15 pub bins: Vec<f64>,
17}
18
19#[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 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 pub const fn params(&self) -> (usize, i32) {
84 (self.buckets, self.utc_offset_minutes)
85 }
86
87 pub const fn width(&self) -> usize {
92 self.buckets
93 }
94
95 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 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); 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)); prof.update(c(101.0, HOUR)); let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
244 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}