Skip to main content

wickra_core/indicators/
session_range.rs

1//! Session Range — the high-minus-low range accumulated within each of the
2//! three canonical trading sessions (Asia / EU / US) of the current day.
3
4use crate::calendar::civil_from_timestamp;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Session Range output: the current day's range within each session.
9///
10/// A session with no bars yet reports `0.0`. All three reset at the local day
11/// boundary.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct SessionRangeOutput {
14    /// High − low within the Asia session (local hours `00:00..08:00`).
15    pub asia: f64,
16    /// High − low within the EU session (local hours `08:00..16:00`).
17    pub eu: f64,
18    /// High − low within the US session (local hours `16:00..24:00`).
19    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/// Per-session high-low range, keyed off the wall-clock hour of
53/// [`Candle::timestamp`](crate::Candle).
54///
55/// The local day (after shifting by `utc_offset_minutes`) is split into three
56/// eight-hour sessions: **Asia** `00:00..08:00`, **EU** `08:00..16:00`, **US**
57/// `16:00..24:00`. Each session accumulates its own high / low; the reported
58/// range is `high - low`, or `0.0` before that session has seen a bar. All three
59/// re-anchor automatically at the day boundary.
60///
61/// # Example
62///
63/// ```
64/// use wickra_core::{Candle, Indicator, SessionRange};
65///
66/// let hour = 3_600_000;
67/// let mut sr = SessionRange::new(0);
68/// // 02:00 UTC — Asia session.
69/// sr.update(Candle::new(100.0, 104.0, 98.0, 101.0, 1.0, 2 * hour).unwrap());
70/// // 10:00 UTC — EU session.
71/// let v = sr.update(Candle::new(101.0, 110.0, 100.0, 109.0, 1.0, 10 * hour).unwrap()).unwrap();
72/// assert_eq!(v.asia, 6.0);
73/// assert_eq!(v.eu, 10.0);
74/// assert_eq!(v.us, 0.0);
75/// ```
76#[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    ///
86    /// The offset is a constant and does not follow daylight saving: for a
87    /// venue that observes it, one value is correct for part of the year and an
88    /// hour out for the rest, which shifts every session boundary by an hour.
89    /// Either pass the offset in force for the span being analysed and keep
90    /// spans that cross a transition apart, or convert the timestamps to the
91    /// venue's wall clock upstream and pass `0`.
92    /// Construct a Session Range indicator with the given UTC offset (minutes).
93    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    /// Configured UTC offset in minutes.
103    pub const fn utc_offset_minutes(&self) -> i32 {
104        self.utc_offset_minutes
105    }
106
107    /// Most recent output if at least one bar has been seen.
108    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; // 0 Asia, 1 EU, 2 US
134        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        // 07:00 UTC is Asia; shifted +120 min it becomes 09:00 -> EU.
221        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}