Skip to main content

hypercall_sdk_types/
expiry_times.rs

1//! Per-underlying expiry time-of-day configuration.
2//!
3//! Option symbols encode expiry as a YYYYMMDD date only; the time of day an
4//! instrument expires is policy. This module is the single authority for
5//! resolving that policy: a global default (08:00 UTC, the Deribit
6//! convention) plus per-underlying overrides (e.g. SPCX expires 20:00 UTC,
7//! 4 PM ET).
8//!
9//! The resolved expiry timestamp is consensus-critical: it is embedded in the
10//! Create2 salt of on-chain option tokens (see `option_token_address`).
11//! Changing an underlying's expiry time while it has listed instruments
12//! changes those instruments' derived timestamps and token addresses. Treat
13//! the configured value as immutable once instruments exist.
14//!
15//! The process-wide configuration is installed once at startup via
16//! [`install_expiry_times`]. Binaries that never install (tools, tests)
17//! resolve everything to the 08:00 UTC default.
18
19use std::collections::HashMap;
20use std::sync::OnceLock;
21
22/// Default expiry time of day: 08:00 UTC (Deribit convention).
23pub const DEFAULT_EXPIRY_TIME: ExpiryTime = ExpiryTime { hour: 8, minute: 0 };
24
25/// A UTC time of day at which instruments expire.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct ExpiryTime {
28    /// Hour in UTC, 0-23.
29    pub hour: u32,
30    /// Minute, 0-59.
31    pub minute: u32,
32}
33
34impl ExpiryTime {
35    /// Construct a validated expiry time.
36    pub fn new(hour: u32, minute: u32) -> Result<Self, ExpiryTimeParseError> {
37        if hour > 23 || minute > 59 {
38            return Err(ExpiryTimeParseError::OutOfRange { hour, minute });
39        }
40        Ok(Self { hour, minute })
41    }
42
43    /// Parse an `"HH:MM"` string (e.g. `"08:00"`, `"20:00"`).
44    pub fn parse(value: &str) -> Result<Self, ExpiryTimeParseError> {
45        let (hour_str, minute_str) =
46            value
47                .split_once(':')
48                .ok_or_else(|| ExpiryTimeParseError::InvalidFormat {
49                    value: value.to_string(),
50                })?;
51        let hour: u32 = hour_str
52            .parse()
53            .map_err(|_| ExpiryTimeParseError::InvalidFormat {
54                value: value.to_string(),
55            })?;
56        let minute: u32 = minute_str
57            .parse()
58            .map_err(|_| ExpiryTimeParseError::InvalidFormat {
59                value: value.to_string(),
60            })?;
61        Self::new(hour, minute)
62    }
63}
64
65/// Error returned when parsing an expiry time string.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ExpiryTimeParseError {
68    /// Input is not in `"HH:MM"` form.
69    InvalidFormat { value: String },
70    /// Components parsed but are not a valid time of day.
71    OutOfRange { hour: u32, minute: u32 },
72}
73
74impl std::fmt::Display for ExpiryTimeParseError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::InvalidFormat { value } => {
78                write!(f, "Invalid expiry time '{}': expected HH:MM", value)
79            }
80            Self::OutOfRange { hour, minute } => {
81                write!(
82                    f,
83                    "Invalid expiry time {}:{:02}: out of range",
84                    hour, minute
85                )
86            }
87        }
88    }
89}
90
91impl std::error::Error for ExpiryTimeParseError {}
92
93/// Process-wide expiry time policy: a default plus per-underlying overrides.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ExpiryTimes {
96    default: ExpiryTime,
97    overrides: HashMap<String, ExpiryTime>,
98}
99
100impl Default for ExpiryTimes {
101    fn default() -> Self {
102        Self {
103            default: DEFAULT_EXPIRY_TIME,
104            overrides: HashMap::new(),
105        }
106    }
107}
108
109impl ExpiryTimes {
110    /// Build a policy from a default time and per-underlying overrides.
111    pub fn new(default: ExpiryTime, overrides: HashMap<String, ExpiryTime>) -> Self {
112        Self { default, overrides }
113    }
114
115    /// Resolve the expiry time of day for an underlying.
116    pub fn for_underlying(&self, underlying: &str) -> ExpiryTime {
117        self.overrides
118            .get(underlying)
119            .copied()
120            .unwrap_or(self.default)
121    }
122}
123
124static EXPIRY_TIMES: OnceLock<ExpiryTimes> = OnceLock::new();
125
126/// Error returned when installing the process-wide expiry times fails.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ExpiryTimesInstallError {
129    /// The configuration that was already installed.
130    pub existing: ExpiryTimes,
131}
132
133impl std::fmt::Display for ExpiryTimesInstallError {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        write!(
136            f,
137            "Expiry times already installed with a different configuration: {:?}",
138            self.existing
139        )
140    }
141}
142
143impl std::error::Error for ExpiryTimesInstallError {}
144
145/// Install the process-wide expiry time policy. Call once at startup, before
146/// any component derives expiry timestamps.
147///
148/// Idempotent for identical configurations; installing a different
149/// configuration after the first install is an error because earlier-derived
150/// timestamps would silently disagree with later ones.
151pub fn install_expiry_times(times: ExpiryTimes) -> Result<(), ExpiryTimesInstallError> {
152    let installed = EXPIRY_TIMES.get_or_init(|| times.clone());
153    if *installed == times {
154        Ok(())
155    } else {
156        Err(ExpiryTimesInstallError {
157            existing: installed.clone(),
158        })
159    }
160}
161
162/// The active expiry time policy: the installed configuration, or the
163/// all-defaults policy (08:00 UTC) if none was installed.
164pub fn expiry_times() -> &'static ExpiryTimes {
165    static DEFAULT: OnceLock<ExpiryTimes> = OnceLock::new();
166    EXPIRY_TIMES.get().unwrap_or_else(|| {
167        DEFAULT.get_or_init(|| {
168            // Expected for tools and tests; a production binary reaching
169            // this forgot its startup install and may derive timestamps
170            // that disagree with the server's catalog configuration.
171            tracing::warn!("ExpiryTimes not installed; using all-defaults policy (08:00 UTC)");
172            ExpiryTimes::default()
173        })
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parse_accepts_hh_mm() {
183        assert_eq!(
184            ExpiryTime::parse("08:00").unwrap(),
185            ExpiryTime { hour: 8, minute: 0 }
186        );
187        assert_eq!(
188            ExpiryTime::parse("20:00").unwrap(),
189            ExpiryTime {
190                hour: 20,
191                minute: 0
192            }
193        );
194        assert_eq!(
195            ExpiryTime::parse("9:30").unwrap(),
196            ExpiryTime {
197                hour: 9,
198                minute: 30
199            }
200        );
201    }
202
203    #[test]
204    fn parse_rejects_invalid_input() {
205        assert!(ExpiryTime::parse("8").is_err());
206        assert!(ExpiryTime::parse("08:00:00").is_err());
207        assert!(ExpiryTime::parse("24:00").is_err());
208        assert!(ExpiryTime::parse("08:60").is_err());
209        assert!(ExpiryTime::parse("ab:cd").is_err());
210        assert!(ExpiryTime::parse("").is_err());
211    }
212
213    #[test]
214    fn overrides_resolve_per_underlying() {
215        let times = ExpiryTimes::new(
216            DEFAULT_EXPIRY_TIME,
217            HashMap::from([(
218                "SPCX".to_string(),
219                ExpiryTime {
220                    hour: 20,
221                    minute: 0,
222                },
223            )]),
224        );
225        assert_eq!(
226            times.for_underlying("SPCX"),
227            ExpiryTime {
228                hour: 20,
229                minute: 0
230            }
231        );
232        assert_eq!(times.for_underlying("BTC"), DEFAULT_EXPIRY_TIME);
233    }
234
235    #[test]
236    fn uninstalled_policy_defaults_to_0800_utc() {
237        // This test must not install: it asserts the fallback path other
238        // tests and tools rely on.
239        assert_eq!(
240            ExpiryTimes::default().for_underlying("BTC"),
241            DEFAULT_EXPIRY_TIME
242        );
243    }
244}