Skip to main content

nautilus_databento/decode/
expiration.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Dataset-specific decode configuration and option expiration correction.
17//!
18//! Some Databento datasets supply option `expiration` with date-level precision only: the
19//! time-of-day is zeroed to midnight UTC. OPRA.PILLAR is the motivating case, where an option
20//! expiring at 16:00 New York time arrives stamped at midnight UTC, which is the prior evening in
21//! New York, causing the matching engine to treat the contract as expired before its final trading
22//! session begins. [`DatabentoDecodeConfig`] holds per-dataset [`OptionExpirationRule`]s, keyed by
23//! [`dbn::Dataset`], that reinterpret such midnight-UTC expirations at a configured exchange-local
24//! wall-clock time. Datasets without a rule, and any expiration already carrying an intraday time,
25//! are left untouched.
26
27use 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
36// Built-in defaults applied when a caller does not supply a `DatabentoDecodeConfig`
37static DEFAULT_CONFIG: LazyLock<DatabentoDecodeConfig> =
38    LazyLock::new(DatabentoDecodeConfig::default);
39
40// New York wall-clock time applied to OPRA options by default (16:00, the regular close)
41fn opra_default_time() -> NaiveTime {
42    NaiveTime::from_hms_opt(16, 0, 0).expect("16:00:00 is a valid time")
43}
44
45/// Rule for reinterpreting a dataset's date-level (midnight-UTC) option expiration timestamps.
46#[derive(Clone, Debug)]
47pub struct OptionExpirationRule {
48    /// Exchange-local timezone the wall-clock times are expressed in.
49    pub timezone: Tz,
50    /// Wall-clock expiration time applied when no per-underlying override matches.
51    pub default_time: NaiveTime,
52    /// Per-underlying wall-clock overrides, keyed by underlying symbol.
53    pub overrides: AHashMap<Ustr, NaiveTime>,
54}
55
56impl OptionExpirationRule {
57    /// Creates the default OPRA rule: 16:00 `America/New_York`, no per-underlying overrides.
58    #[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/// Dataset-specific configuration applied while decoding Databento definitions.
76///
77/// The configuration is keyed by [`dbn::Dataset`] so per-dataset parsing rules scale without
78/// changing decode function signatures: adding behavior for another dataset is a new map entry.
79#[derive(Clone, Debug)]
80pub struct DatabentoDecodeConfig {
81    /// Per-dataset option expiration correction rules.
82    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/// Returns a corrected option `expiration` for datasets with date-level (midnight-UTC) timestamps.
94///
95/// When `dataset` has an [`OptionExpirationRule`] in `config` and `expiration` falls exactly on midnight
96/// UTC, the timestamp is reinterpreted at the rule's wall-clock time (the per-underlying override if
97/// one matches, otherwise the rule default) in the rule's timezone. Datasets without a rule, and any
98/// expiration already carrying an intraday time, are returned unchanged. A `config` of `None` uses
99/// the built-in defaults (OPRA corrected to 16:00 New York), so the correction is on by default.
100#[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    // Only correct date-level timestamps (exact midnight UTC); leave any intraday time untouched,
117    // so the correction self-disables should the dataset ever supply real expiration times.
118    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; // 2026-06-29 00:00 UTC
152    const EDT_1600_ET: u64 = 1_782_763_200_000_000_000; // 2026-06-29 16:00 ET (20:00 UTC)
153    const EST_MIDNIGHT_UTC: u64 = 1_768_521_600_000_000_000; // 2026-01-16 00:00 UTC
154    const EST_1600_ET: u64 = 1_768_597_200_000_000_000; // 2026-01-16 16:00 ET (21:00 UTC)
155    const EDT_0930_ET: u64 = 1_782_739_800_000_000_000; // 2026-06-29 09:30 ET (13:30 UTC)
156    const INTRADAY_UTC: u64 = 1_789_738_200_000_000_000; // 2026-09-18 13:30 UTC (non-midnight)
157
158    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        // A custom config that omits a rule for OPRA disables the correction for that dataset.
251        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}