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    /// Construct a Session High/Low indicator with the given UTC offset (minutes).
56    pub const fn new(utc_offset_minutes: i32) -> Self {
57        Self {
58            utc_offset_minutes,
59            day_key: None,
60            high: f64::NEG_INFINITY,
61            low: f64::INFINITY,
62            last: None,
63        }
64    }
65
66    /// Configured UTC offset in minutes.
67    pub const fn utc_offset_minutes(&self) -> i32 {
68        self.utc_offset_minutes
69    }
70
71    /// Most recent output if at least one bar has been seen.
72    pub const fn value(&self) -> Option<SessionHighLowOutput> {
73        self.last
74    }
75}
76
77impl Indicator for SessionHighLow {
78    type Input = Candle;
79    type Output = SessionHighLowOutput;
80
81    fn update(&mut self, candle: Candle) -> Option<SessionHighLowOutput> {
82        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
83        let key = (civil.year, civil.month, civil.day);
84        if self.day_key == Some(key) {
85            if candle.high > self.high {
86                self.high = candle.high;
87            }
88            if candle.low < self.low {
89                self.low = candle.low;
90            }
91        } else {
92            self.day_key = Some(key);
93            self.high = candle.high;
94            self.low = candle.low;
95        }
96        let out = SessionHighLowOutput {
97            high: self.high,
98            low: self.low,
99        };
100        self.last = Some(out);
101        Some(out)
102    }
103
104    fn reset(&mut self) {
105        self.day_key = None;
106        self.high = f64::NEG_INFINITY;
107        self.low = f64::INFINITY;
108        self.last = None;
109    }
110
111    fn warmup_period(&self) -> usize {
112        1
113    }
114
115    fn is_ready(&self) -> bool {
116        self.last.is_some()
117    }
118
119    fn name(&self) -> &'static str {
120        "SessionHighLow"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128    use approx::assert_relative_eq;
129
130    const HOUR: i64 = 3_600_000;
131
132    fn c(high: f64, low: f64, ts: i64) -> Candle {
133        let mid = f64::midpoint(high, low);
134        Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
135    }
136
137    #[test]
138    fn metadata_and_accessors() {
139        let shl = SessionHighLow::new(-300);
140        assert_eq!(shl.utc_offset_minutes(), -300);
141        assert_eq!(shl.name(), "SessionHighLow");
142        assert_eq!(shl.warmup_period(), 1);
143        assert!(!shl.is_ready());
144        assert!(shl.value().is_none());
145    }
146
147    #[test]
148    fn tracks_high_low_within_day() {
149        let mut shl = SessionHighLow::new(0);
150        let first = shl.update(c(105.0, 99.0, 0)).unwrap();
151        assert_relative_eq!(first.high, 105.0);
152        assert_relative_eq!(first.low, 99.0);
153        assert!(shl.is_ready());
154        let second = shl.update(c(108.0, 100.0, HOUR)).unwrap();
155        assert_relative_eq!(second.high, 108.0);
156        assert_relative_eq!(second.low, 99.0);
157        // A narrower bar does not shrink the range.
158        let third = shl.update(c(106.0, 101.0, 2 * HOUR)).unwrap();
159        assert_relative_eq!(third.high, 108.0);
160        assert_relative_eq!(third.low, 99.0);
161        // A bar with a lower low extends the range downward (same day).
162        let fourth = shl.update(c(107.0, 95.0, 3 * HOUR)).unwrap();
163        assert_relative_eq!(fourth.high, 108.0);
164        assert_relative_eq!(fourth.low, 95.0);
165    }
166
167    #[test]
168    fn re_anchors_on_new_day() {
169        let mut shl = SessionHighLow::new(0);
170        shl.update(c(105.0, 99.0, 0));
171        shl.update(c(108.0, 100.0, HOUR));
172        let next = shl.update(c(51.0, 49.0, 24 * HOUR)).unwrap();
173        assert_relative_eq!(next.high, 51.0);
174        assert_relative_eq!(next.low, 49.0);
175    }
176
177    #[test]
178    fn utc_offset_shifts_day_boundary() {
179        // Two bars 1h apart straddling UTC midnight. At UTC they are different
180        // days; at +120 min they fall on the same local day.
181        let pre = 23 * HOUR; // 1970-01-01 23:00 UTC
182        let post = 24 * HOUR; // 1970-01-02 00:00 UTC
183        let mut utc = SessionHighLow::new(0);
184        utc.update(c(105.0, 99.0, pre));
185        let rolled = utc.update(c(108.0, 100.0, post)).unwrap();
186        assert_relative_eq!(rolled.high, 108.0);
187        assert_relative_eq!(rolled.low, 100.0); // re-anchored
188
189        let mut shifted = SessionHighLow::new(120);
190        shifted.update(c(105.0, 99.0, pre));
191        let same = shifted.update(c(108.0, 100.0, post)).unwrap();
192        assert_relative_eq!(same.high, 108.0);
193        assert_relative_eq!(same.low, 99.0); // same local day, range kept
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut shl = SessionHighLow::new(0);
199        shl.update(c(105.0, 99.0, 0));
200        shl.reset();
201        assert!(!shl.is_ready());
202        assert!(shl.value().is_none());
203        let after = shl.update(c(60.0, 50.0, HOUR)).unwrap();
204        assert_relative_eq!(after.high, 60.0);
205        assert_relative_eq!(after.low, 50.0);
206    }
207
208    #[test]
209    fn batch_equals_streaming() {
210        let candles: Vec<Candle> = (0..30)
211            .map(|i| {
212                c(
213                    100.0 + f64::from(i),
214                    90.0 + f64::from(i) * 0.5,
215                    i64::from(i) * HOUR,
216                )
217            })
218            .collect();
219        let mut a = SessionHighLow::new(0);
220        let mut b = SessionHighLow::new(0);
221        assert_eq!(
222            a.batch(&candles),
223            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
224        );
225    }
226}