Skip to main content

wickra_core/indicators/
overnight_gap.rs

1//! Overnight Gap — the return from the previous session's close to the current
2//! session's open, detected automatically at each day boundary.
3
4use crate::calendar::civil_from_timestamp;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Close-to-open overnight gap as a simple return.
9///
10/// At every local day boundary the indicator computes
11/// `open / previous_close - 1`, where `previous_close` is the close of the last
12/// bar of the prior session and `open` is the open of the first bar of the new
13/// session. The value holds for the rest of the session until the next boundary.
14/// The boundary is the wall-clock day of [`Candle::timestamp`](crate::Candle)
15/// shifted by `utc_offset_minutes`. The first session yields no gap (there is no
16/// prior close to compare against).
17///
18/// # Example
19///
20/// ```
21/// use wickra_core::{Candle, Indicator, OvernightGap};
22///
23/// let hour = 3_600_000;
24/// let mut gap = OvernightGap::new(0);
25/// // Day 1 closes at 100.
26/// assert!(gap.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
27/// // Day 2 opens at 105 -> gap = 105 / 100 - 1 = 0.05.
28/// let g = gap.update(Candle::new(105.0, 106.0, 104.0, 105.5, 1.0, 24 * hour).unwrap()).unwrap();
29/// assert!((g - 0.05).abs() < 1e-9);
30/// ```
31#[derive(Debug, Clone)]
32pub struct OvernightGap {
33    utc_offset_minutes: i32,
34    day_key: Option<(i64, u32, u32)>,
35    last_close: Option<f64>,
36    gap: Option<f64>,
37}
38
39impl OvernightGap {
40    ///
41    /// The offset is a constant and does not follow daylight saving: for a
42    /// venue that observes it, one value is correct for part of the year and an
43    /// hour out for the rest, which shifts every session boundary by an hour.
44    /// Either pass the offset in force for the span being analysed and keep
45    /// spans that cross a transition apart, or convert the timestamps to the
46    /// venue's wall clock upstream and pass `0`.
47    /// Construct an Overnight Gap indicator with the given UTC offset (minutes).
48    pub const fn new(utc_offset_minutes: i32) -> Self {
49        Self {
50            utc_offset_minutes,
51            day_key: None,
52            last_close: None,
53            gap: None,
54        }
55    }
56
57    /// Configured UTC offset in minutes.
58    pub const fn utc_offset_minutes(&self) -> i32 {
59        self.utc_offset_minutes
60    }
61
62    /// Most recent overnight gap if at least one day boundary has been crossed.
63    pub const fn value(&self) -> Option<f64> {
64        self.gap
65    }
66}
67
68impl Indicator for OvernightGap {
69    type Input = Candle;
70    type Output = f64;
71
72    #[inline]
73    fn update(&mut self, candle: Candle) -> Option<f64> {
74        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
75        let key = (civil.year, civil.month, civil.day);
76        if self.day_key != Some(key) {
77            if let Some(prev_close) = self.last_close {
78                self.gap = Some(if prev_close == 0.0 {
79                    0.0
80                } else {
81                    candle.open / prev_close - 1.0
82                });
83            }
84            self.day_key = Some(key);
85        }
86        self.last_close = Some(candle.close);
87        self.gap
88    }
89
90    fn reset(&mut self) {
91        self.day_key = None;
92        self.last_close = None;
93        self.gap = None;
94    }
95
96    #[inline]
97    fn warmup_period(&self) -> usize {
98        2
99    }
100
101    #[inline]
102    fn is_ready(&self) -> bool {
103        self.gap.is_some()
104    }
105
106    #[inline]
107    fn name(&self) -> &'static str {
108        "OvernightGap"
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::traits::BatchExt;
116    use approx::assert_relative_eq;
117
118    const HOUR: i64 = 3_600_000;
119
120    fn c(open: f64, close: f64, ts: i64) -> Candle {
121        let high = open.max(close);
122        let low = open.min(close);
123        Candle::new(open, high, low, close, 1.0, ts).unwrap()
124    }
125
126    #[test]
127    fn metadata_and_accessors() {
128        let gap = OvernightGap::new(330);
129        assert_eq!(gap.utc_offset_minutes(), 330);
130        assert_eq!(gap.name(), "OvernightGap");
131        assert_eq!(gap.warmup_period(), 2);
132        assert!(!gap.is_ready());
133        assert!(gap.value().is_none());
134    }
135
136    #[test]
137    fn first_session_has_no_gap() {
138        let mut gap = OvernightGap::new(0);
139        assert!(gap.update(c(99.0, 100.0, 0)).is_none());
140        // Same day, still no gap.
141        assert!(gap.update(c(100.0, 101.0, HOUR)).is_none());
142        assert!(!gap.is_ready());
143    }
144
145    #[test]
146    fn computes_gap_at_day_boundary() {
147        let mut gap = OvernightGap::new(0);
148        gap.update(c(99.0, 100.0, 0)); // day 1 closes 100
149        let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap();
150        assert_relative_eq!(g, 0.05);
151        assert!(gap.is_ready());
152        // Holds for the rest of the session.
153        let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap();
154        assert_relative_eq!(same, 0.05);
155    }
156
157    #[test]
158    fn negative_gap_down() {
159        let mut gap = OvernightGap::new(0);
160        gap.update(c(99.0, 100.0, 0));
161        let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap();
162        assert_relative_eq!(g, -0.1);
163    }
164
165    #[test]
166    fn zero_prev_close_yields_zero_gap() {
167        let mut gap = OvernightGap::new(0);
168        gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0
169        let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap();
170        assert_relative_eq!(g, 0.0);
171    }
172
173    #[test]
174    fn reset_clears_state() {
175        let mut gap = OvernightGap::new(0);
176        gap.update(c(99.0, 100.0, 0));
177        gap.update(c(105.0, 105.5, 24 * HOUR));
178        gap.reset();
179        assert!(!gap.is_ready());
180        assert!(gap.value().is_none());
181        assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none());
182    }
183
184    #[test]
185    fn batch_equals_streaming() {
186        let candles: Vec<Candle> = (0..50)
187            .map(|i| {
188                c(
189                    100.0 + f64::from(i % 7),
190                    100.0 + f64::from(i % 5),
191                    i64::from(i) * 6 * HOUR,
192                )
193            })
194            .collect();
195        let mut a = OvernightGap::new(0);
196        let mut b = OvernightGap::new(0);
197        assert_eq!(
198            a.batch(&candles),
199            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
200        );
201    }
202}