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 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
41// Built-in defaults applied when a caller does not supply a `DatabentoDecodeConfig`
42static 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
47// New York wall-clock time applied to OPRA options by default (16:00, the regular close)
48const fn opra_default_time() -> Time {
49    Time::constant(16, 0, 0, 0)
50}
51
52/// Rule for reinterpreting a dataset's date-level (midnight-UTC) option expiration timestamps.
53#[derive(Clone, Debug)]
54pub struct OptionExpirationRule {
55    /// Exchange-local timezone the wall-clock times are expressed in.
56    pub timezone: TimeZone,
57    /// Wall-clock expiration time applied when no per-underlying override matches.
58    pub default_time: Time,
59    /// Per-underlying wall-clock overrides, keyed by underlying symbol.
60    pub overrides: AHashMap<Ustr, Time>,
61}
62
63impl OptionExpirationRule {
64    /// Creates the default OPRA rule: 16:00 `America/New_York`, no per-underlying overrides.
65    #[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/// Dataset-specific configuration applied while decoding Databento definitions.
83///
84/// The configuration is keyed by [`dbn::Dataset`] so per-dataset parsing rules scale without
85/// changing decode function signatures: adding behavior for another dataset is a new map entry.
86#[derive(Clone, Debug)]
87pub struct DatabentoDecodeConfig {
88    /// Per-dataset option expiration correction rules.
89    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/// Returns a corrected option `expiration` for datasets with date-level (midnight-UTC) timestamps.
101///
102/// When `dataset` has an [`OptionExpirationRule`] in `config` and `expiration` falls exactly on midnight
103/// UTC, the timestamp is reinterpreted at the rule's wall-clock time (the per-underlying override if
104/// one matches, otherwise the rule default) in the rule's timezone. Datasets without a rule, and any
105/// expiration already carrying an intraday time, are returned unchanged. A `config` of `None` uses
106/// the built-in defaults (OPRA corrected to 16:00 New York), so the correction is on by default.
107#[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    // Only correct date-level timestamps (exact midnight UTC); leave any intraday time untouched,
124    // so the correction self-disables should the dataset ever supply real expiration times.
125    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; // 2026-06-29 00:00 UTC
154    const EDT_1600_ET: u64 = 1_782_763_200_000_000_000; // 2026-06-29 16:00 ET (20:00 UTC)
155    const EST_MIDNIGHT_UTC: u64 = 1_768_521_600_000_000_000; // 2026-01-16 00:00 UTC
156    const EST_1600_ET: u64 = 1_768_597_200_000_000_000; // 2026-01-16 16:00 ET (21:00 UTC)
157    const EDT_0930_ET: u64 = 1_782_739_800_000_000_000; // 2026-06-29 09:30 ET (13:30 UTC)
158    const INTRADAY_UTC: u64 = 1_789_738_200_000_000_000; // 2026-09-18 13:30 UTC (non-midnight)
159
160    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        // A custom config that omits a rule for OPRA disables the correction for that dataset.
253        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}