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    /// Construct an Overnight Gap indicator with the given UTC offset (minutes).
41    pub const fn new(utc_offset_minutes: i32) -> Self {
42        Self {
43            utc_offset_minutes,
44            day_key: None,
45            last_close: None,
46            gap: None,
47        }
48    }
49
50    /// Configured UTC offset in minutes.
51    pub const fn utc_offset_minutes(&self) -> i32 {
52        self.utc_offset_minutes
53    }
54
55    /// Most recent overnight gap if at least one day boundary has been crossed.
56    pub const fn value(&self) -> Option<f64> {
57        self.gap
58    }
59}
60
61impl Indicator for OvernightGap {
62    type Input = Candle;
63    type Output = f64;
64
65    fn update(&mut self, candle: Candle) -> Option<f64> {
66        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
67        let key = (civil.year, civil.month, civil.day);
68        if self.day_key != Some(key) {
69            if let Some(prev_close) = self.last_close {
70                self.gap = Some(if prev_close == 0.0 {
71                    0.0
72                } else {
73                    candle.open / prev_close - 1.0
74                });
75            }
76            self.day_key = Some(key);
77        }
78        self.last_close = Some(candle.close);
79        self.gap
80    }
81
82    fn reset(&mut self) {
83        self.day_key = None;
84        self.last_close = None;
85        self.gap = None;
86    }
87
88    fn warmup_period(&self) -> usize {
89        2
90    }
91
92    fn is_ready(&self) -> bool {
93        self.gap.is_some()
94    }
95
96    fn name(&self) -> &'static str {
97        "OvernightGap"
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::traits::BatchExt;
105    use approx::assert_relative_eq;
106
107    const HOUR: i64 = 3_600_000;
108
109    fn c(open: f64, close: f64, ts: i64) -> Candle {
110        let high = open.max(close);
111        let low = open.min(close);
112        Candle::new(open, high, low, close, 1.0, ts).unwrap()
113    }
114
115    #[test]
116    fn metadata_and_accessors() {
117        let gap = OvernightGap::new(330);
118        assert_eq!(gap.utc_offset_minutes(), 330);
119        assert_eq!(gap.name(), "OvernightGap");
120        assert_eq!(gap.warmup_period(), 2);
121        assert!(!gap.is_ready());
122        assert!(gap.value().is_none());
123    }
124
125    #[test]
126    fn first_session_has_no_gap() {
127        let mut gap = OvernightGap::new(0);
128        assert!(gap.update(c(99.0, 100.0, 0)).is_none());
129        // Same day, still no gap.
130        assert!(gap.update(c(100.0, 101.0, HOUR)).is_none());
131        assert!(!gap.is_ready());
132    }
133
134    #[test]
135    fn computes_gap_at_day_boundary() {
136        let mut gap = OvernightGap::new(0);
137        gap.update(c(99.0, 100.0, 0)); // day 1 closes 100
138        let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap();
139        assert_relative_eq!(g, 0.05);
140        assert!(gap.is_ready());
141        // Holds for the rest of the session.
142        let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap();
143        assert_relative_eq!(same, 0.05);
144    }
145
146    #[test]
147    fn negative_gap_down() {
148        let mut gap = OvernightGap::new(0);
149        gap.update(c(99.0, 100.0, 0));
150        let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap();
151        assert_relative_eq!(g, -0.1);
152    }
153
154    #[test]
155    fn zero_prev_close_yields_zero_gap() {
156        let mut gap = OvernightGap::new(0);
157        gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0
158        let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap();
159        assert_relative_eq!(g, 0.0);
160    }
161
162    #[test]
163    fn reset_clears_state() {
164        let mut gap = OvernightGap::new(0);
165        gap.update(c(99.0, 100.0, 0));
166        gap.update(c(105.0, 105.5, 24 * HOUR));
167        gap.reset();
168        assert!(!gap.is_ready());
169        assert!(gap.value().is_none());
170        assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none());
171    }
172
173    #[test]
174    fn batch_equals_streaming() {
175        let candles: Vec<Candle> = (0..50)
176            .map(|i| {
177                c(
178                    100.0 + f64::from(i % 7),
179                    100.0 + f64::from(i % 5),
180                    i64::from(i) * 6 * HOUR,
181                )
182            })
183            .collect();
184        let mut a = OvernightGap::new(0);
185        let mut b = OvernightGap::new(0);
186        assert_eq!(
187            a.batch(&candles),
188            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
189        );
190    }
191}