Skip to main content

wickra_core/indicators/
session_high_low.rs

1//! Session High/Low — the running high and low of the current calendar-day
2//! session, re-anchored automatically at each day boundary.
3
4use crate::calendar::civil_from_timestamp;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Session High/Low output: the high and low established so far in the current
9/// session.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct SessionHighLowOutput {
12    /// Highest high seen since the current session opened.
13    pub high: f64,
14    /// Lowest low seen since the current session opened.
15    pub low: f64,
16}
17
18/// Running high / low of the current session, keyed off the wall-clock day of
19/// [`Candle::timestamp`](crate::Candle).
20///
21/// Unlike [`crate::OpeningRange`] or [`crate::InitialBalance`], which require the
22/// caller to invoke `reset()` at every session boundary, this indicator detects
23/// the boundary itself: whenever a candle falls on a different local calendar
24/// day (after shifting by `utc_offset_minutes`) the high / low are re-anchored to
25/// that candle. `utc_offset_minutes` lets callers align the day boundary to an
26/// exchange session — `0` for UTC, `-300` for U.S. Eastern standard time.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Candle, Indicator, SessionHighLow};
32///
33/// // One bar per hour; the day rolls over after 24 bars at UTC.
34/// let mut shl = SessionHighLow::new(0);
35/// let hour = 3_600_000;
36/// shl.update(Candle::new(100.0, 105.0, 99.0, 101.0, 1.0, 0).unwrap());
37/// let v = shl.update(Candle::new(101.0, 108.0, 100.0, 107.0, 1.0, hour).unwrap()).unwrap();
38/// assert_eq!(v.high, 108.0);
39/// assert_eq!(v.low, 99.0);
40/// // A bar on the next day re-anchors to that bar alone.
41/// let v = shl.update(Candle::new(50.0, 51.0, 49.0, 50.0, 1.0, 24 * hour).unwrap()).unwrap();
42/// assert_eq!(v.high, 51.0);
43/// assert_eq!(v.low, 49.0);
44/// ```
45#[derive(Debug, Clone)]
46pub struct SessionHighLow {
47    utc_offset_minutes: i32,
48    day_key: Option<(i64, u32, u32)>,
49    high: f64,
50    low: f64,
51    last: Option<SessionHighLowOutput>,
52}
53
54impl SessionHighLow {
55    ///
56    /// The offset is a constant and does not follow daylight saving: for a
57    /// venue that observes it, one value is correct for part of the year and an
58    /// hour out for the rest, which shifts every session boundary by an hour.
59    /// Either pass the offset in force for the span being analysed and keep
60    /// spans that cross a transition apart, or convert the timestamps to the
61    /// venue's wall clock upstream and pass `0`.
62    /// Construct a Session High/Low indicator with the given UTC offset (minutes).
63    pub const fn new(utc_offset_minutes: i32) -> Self {
64        Self {
65            utc_offset_minutes,
66            day_key: None,
67            high: f64::NEG_INFINITY,
68            low: f64::INFINITY,
69            last: None,
70        }
71    }
72
73    /// Configured UTC offset in minutes.
74    pub const fn utc_offset_minutes(&self) -> i32 {
75        self.utc_offset_minutes
76    }
77
78    /// Most recent output if at least one bar has been seen.
79    pub const fn value(&self) -> Option<SessionHighLowOutput> {
80        self.last
81    }
82}
83
84impl Indicator for SessionHighLow {
85    type Input = Candle;
86    type Output = SessionHighLowOutput;
87
88    #[inline]
89    fn update(&mut self, candle: Candle) -> Option<SessionHighLowOutput> {
90        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
91        let key = (civil.year, civil.month, civil.day);
92        if self.day_key == Some(key) {
93            if candle.high > self.high {
94                self.high = candle.high;
95            }
96            if candle.low < self.low {
97                self.low = candle.low;
98            }
99        } else {
100            self.day_key = Some(key);
101            self.high = candle.high;
102            self.low = candle.low;
103        }
104        let out = SessionHighLowOutput {
105            high: self.high,
106            low: self.low,
107        };
108        self.last = Some(out);
109        Some(out)
110    }
111
112    fn reset(&mut self) {
113        self.day_key = None;
114        self.high = f64::NEG_INFINITY;
115        self.low = f64::INFINITY;
116        self.last = None;
117    }
118
119    #[inline]
120    fn warmup_period(&self) -> usize {
121        1
122    }
123
124    #[inline]
125    fn is_ready(&self) -> bool {
126        self.last.is_some()
127    }
128
129    #[inline]
130    fn name(&self) -> &'static str {
131        "SessionHighLow"
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::traits::BatchExt;
139    use approx::assert_relative_eq;
140
141    const HOUR: i64 = 3_600_000;
142
143    fn c(high: f64, low: f64, ts: i64) -> Candle {
144        let mid = f64::midpoint(high, low);
145        Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
146    }
147
148    #[test]
149    fn metadata_and_accessors() {
150        let shl = SessionHighLow::new(-300);
151        assert_eq!(shl.utc_offset_minutes(), -300);
152        assert_eq!(shl.name(), "SessionHighLow");
153        assert_eq!(shl.warmup_period(), 1);
154        assert!(!shl.is_ready());
155        assert!(shl.value().is_none());
156    }
157
158    #[test]
159    fn tracks_high_low_within_day() {
160        let mut shl = SessionHighLow::new(0);
161        let first = shl.update(c(105.0, 99.0, 0)).unwrap();
162        assert_relative_eq!(first.high, 105.0);
163        assert_relative_eq!(first.low, 99.0);
164        assert!(shl.is_ready());
165        let second = shl.update(c(108.0, 100.0, HOUR)).unwrap();
166        assert_relative_eq!(second.high, 108.0);
167        assert_relative_eq!(second.low, 99.0);
168        // A narrower bar does not shrink the range.
169        let third = shl.update(c(106.0, 101.0, 2 * HOUR)).unwrap();
170        assert_relative_eq!(third.high, 108.0);
171        assert_relative_eq!(third.low, 99.0);
172        // A bar with a lower low extends the range downward (same day).
173        let fourth = shl.update(c(107.0, 95.0, 3 * HOUR)).unwrap();
174        assert_relative_eq!(fourth.high, 108.0);
175        assert_relative_eq!(fourth.low, 95.0);
176    }
177
178    #[test]
179    fn re_anchors_on_new_day() {
180        let mut shl = SessionHighLow::new(0);
181        shl.update(c(105.0, 99.0, 0));
182        shl.update(c(108.0, 100.0, HOUR));
183        let next = shl.update(c(51.0, 49.0, 24 * HOUR)).unwrap();
184        assert_relative_eq!(next.high, 51.0);
185        assert_relative_eq!(next.low, 49.0);
186    }
187
188    #[test]
189    fn utc_offset_shifts_day_boundary() {
190        // Two bars 1h apart straddling UTC midnight. At UTC they are different
191        // days; at +120 min they fall on the same local day.
192        let pre = 23 * HOUR; // 1970-01-01 23:00 UTC
193        let post = 24 * HOUR; // 1970-01-02 00:00 UTC
194        let mut utc = SessionHighLow::new(0);
195        utc.update(c(105.0, 99.0, pre));
196        let rolled = utc.update(c(108.0, 100.0, post)).unwrap();
197        assert_relative_eq!(rolled.high, 108.0);
198        assert_relative_eq!(rolled.low, 100.0); // re-anchored
199
200        let mut shifted = SessionHighLow::new(120);
201        shifted.update(c(105.0, 99.0, pre));
202        let same = shifted.update(c(108.0, 100.0, post)).unwrap();
203        assert_relative_eq!(same.high, 108.0);
204        assert_relative_eq!(same.low, 99.0); // same local day, range kept
205    }
206
207    #[test]
208    fn reset_clears_state() {
209        let mut shl = SessionHighLow::new(0);
210        shl.update(c(105.0, 99.0, 0));
211        shl.reset();
212        assert!(!shl.is_ready());
213        assert!(shl.value().is_none());
214        let after = shl.update(c(60.0, 50.0, HOUR)).unwrap();
215        assert_relative_eq!(after.high, 60.0);
216        assert_relative_eq!(after.low, 50.0);
217    }
218
219    #[test]
220    fn batch_equals_streaming() {
221        let candles: Vec<Candle> = (0..30)
222            .map(|i| {
223                c(
224                    100.0 + f64::from(i),
225                    90.0 + f64::from(i) * 0.5,
226                    i64::from(i) * HOUR,
227                )
228            })
229            .collect();
230        let mut a = SessionHighLow::new(0);
231        let mut b = SessionHighLow::new(0);
232        assert_eq!(
233            a.batch(&candles),
234            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
235        );
236    }
237}