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 {
42 Self {
43 utc_offset_minutes,
44 day_key: None,
45 last_close: None,
46 gap: None,
47 }
48 }
49
50 pub const fn utc_offset_minutes(&self) -> i32 {
52 self.utc_offset_minutes
53 }
54
55 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 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)); 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 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)); 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}