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    /// Construct a Session Range indicator with the given UTC offset (minutes).
86    pub const fn new(utc_offset_minutes: i32) -> Self {
87        Self {
88            utc_offset_minutes,
89            day_key: None,
90            sessions: [Extent::EMPTY; 3],
91            last: None,
92        }
93    }
94
95    /// Configured UTC offset in minutes.
96    pub const fn utc_offset_minutes(&self) -> i32 {
97        self.utc_offset_minutes
98    }
99
100    /// Most recent output if at least one bar has been seen.
101    pub const fn value(&self) -> Option<SessionRangeOutput> {
102        self.last
103    }
104
105    fn snapshot(&self) -> SessionRangeOutput {
106        SessionRangeOutput {
107            asia: self.sessions[0].range(),
108            eu: self.sessions[1].range(),
109            us: self.sessions[2].range(),
110        }
111    }
112}
113
114impl Indicator for SessionRange {
115    type Input = Candle;
116    type Output = SessionRangeOutput;
117
118    fn update(&mut self, candle: Candle) -> Option<SessionRangeOutput> {
119        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
120        let key = (civil.year, civil.month, civil.day);
121        if self.day_key != Some(key) {
122            self.day_key = Some(key);
123            self.sessions = [Extent::EMPTY; 3];
124        }
125        let session = (civil.hour / 8) as usize; // 0 Asia, 1 EU, 2 US
126        self.sessions[session].add(candle);
127        let out = self.snapshot();
128        self.last = Some(out);
129        Some(out)
130    }
131
132    fn reset(&mut self) {
133        self.day_key = None;
134        self.sessions = [Extent::EMPTY; 3];
135        self.last = None;
136    }
137
138    fn warmup_period(&self) -> usize {
139        1
140    }
141
142    fn is_ready(&self) -> bool {
143        self.last.is_some()
144    }
145
146    fn name(&self) -> &'static str {
147        "SessionRange"
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
159    fn c(high: f64, low: f64, ts: i64) -> Candle {
160        let mid = f64::midpoint(high, low);
161        Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
162    }
163
164    #[test]
165    fn metadata_and_accessors() {
166        let sr = SessionRange::new(60);
167        assert_eq!(sr.utc_offset_minutes(), 60);
168        assert_eq!(sr.name(), "SessionRange");
169        assert_eq!(sr.warmup_period(), 1);
170        assert!(!sr.is_ready());
171        assert!(sr.value().is_none());
172    }
173
174    #[test]
175    fn assigns_bars_to_sessions() {
176        let mut sr = SessionRange::new(0);
177        let asia = sr.update(c(104.0, 98.0, 2 * HOUR)).unwrap();
178        assert_relative_eq!(asia.asia, 6.0);
179        assert_relative_eq!(asia.eu, 0.0);
180        assert_relative_eq!(asia.us, 0.0);
181        assert!(sr.is_ready());
182        let eu = sr.update(c(110.0, 100.0, 10 * HOUR)).unwrap();
183        assert_relative_eq!(eu.eu, 10.0);
184        let us = sr.update(c(120.0, 118.0, 20 * HOUR)).unwrap();
185        assert_relative_eq!(us.us, 2.0);
186        assert_relative_eq!(us.asia, 6.0);
187    }
188
189    #[test]
190    fn widens_within_one_session() {
191        let mut sr = SessionRange::new(0);
192        sr.update(c(104.0, 98.0, HOUR));
193        let wider = sr.update(c(106.0, 95.0, 3 * HOUR)).unwrap();
194        assert_relative_eq!(wider.asia, 11.0);
195    }
196
197    #[test]
198    fn resets_sessions_on_new_day() {
199        let mut sr = SessionRange::new(0);
200        sr.update(c(104.0, 98.0, 2 * HOUR));
201        sr.update(c(110.0, 100.0, 10 * HOUR));
202        let next = sr.update(c(101.0, 99.0, (24 + 2) * HOUR)).unwrap();
203        assert_relative_eq!(next.asia, 2.0);
204        assert_relative_eq!(next.eu, 0.0);
205    }
206
207    #[test]
208    fn utc_offset_moves_bar_between_sessions() {
209        // 07:00 UTC is Asia; shifted +120 min it becomes 09:00 -> EU.
210        let mut utc = SessionRange::new(0);
211        let a = utc.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
212        assert_relative_eq!(a.asia, 6.0);
213        assert_relative_eq!(a.eu, 0.0);
214
215        let mut shifted = SessionRange::new(120);
216        let e = shifted.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
217        assert_relative_eq!(e.asia, 0.0);
218        assert_relative_eq!(e.eu, 6.0);
219    }
220
221    #[test]
222    fn reset_clears_state() {
223        let mut sr = SessionRange::new(0);
224        sr.update(c(104.0, 98.0, 2 * HOUR));
225        sr.reset();
226        assert!(!sr.is_ready());
227        assert!(sr.value().is_none());
228    }
229
230    #[test]
231    fn batch_equals_streaming() {
232        let candles: Vec<Candle> = (0..40)
233            .map(|i| {
234                c(
235                    100.0 + f64::from(i % 5),
236                    95.0 - f64::from(i % 3),
237                    i64::from(i) * HOUR,
238                )
239            })
240            .collect();
241        let mut a = SessionRange::new(0);
242        let mut b = SessionRange::new(0);
243        assert_eq!(
244            a.batch(&candles),
245            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
246        );
247    }
248}