wickra_core/indicators/
overnight_gap.rs1use crate::calendar::civil_from_timestamp;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[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 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 pub const fn utc_offset_minutes(&self) -> i32 {
59 self.utc_offset_minutes
60 }
61
62 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 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)); 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 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)); 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}