nautilus_databento/decode/
expiration.rs1use std::sync::LazyLock;
28
29use ahash::AHashMap;
30use chrono::{DateTime, LocalResult, NaiveTime, TimeZone};
31use chrono_tz::{America::New_York, Tz};
32use databento::dbn;
33use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_DAY};
34use ustr::Ustr;
35
36static DEFAULT_CONFIG: LazyLock<DatabentoDecodeConfig> =
38 LazyLock::new(DatabentoDecodeConfig::default);
39
40fn opra_default_time() -> NaiveTime {
42 NaiveTime::from_hms_opt(16, 0, 0).expect("16:00:00 is a valid time")
43}
44
45#[derive(Clone, Debug)]
47pub struct OptionExpirationRule {
48 pub timezone: Tz,
50 pub default_time: NaiveTime,
52 pub overrides: AHashMap<Ustr, NaiveTime>,
54}
55
56impl OptionExpirationRule {
57 #[must_use]
59 pub fn opra() -> Self {
60 Self {
61 timezone: New_York,
62 default_time: opra_default_time(),
63 overrides: AHashMap::new(),
64 }
65 }
66
67 fn time_for(&self, underlying: Ustr) -> NaiveTime {
68 self.overrides
69 .get(&underlying)
70 .copied()
71 .unwrap_or(self.default_time)
72 }
73}
74
75#[derive(Clone, Debug)]
80pub struct DatabentoDecodeConfig {
81 pub option_expiration: AHashMap<dbn::Dataset, OptionExpirationRule>,
83}
84
85impl Default for DatabentoDecodeConfig {
86 fn default() -> Self {
87 let mut option_expiration = AHashMap::new();
88 option_expiration.insert(dbn::Dataset::OpraPillar, OptionExpirationRule::opra());
89 Self { option_expiration }
90 }
91}
92
93#[must_use]
101pub fn corrected_option_expiration(
102 expiration: UnixNanos,
103 underlying: Ustr,
104 dataset: Option<dbn::Dataset>,
105 config: Option<&DatabentoDecodeConfig>,
106) -> UnixNanos {
107 let Some(dataset) = dataset else {
108 return expiration;
109 };
110 let config = config.unwrap_or(&DEFAULT_CONFIG);
111 let Some(rule) = config.option_expiration.get(&dataset) else {
112 return expiration;
113 };
114
115 let raw = expiration.as_u64();
116 if raw == 0 || !raw.is_multiple_of(NANOSECONDS_IN_DAY) {
119 return expiration;
120 }
121 let Ok(raw) = i64::try_from(raw) else {
122 return expiration;
123 };
124
125 let date = DateTime::from_timestamp_nanos(raw).date_naive();
126 let corrected = match rule
127 .timezone
128 .from_local_datetime(&date.and_time(rule.time_for(underlying)))
129 {
130 LocalResult::Single(dt) => dt,
131 LocalResult::Ambiguous(dt, _) => dt,
132 LocalResult::None => return expiration,
133 };
134
135 match corrected.timestamp_nanos_opt() {
136 Some(ns) if ns >= 0 => UnixNanos::from(ns as u64),
137 _ => expiration,
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use chrono::NaiveTime;
144 use databento::dbn;
145 use nautilus_core::UnixNanos;
146 use rstest::rstest;
147 use ustr::Ustr;
148
149 use super::{DatabentoDecodeConfig, corrected_option_expiration};
150
151 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: NaiveTime) -> DatabentoDecodeConfig {
159 let mut config = DatabentoDecodeConfig::default();
160 config
161 .option_expiration
162 .get_mut(&dbn::Dataset::OpraPillar)
163 .unwrap()
164 .overrides
165 .insert(Ustr::from(underlying), time);
166 config
167 }
168
169 #[rstest]
170 fn test_opra_midnight_corrected_to_1600_et_during_edt() {
171 let result = corrected_option_expiration(
172 UnixNanos::from(EDT_MIDNIGHT_UTC),
173 Ustr::from("SPX"),
174 Some(dbn::Dataset::OpraPillar),
175 None,
176 );
177 assert_eq!(result.as_u64(), EDT_1600_ET);
178 }
179
180 #[rstest]
181 fn test_opra_midnight_corrected_to_1600_et_during_est() {
182 let result = corrected_option_expiration(
183 UnixNanos::from(EST_MIDNIGHT_UTC),
184 Ustr::from("SPX"),
185 Some(dbn::Dataset::OpraPillar),
186 None,
187 );
188 assert_eq!(result.as_u64(), EST_1600_ET);
189 }
190
191 #[rstest]
192 fn test_opra_override_applied_for_matching_underlying() {
193 let config = config_with_opra_override("XSP", NaiveTime::from_hms_opt(9, 30, 0).unwrap());
194 let result = corrected_option_expiration(
195 UnixNanos::from(EDT_MIDNIGHT_UTC),
196 Ustr::from("XSP"),
197 Some(dbn::Dataset::OpraPillar),
198 Some(&config),
199 );
200 assert_eq!(result.as_u64(), EDT_0930_ET);
201 }
202
203 #[rstest]
204 fn test_opra_default_used_when_underlying_not_overridden() {
205 let config = config_with_opra_override("XSP", NaiveTime::from_hms_opt(9, 30, 0).unwrap());
206 let result = corrected_option_expiration(
207 UnixNanos::from(EDT_MIDNIGHT_UTC),
208 Ustr::from("SPX"),
209 Some(dbn::Dataset::OpraPillar),
210 Some(&config),
211 );
212 assert_eq!(result.as_u64(), EDT_1600_ET);
213 }
214
215 #[rstest]
216 fn test_opra_intraday_expiration_passes_through() {
217 let result = corrected_option_expiration(
218 UnixNanos::from(INTRADAY_UTC),
219 Ustr::from("SPX"),
220 Some(dbn::Dataset::OpraPillar),
221 None,
222 );
223 assert_eq!(result.as_u64(), INTRADAY_UTC);
224 }
225
226 #[rstest]
227 fn test_non_opra_midnight_passes_through() {
228 let result = corrected_option_expiration(
229 UnixNanos::from(EDT_MIDNIGHT_UTC),
230 Ustr::from("ESU6"),
231 Some(dbn::Dataset::GlbxMdp3),
232 None,
233 );
234 assert_eq!(result.as_u64(), EDT_MIDNIGHT_UTC);
235 }
236
237 #[rstest]
238 fn test_unknown_dataset_passes_through() {
239 let result = corrected_option_expiration(
240 UnixNanos::from(EDT_MIDNIGHT_UTC),
241 Ustr::from("SPX"),
242 None,
243 None,
244 );
245 assert_eq!(result.as_u64(), EDT_MIDNIGHT_UTC);
246 }
247
248 #[rstest]
249 fn test_dataset_without_rule_passes_through() {
250 let config = DatabentoDecodeConfig {
252 option_expiration: ahash::AHashMap::new(),
253 };
254 let result = corrected_option_expiration(
255 UnixNanos::from(EDT_MIDNIGHT_UTC),
256 Ustr::from("SPX"),
257 Some(dbn::Dataset::OpraPillar),
258 Some(&config),
259 );
260 assert_eq!(result.as_u64(), EDT_MIDNIGHT_UTC);
261 }
262}