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> {
56 if buckets == 0 {
57 return Err(Error::PeriodZero);
58 }
59 Ok(Self {
60 buckets,
61 utc_offset_minutes,
62 prev_close: None,
63 count: vec![0; buckets],
64 mean: vec![0.0; buckets],
65 m2: vec![0.0; buckets],
66 last: None,
67 })
68 }
69
70 pub const fn params(&self) -> (usize, i32) {
72 (self.buckets, self.utc_offset_minutes)
73 }
74
75 pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> {
77 self.last.as_ref()
78 }
79
80 fn bucket_of(&self, minute_of_day: u32) -> usize {
81 let raw = (minute_of_day as usize * self.buckets) / 1440;
82 raw.min(self.buckets - 1)
83 }
84
85 fn snapshot(&self) -> IntradayVolatilityProfileOutput {
86 let bins = self
87 .count
88 .iter()
89 .zip(&self.m2)
90 .map(|(n, m2)| {
91 if *n >= 2 {
92 (m2 / (*n - 1) as f64).sqrt()
93 } else {
94 0.0
95 }
96 })
97 .collect();
98 IntradayVolatilityProfileOutput { bins }
99 }
100}
101
102impl Indicator for IntradayVolatilityProfile {
103 type Input = Candle;
104 type Output = IntradayVolatilityProfileOutput;
105
106 fn update(&mut self, candle: Candle) -> Option<IntradayVolatilityProfileOutput> {
107 let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
108 let result = if let Some(prev) = self.prev_close {
109 let ret = if prev == 0.0 {
110 0.0
111 } else {
112 candle.close / prev - 1.0
113 };
114 let bucket = self.bucket_of(civil.minute_of_day());
115 self.count[bucket] += 1;
116 let delta = ret - self.mean[bucket];
117 self.mean[bucket] += delta / self.count[bucket] as f64;
118 let delta2 = ret - self.mean[bucket];
119 self.m2[bucket] += delta * delta2;
120 let out = self.snapshot();
121 self.last = Some(out.clone());
122 Some(out)
123 } else {
124 None
125 };
126 self.prev_close = Some(candle.close);
127 result
128 }
129
130 fn reset(&mut self) {
131 self.prev_close = None;
132 self.count.iter_mut().for_each(|x| *x = 0);
133 self.mean.iter_mut().for_each(|x| *x = 0.0);
134 self.m2.iter_mut().for_each(|x| *x = 0.0);
135 self.last = None;
136 }
137
138 fn warmup_period(&self) -> usize {
139 2
140 }
141
142 fn is_ready(&self) -> bool {
143 self.last.is_some()
144 }
145
146 fn name(&self) -> &'static str {
147 "IntradayVolatilityProfile"
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::traits::BatchExt;
155 use approx::assert_relative_eq;
156
157 const HOUR: i64 = 3_600_000;
158 const DAY: i64 = 24 * HOUR;
159
160 fn c(close: f64, ts: i64) -> Candle {
161 Candle::new(close, close, close, close, 1.0, ts).unwrap()
162 }
163
164 #[test]
165 fn rejects_zero_buckets() {
166 assert!(matches!(
167 IntradayVolatilityProfile::new(0, 0),
168 Err(Error::PeriodZero)
169 ));
170 }
171
172 #[test]
173 fn metadata_and_accessors() {
174 let prof = IntradayVolatilityProfile::new(24, 90).unwrap();
175 assert_eq!(prof.params(), (24, 90));
176 assert_eq!(prof.name(), "IntradayVolatilityProfile");
177 assert_eq!(prof.warmup_period(), 2);
178 assert!(!prof.is_ready());
179 assert!(prof.value().is_none());
180 }
181
182 #[test]
183 fn single_sample_bucket_has_zero_vol() {
184 let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
185 assert!(prof.update(c(100.0, 0)).is_none());
186 let out = prof.update(c(101.0, HOUR)).unwrap();
187 assert_eq!(out.bins.len(), 24);
188 assert_relative_eq!(out.bins[1], 0.0); assert!(prof.is_ready());
190 }
191
192 #[test]
193 fn std_matches_manual_two_samples() {
194 let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
195 prof.update(c(100.0, 0)); prof.update(c(101.0, HOUR)); let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
199 let mean = 0.02;
201 let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt();
202 assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9);
203 }
204
205 #[test]
206 fn zero_prev_close_uses_zero_return() {
207 let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap();
208 prof.update(c(0.0, 0));
209 let out = prof.update(c(5.0, HOUR)).unwrap();
210 assert_relative_eq!(out.bins[0], 0.0);
211 }
212
213 #[test]
214 fn reset_clears_state() {
215 let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
216 prof.update(c(100.0, 0));
217 prof.update(c(101.0, HOUR));
218 prof.reset();
219 assert!(!prof.is_ready());
220 assert!(prof.value().is_none());
221 assert!(prof.update(c(100.0, DAY)).is_none());
222 }
223
224 #[test]
225 fn batch_equals_streaming() {
226 let candles: Vec<Candle> = (0..50)
227 .map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR))
228 .collect();
229 let mut a = IntradayVolatilityProfile::new(12, 0).unwrap();
230 let mut b = IntradayVolatilityProfile::new(12, 0).unwrap();
231 assert_eq!(
232 a.batch(&candles),
233 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
234 );
235 }
236}