use std::{
fmt::{Debug, Display},
hash::Hash,
str::FromStr,
};
use nautilus_core::UnixNanos;
use serde::{Deserialize, Serialize};
use ustr::Ustr;
use crate::{identifiers::Venue, instruments::CryptoOption};
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
)]
pub struct OptionSeriesId {
pub venue: Venue,
pub underlying: Ustr,
pub settlement_currency: Ustr,
pub expiration_ns: UnixNanos,
}
impl OptionSeriesId {
#[must_use]
pub fn new(
venue: Venue,
underlying: Ustr,
settlement_currency: Ustr,
expiration_ns: UnixNanos,
) -> Self {
Self {
venue,
underlying,
settlement_currency,
expiration_ns,
}
}
pub fn from_expiry(
venue: &str,
underlying: &str,
settlement_currency: &str,
date_str: &str,
) -> anyhow::Result<Self> {
let expiration_ns = UnixNanos::from_str(date_str)
.map_err(|e| anyhow::anyhow!("Failed to parse expiry date '{date_str}': {e}"))?;
Ok(Self {
venue: Venue::new(venue),
underlying: Ustr::from(underlying),
settlement_currency: Ustr::from(settlement_currency),
expiration_ns,
})
}
#[must_use]
pub fn to_wire_string(&self) -> String {
format!(
"{}:{}:{}:{}",
self.venue, self.underlying, self.settlement_currency, self.expiration_ns
)
}
#[must_use]
pub fn from_crypto_option(option: &CryptoOption) -> Self {
Self {
venue: option.id.venue,
underlying: option.underlying.code,
settlement_currency: option.settlement_currency.code,
expiration_ns: option.expiration_ns,
}
}
}
impl Display for OptionSeriesId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let dt = self.expiration_ns.to_datetime_utc();
write!(
f,
"{}:{}:{}:{}",
self.venue,
self.underlying,
self.settlement_currency,
dt.format("%Y-%m-%dT%H:%M:%SZ"),
)
}
}
impl Debug for OptionSeriesId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let dt = self.expiration_ns.to_datetime_utc();
write!(
f,
"\"{}:{}:{}:{}\"",
self.venue,
self.underlying,
self.settlement_currency,
dt.format("%Y-%m-%dT%H:%M:%SZ"),
)
}
}
impl FromStr for OptionSeriesId {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
let parts: Vec<&str> = s.splitn(4, ':').collect();
if parts.len() != 4 {
anyhow::bail!(
"Error parsing `OptionSeriesId` from '{s}': expected format 'VENUE:UNDERLYING:SETTLEMENT:EXPIRY'"
);
}
let venue = Venue::new(parts[0]);
let underlying = Ustr::from(parts[1]);
let settlement_currency = Ustr::from(parts[2]);
let expiration_ns = UnixNanos::from_str(parts[3]).map_err(|e| {
anyhow::anyhow!(
"Error parsing `OptionSeriesId` expiration from '{}': {e}",
parts[3]
)
})?;
Ok(Self {
venue,
underlying,
settlement_currency,
expiration_ns,
})
}
}
impl Serialize for OptionSeriesId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_wire_string())
}
}
impl<'de> Deserialize<'de> for OptionSeriesId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: &str = Deserialize::deserialize(deserializer)?;
Self::from_str(s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use rstest::*;
use super::*;
fn test_series_id() -> OptionSeriesId {
OptionSeriesId::new(
Venue::new("DERIBIT"),
Ustr::from("BTC"),
Ustr::from("BTC"),
UnixNanos::from(1_700_000_000_000_000_000u64),
)
}
#[rstest]
fn test_option_series_id_new() {
let venue = Venue::new("DERIBIT");
let underlying = Ustr::from("BTC");
let settlement = Ustr::from("BTC");
let expiration_ns = UnixNanos::from(1_700_000_000_000_000_000u64);
let id = OptionSeriesId::new(venue, underlying, settlement, expiration_ns);
assert_eq!(id.venue, venue);
assert_eq!(id.underlying, underlying);
assert_eq!(id.settlement_currency, settlement);
assert_eq!(id.expiration_ns, expiration_ns);
}
#[rstest]
fn test_option_series_id_display() {
let id = test_series_id();
assert_eq!(id.to_string(), "DERIBIT:BTC:BTC:2023-11-14T22:13:20Z");
}
#[rstest]
fn test_option_series_id_wire_string() {
let id = test_series_id();
assert_eq!(id.to_wire_string(), "DERIBIT:BTC:BTC:1700000000000000000");
}
#[rstest]
fn test_option_series_id_debug() {
let id = test_series_id();
assert_eq!(
format!("{id:?}"),
"\"DERIBIT:BTC:BTC:2023-11-14T22:13:20Z\""
);
}
#[rstest]
fn test_option_series_id_from_str() {
let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:1700000000000000000").unwrap();
assert_eq!(id.venue, Venue::new("DERIBIT"));
assert_eq!(id.underlying, Ustr::from("BTC"));
assert_eq!(id.settlement_currency, Ustr::from("BTC"));
assert_eq!(
id.expiration_ns,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
}
#[rstest]
fn test_option_series_id_from_str_rfc3339() {
let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14T22:13:20Z").unwrap();
assert_eq!(id.venue, Venue::new("DERIBIT"));
assert_eq!(id.underlying, Ustr::from("BTC"));
assert_eq!(
id.expiration_ns,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
}
#[rstest]
fn test_option_series_id_from_str_date() {
let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14").unwrap();
assert_eq!(id.venue, Venue::new("DERIBIT"));
assert_eq!(id.underlying, Ustr::from("BTC"));
assert_eq!(
id.expiration_ns,
UnixNanos::from(1_699_920_000_000_000_000u64)
);
}
#[rstest]
fn test_option_series_id_from_str_invalid_format() {
assert!(OptionSeriesId::from_str("DERIBIT:BTC:BTC").is_err());
}
#[rstest]
fn test_option_series_id_from_str_invalid_expiry() {
assert!(OptionSeriesId::from_str("DERIBIT:BTC:BTC:not_a_date").is_err());
}
#[rstest]
fn test_option_series_id_inequality() {
let id1 = test_series_id();
let id2 = OptionSeriesId::new(
Venue::new("DERIBIT"),
Ustr::from("ETH"),
Ustr::from("ETH"),
UnixNanos::from(1_700_000_000_000_000_000u64),
);
assert_ne!(id1, id2);
}
#[rstest]
fn test_option_series_id_hash() {
use std::collections::HashSet;
let id1 = test_series_id();
let id2 = OptionSeriesId::new(
Venue::new("DERIBIT"),
Ustr::from("ETH"),
Ustr::from("ETH"),
UnixNanos::from(1_700_000_000_000_000_000u64),
);
let mut set = HashSet::new();
set.insert(id1);
set.insert(id2);
set.insert(id1);
assert_eq!(set.len(), 2);
}
#[rstest]
fn test_option_series_id_serde_roundtrip() {
let id = test_series_id();
let json = serde_json::to_string(&id).unwrap();
let deserialized: OptionSeriesId = serde_json::from_str(&json).unwrap();
assert_eq!(id, deserialized);
}
#[rstest]
fn test_from_expiry_happy_path() {
let id = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "2025-03-28").unwrap();
assert_eq!(id.venue, Venue::new("DERIBIT"));
assert_eq!(id.underlying, Ustr::from("BTC"));
assert_eq!(id.settlement_currency, Ustr::from("BTC"));
assert!(id.expiration_ns.as_u64() > 0);
}
#[rstest]
fn test_from_expiry_invalid_date() {
let result = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "not-a-date");
assert!(result.is_err());
}
#[rstest]
fn test_from_expiry_roundtrip() {
let id = OptionSeriesId::from_expiry("DERIBIT", "ETH", "ETH", "2025-06-27").unwrap();
let s = id.to_string();
let parsed = OptionSeriesId::from_str(&s).unwrap();
assert_eq!(id, parsed);
}
}