nautilus_databento/decode/
expiration.rs1use std::sync::LazyLock;
28
29use ahash::AHashMap;
30use databento::dbn;
31use jiff::{
32 civil::Time,
33 tz::{AmbiguousOffset, Offset, TimeZone},
34};
35use nautilus_core::{
36 UnixNanos,
37 datetime::{NANOSECONDS_IN_DAY, get_timezone},
38};
39use ustr::Ustr;
40
41static DEFAULT_CONFIG: LazyLock<DatabentoDecodeConfig> =
43 LazyLock::new(DatabentoDecodeConfig::default);
44static NEW_YORK: LazyLock<TimeZone> =
45 LazyLock::new(|| get_timezone("America/New_York").expect("bundled America/New_York timezone"));
46
47const fn opra_default_time() -> Time {
49 Time::constant(16, 0, 0, 0)
50}
51
52#[derive(Clone, Debug)]
54pub struct OptionExpirationRule {
55 pub timezone: TimeZone,
57 pub default_time: Time,
59 pub overrides: AHashMap<Ustr, Time>,
61}
62
63impl OptionExpirationRule {
64 #[must_use]
66 pub fn opra() -> Self {
67 Self {
68 timezone: NEW_YORK.clone(),
69 default_time: opra_default_time(),
70 overrides: AHashMap::new(),
71 }
72 }
73
74 fn time_for(&self, underlying: Ustr) -> Time {
75 self.overrides
76 .get(&underlying)
77 .copied()
78 .unwrap_or(self.default_time)
79 }
80}
81
82#[derive(Clone, Debug)]
87pub struct DatabentoDecodeConfig {
88 pub option_expiration: AHashMap<dbn::Dataset, OptionExpirationRule>,
90}
91
92impl Default for DatabentoDecodeConfig {
93 fn default() -> Self {
94 let mut option_expiration = AHashMap::new();
95 option_expiration.insert(dbn::Dataset::OpraPillar, OptionExpirationRule::opra());
96 Self { option_expiration }
97 }
98}
99
100#[must_use]
108pub fn corrected_option_expiration(
109 expiration: UnixNanos,
110 underlying: Ustr,
111 dataset: Option<dbn::Dataset>,
112 config: Option<&DatabentoDecodeConfig>,
113) -> UnixNanos {
114 let Some(dataset) = dataset else {
115 return expiration;
116 };
117 let config = config.unwrap_or(&DEFAULT_CONFIG);
118 let Some(rule) = config.option_expiration.get(&dataset) else {
119 return expiration;
120 };
121
122 let raw = expiration.as_u64();
123 if raw == 0 || !raw.is_multiple_of(NANOSECONDS_IN_DAY) {
126 return expiration;
127 }
128 let date = Offset::UTC.to_datetime(expiration.to_datetime_utc()).date();
129 let ambiguous = rule
130 .timezone
131 .to_ambiguous_timestamp(date.to_datetime(rule.time_for(underlying)));
132 let corrected = match ambiguous.offset() {
133 AmbiguousOffset::Unambiguous { .. } => ambiguous.unambiguous(),
134 AmbiguousOffset::Fold { .. } => ambiguous.earlier(),
135 AmbiguousOffset::Gap { .. } => return expiration,
136 };
137 corrected
138 .ok()
139 .and_then(|timestamp| u64::try_from(timestamp.as_nanosecond()).ok())
140 .map_or(expiration, UnixNanos::from)
141}
142
143#[cfg(test)]
144mod tests {
145 use databento::dbn;
146 use jiff::civil::Time;
147 use nautilus_core::UnixNanos;
148 use rstest::rstest;
149 use ustr::Ustr;
150
151 use super::{DatabentoDecodeConfig, corrected_option_expiration};
152
153 const EDT_MIDNIGHT_UTC: u64 = 1_782_691_200_000_000_000; const EDT_1600_ET: u64 = 1_782_763_200_000_000_000; const EST_MIDNIGHT_UTC: u64 = 1_768_521_600_000_000_000; const EST_1600_ET: u64 = 1_768_597_200_000_000_000; const EDT_0930_ET: u64 = 1_782_739_800_000_000_000; const INTRADAY_UTC: u64 = 1_789_738_200_000_000_000; fn config_with_opra_override(underlying: &str, time: Time) -> DatabentoDecodeConfig {
161 let mut config = DatabentoDecodeConfig::default();
162 config
163 .option_expiration
164 .get_mut(&dbn::Dataset::OpraPillar)
165 .unwrap()
166 .overrides
167 .insert(Ustr::from(underlying), time);
168 config
169 }
170
171 #[rstest]
172 fn test_opra_midnight_corrected_to_1600_et_during_edt() {
173 let result = corrected_option_expiration(
174 UnixNanos::from(EDT_MIDNIGHT_UTC),
175 Ustr::from("SPX"),
176 Some(dbn::Dataset::OpraPillar),
177 None,
178 );
179 assert_eq!(result.as_u64(), EDT_1600_ET);
180 }
181
182 #[rstest]
183 fn test_opra_midnight_corrected_to_1600_et_during_est() {
184 let result = corrected_option_expiration(
185 UnixNanos::from(EST_MIDNIGHT_UTC),
186 Ustr::from("SPX"),
187 Some(dbn::Dataset::OpraPillar),
188 None,
189 );
190 assert_eq!(result.as_u64(), EST_1600_ET);
191 }
192
193 #[rstest]
194 fn test_opra_override_applied_for_matching_underlying() {
195 let config = config_with_opra_override("XSP", Time::constant(9, 30, 0, 0));
196 let result = corrected_option_expiration(
197 UnixNanos::from(EDT_MIDNIGHT_UTC),
198 Ustr::from("XSP"),
199 Some(dbn::Dataset::OpraPillar),
200 Some(&config),
201 );
202 assert_eq!(result.as_u64(), EDT_0930_ET);
203 }
204
205 #[rstest]
206 fn test_opra_default_used_when_underlying_not_overridden() {
207 let config = config_with_opra_override("XSP", Time::constant(9, 30, 0, 0));
208 let result = corrected_option_expiration(
209 UnixNanos::from(EDT_MIDNIGHT_UTC),
210 Ustr::from("SPX"),
211 Some(dbn::Dataset::OpraPillar),
212 Some(&config),
213 );
214 assert_eq!(result.as_u64(), EDT_1600_ET);
215 }
216
217 #[rstest]
218 fn test_opra_intraday_expiration_passes_through() {
219 let result = corrected_option_expiration(
220 UnixNanos::from(INTRADAY_UTC),
221 Ustr::from("SPX"),
222 Some(dbn::Dataset::OpraPillar),
223 None,
224 );
225 assert_eq!(result.as_u64(), INTRADAY_UTC);
226 }
227
228 #[rstest]
229 fn test_non_opra_midnight_passes_through() {
230 let result = corrected_option_expiration(
231 UnixNanos::from(EDT_MIDNIGHT_UTC),
232 Ustr::from("ESU6"),
233 Some(dbn::Dataset::GlbxMdp3),
234 None,
235 );
236 assert_eq!(result.as_u64(), EDT_MIDNIGHT_UTC);
237 }
238
239 #[rstest]
240 fn test_unknown_dataset_passes_through() {
241 let result = corrected_option_expiration(
242 UnixNanos::from(EDT_MIDNIGHT_UTC),
243 Ustr::from("SPX"),
244 None,
245 None,
246 );
247 assert_eq!(result.as_u64(), EDT_MIDNIGHT_UTC);
248 }
249
250 #[rstest]
251 fn test_dataset_without_rule_passes_through() {
252 let config = DatabentoDecodeConfig {
254 option_expiration: ahash::AHashMap::new(),
255 };
256 let result = corrected_option_expiration(
257 UnixNanos::from(EDT_MIDNIGHT_UTC),
258 Ustr::from("SPX"),
259 Some(dbn::Dataset::OpraPillar),
260 Some(&config),
261 );
262 assert_eq!(result.as_u64(), EDT_MIDNIGHT_UTC);
263 }
264}