wickra_core/indicators/
session_range.rs1use crate::calendar::civil_from_timestamp;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct SessionRangeOutput {
14 pub asia: f64,
16 pub eu: f64,
18 pub us: f64,
20}
21
22#[derive(Debug, Clone, Copy)]
23struct Extent {
24 high: f64,
25 low: f64,
26}
27
28impl Extent {
29 const EMPTY: Self = Self {
30 high: f64::NEG_INFINITY,
31 low: f64::INFINITY,
32 };
33
34 fn add(&mut self, candle: Candle) {
35 if candle.high > self.high {
36 self.high = candle.high;
37 }
38 if candle.low < self.low {
39 self.low = candle.low;
40 }
41 }
42
43 fn range(self) -> f64 {
44 if self.high >= self.low {
45 self.high - self.low
46 } else {
47 0.0
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
77pub struct SessionRange {
78 utc_offset_minutes: i32,
79 day_key: Option<(i64, u32, u32)>,
80 sessions: [Extent; 3],
81 last: Option<SessionRangeOutput>,
82}
83
84impl SessionRange {
85 pub const fn new(utc_offset_minutes: i32) -> Self {
94 Self {
95 utc_offset_minutes,
96 day_key: None,
97 sessions: [Extent::EMPTY; 3],
98 last: None,
99 }
100 }
101
102 pub const fn utc_offset_minutes(&self) -> i32 {
104 self.utc_offset_minutes
105 }
106
107 pub const fn value(&self) -> Option<SessionRangeOutput> {
109 self.last
110 }
111
112 fn snapshot(&self) -> SessionRangeOutput {
113 SessionRangeOutput {
114 asia: self.sessions[0].range(),
115 eu: self.sessions[1].range(),
116 us: self.sessions[2].range(),
117 }
118 }
119}
120
121impl Indicator for SessionRange {
122 type Input = Candle;
123 type Output = SessionRangeOutput;
124
125 #[inline]
126 fn update(&mut self, candle: Candle) -> Option<SessionRangeOutput> {
127 let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
128 let key = (civil.year, civil.month, civil.day);
129 if self.day_key != Some(key) {
130 self.day_key = Some(key);
131 self.sessions = [Extent::EMPTY; 3];
132 }
133 let session = (civil.hour / 8) as usize; self.sessions[session].add(candle);
135 let out = self.snapshot();
136 self.last = Some(out);
137 Some(out)
138 }
139
140 fn reset(&mut self) {
141 self.day_key = None;
142 self.sessions = [Extent::EMPTY; 3];
143 self.last = None;
144 }
145
146 #[inline]
147 fn warmup_period(&self) -> usize {
148 1
149 }
150
151 #[inline]
152 fn is_ready(&self) -> bool {
153 self.last.is_some()
154 }
155
156 #[inline]
157 fn name(&self) -> &'static str {
158 "SessionRange"
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::traits::BatchExt;
166 use approx::assert_relative_eq;
167
168 const HOUR: i64 = 3_600_000;
169
170 fn c(high: f64, low: f64, ts: i64) -> Candle {
171 let mid = f64::midpoint(high, low);
172 Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
173 }
174
175 #[test]
176 fn metadata_and_accessors() {
177 let sr = SessionRange::new(60);
178 assert_eq!(sr.utc_offset_minutes(), 60);
179 assert_eq!(sr.name(), "SessionRange");
180 assert_eq!(sr.warmup_period(), 1);
181 assert!(!sr.is_ready());
182 assert!(sr.value().is_none());
183 }
184
185 #[test]
186 fn assigns_bars_to_sessions() {
187 let mut sr = SessionRange::new(0);
188 let asia = sr.update(c(104.0, 98.0, 2 * HOUR)).unwrap();
189 assert_relative_eq!(asia.asia, 6.0);
190 assert_relative_eq!(asia.eu, 0.0);
191 assert_relative_eq!(asia.us, 0.0);
192 assert!(sr.is_ready());
193 let eu = sr.update(c(110.0, 100.0, 10 * HOUR)).unwrap();
194 assert_relative_eq!(eu.eu, 10.0);
195 let us = sr.update(c(120.0, 118.0, 20 * HOUR)).unwrap();
196 assert_relative_eq!(us.us, 2.0);
197 assert_relative_eq!(us.asia, 6.0);
198 }
199
200 #[test]
201 fn widens_within_one_session() {
202 let mut sr = SessionRange::new(0);
203 sr.update(c(104.0, 98.0, HOUR));
204 let wider = sr.update(c(106.0, 95.0, 3 * HOUR)).unwrap();
205 assert_relative_eq!(wider.asia, 11.0);
206 }
207
208 #[test]
209 fn resets_sessions_on_new_day() {
210 let mut sr = SessionRange::new(0);
211 sr.update(c(104.0, 98.0, 2 * HOUR));
212 sr.update(c(110.0, 100.0, 10 * HOUR));
213 let next = sr.update(c(101.0, 99.0, (24 + 2) * HOUR)).unwrap();
214 assert_relative_eq!(next.asia, 2.0);
215 assert_relative_eq!(next.eu, 0.0);
216 }
217
218 #[test]
219 fn utc_offset_moves_bar_between_sessions() {
220 let mut utc = SessionRange::new(0);
222 let a = utc.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
223 assert_relative_eq!(a.asia, 6.0);
224 assert_relative_eq!(a.eu, 0.0);
225
226 let mut shifted = SessionRange::new(120);
227 let e = shifted.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
228 assert_relative_eq!(e.asia, 0.0);
229 assert_relative_eq!(e.eu, 6.0);
230 }
231
232 #[test]
233 fn reset_clears_state() {
234 let mut sr = SessionRange::new(0);
235 sr.update(c(104.0, 98.0, 2 * HOUR));
236 sr.reset();
237 assert!(!sr.is_ready());
238 assert!(sr.value().is_none());
239 }
240
241 #[test]
242 fn batch_equals_streaming() {
243 let candles: Vec<Candle> = (0..40)
244 .map(|i| {
245 c(
246 100.0 + f64::from(i % 5),
247 95.0 - f64::from(i % 3),
248 i64::from(i) * HOUR,
249 )
250 })
251 .collect();
252 let mut a = SessionRange::new(0);
253 let mut b = SessionRange::new(0);
254 assert_eq!(
255 a.batch(&candles),
256 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
257 );
258 }
259}