use std::hash::{Hash, Hasher};
use std::sync::Arc;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle};
use serde::{Deserialize, Serialize};
use crate::domain::money::PriceCents;
use crate::error::BacktestError;
const CONTRACT_ID_VERSION: &str = "v1";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct Underlying(Arc<str>);
impl Underlying {
#[must_use = "the validated underlying must be used"]
pub fn new<S: Into<String>>(ticker: S) -> Result<Self, BacktestError> {
let ticker = ticker.into();
let valid_len = !ticker.is_empty() && ticker.len() <= 32;
let valid_chars = ticker
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '.' || c == '_');
if !valid_len || !valid_chars {
return Err(BacktestError::Conversion(format!(
"underlying {ticker:?} violates the grammar ^[A-Z0-9._]{{1,32}}$"
)));
}
Ok(Self(Arc::from(ticker)))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Underlying {
type Error = BacktestError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Underlying> for String {
fn from(value: Underlying) -> Self {
value.0.as_ref().to_owned()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractKey {
pub underlying: Underlying,
pub expiration: ExpirationDate,
pub strike: PriceCents,
pub style: OptionStyle,
}
fn expiration_exact_eq(a: &ExpirationDate, b: &ExpirationDate) -> bool {
match (a, b) {
(ExpirationDate::Days(da), ExpirationDate::Days(db)) => da.to_dec() == db.to_dec(),
(ExpirationDate::DateTime(ta), ExpirationDate::DateTime(tb)) => ta == tb,
_ => false,
}
}
impl PartialEq for ContractKey {
fn eq(&self, other: &Self) -> bool {
self.underlying == other.underlying
&& self.strike == other.strike
&& self.style == other.style
&& expiration_exact_eq(&self.expiration, &other.expiration)
}
}
impl Eq for ContractKey {}
fn expiration_exact_cmp(a: &ExpirationDate, b: &ExpirationDate) -> std::cmp::Ordering {
match (a, b) {
(ExpirationDate::Days(da), ExpirationDate::Days(db)) => da.to_dec().cmp(&db.to_dec()),
(ExpirationDate::DateTime(ta), ExpirationDate::DateTime(tb)) => ta.cmp(tb),
(ExpirationDate::Days(_), ExpirationDate::DateTime(_)) => std::cmp::Ordering::Less,
(ExpirationDate::DateTime(_), ExpirationDate::Days(_)) => std::cmp::Ordering::Greater,
}
}
impl PartialOrd for ContractKey {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ContractKey {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.underlying
.cmp(&other.underlying)
.then_with(|| expiration_exact_cmp(&self.expiration, &other.expiration))
.then_with(|| self.strike.cmp(&other.strike))
.then_with(|| self.style.cmp(&other.style))
}
}
impl Hash for ContractKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.underlying.hash(state);
match &self.expiration {
ExpirationDate::Days(days) => {
0u8.hash(state);
let normalized = days.to_dec().normalize();
normalized.mantissa().hash(state);
normalized.scale().hash(state);
}
ExpirationDate::DateTime(instant) => {
1u8.hash(state);
instant.hash(state);
}
}
self.strike.hash(state);
self.style.hash(state);
}
}
impl ContractKey {
#[must_use = "the resolved expiration must be used"]
pub fn expiration_ns(&self) -> Result<i64, BacktestError> {
match &self.expiration {
ExpirationDate::DateTime(instant) => instant.timestamp_nanos_opt().ok_or_else(|| {
BacktestError::Conversion(format!(
"expiration {instant} outside the nanosecond i64 range"
))
}),
ExpirationDate::Days(days) => Err(BacktestError::Conversion(format!(
"relative expiration Days({days}) is unresolved — resolved once at tape materialisation"
))),
}
}
#[must_use = "the contract id must be used"]
pub fn to_contract_id(&self) -> Result<String, BacktestError> {
let expiration_ns = self.expiration_ns()?;
let style = match self.style {
OptionStyle::Call => "C",
OptionStyle::Put => "P",
};
Ok(format!(
"{CONTRACT_ID_VERSION}:{}:{expiration_ns}:{}:{style}",
self.underlying.as_str(),
self.strike.value()
))
}
#[must_use = "the parsed contract key must be used"]
pub fn from_contract_id(contract_id: &str) -> Result<Self, BacktestError> {
let segments: Vec<&str> = contract_id.split(':').collect();
let [version, underlying, expiration_ns, strike_cents, style] = segments.as_slice() else {
return Err(BacktestError::Conversion(format!(
"contract id {contract_id:?} must have exactly 5 ':'-separated segments"
)));
};
if *version != CONTRACT_ID_VERSION {
return Err(BacktestError::Conversion(format!(
"contract id {contract_id:?} has unsupported version {version:?}, expected \"v1\""
)));
}
let underlying = Underlying::new(*underlying)?;
let expiration_ns: i64 = expiration_ns.parse().map_err(|_| {
BacktestError::Conversion(format!(
"contract id {contract_id:?} has non-numeric expiration_ns {expiration_ns:?}"
))
})?;
let strike: u64 = strike_cents.parse().map_err(|_| {
BacktestError::Conversion(format!(
"contract id {contract_id:?} has non-numeric strike_cents {strike_cents:?}"
))
})?;
let style = match *style {
"C" => OptionStyle::Call,
"P" => OptionStyle::Put,
other => {
return Err(BacktestError::Conversion(format!(
"contract id {contract_id:?} has unknown style {other:?}, expected \"C\" or \"P\""
)));
}
};
Ok(Self {
underlying,
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(expiration_ns)),
strike: PriceCents::new(strike),
style,
})
}
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle};
use positive::Positive;
fn days_30() -> Positive {
let Ok(days) = Positive::new(30.0) else {
panic!("30.0 is a valid positive value");
};
days
}
use super::{ContractKey, Underlying};
use crate::domain::money::PriceCents;
use crate::error::BacktestError;
fn resolved_key() -> ContractKey {
ContractKey {
underlying: Underlying::new("SPX").unwrap_or_else(|_| unreachable!("SPX is valid")),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
1_750_291_200_000_000_000,
)),
strike: PriceCents::new(510_000),
style: OptionStyle::Call,
}
}
#[test]
fn test_contract_id_roundtrips() {
let key = resolved_key();
let id = key.to_contract_id();
assert!(matches!(
id.as_deref(),
Ok("v1:SPX:1750291200000000000:510000:C")
));
let id = id.unwrap_or_default();
let back = ContractKey::from_contract_id(&id);
assert!(matches!(back, Ok(ref k) if *k == key));
}
#[test]
fn test_contract_id_rejects_wrong_version() {
let err = ContractKey::from_contract_id("v2:SPX:0:100:C");
assert!(matches!(err, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_contract_id_rejects_wrong_segment_count() {
let err = ContractKey::from_contract_id("v1:SPX:0:100");
assert!(matches!(err, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_contract_id_rejects_bad_underlying_grammar() {
for bad in ["v1:spx:0:100:C", "v1::0:100:C", "v1:S-PX:0:100:C"] {
assert!(
matches!(
ContractKey::from_contract_id(bad),
Err(BacktestError::Conversion(_))
),
"{bad} must be rejected"
);
}
}
#[test]
fn test_contract_id_rejects_non_numeric_segments() {
assert!(matches!(
ContractKey::from_contract_id("v1:SPX:soon:100:C"),
Err(BacktestError::Conversion(_))
));
assert!(matches!(
ContractKey::from_contract_id("v1:SPX:0:many:P"),
Err(BacktestError::Conversion(_))
));
assert!(matches!(
ContractKey::from_contract_id("v1:SPX:0:100:X"),
Err(BacktestError::Conversion(_))
));
}
#[test]
fn test_underlying_deserialisation_revalidates_grammar() {
let bad: Result<Underlying, _> = serde_json::from_str("\"spx\"");
assert!(bad.is_err(), "lowercase ticker must fail deserialisation");
let good: Result<Underlying, _> = serde_json::from_str("\"SPX\"");
assert!(matches!(good, Ok(ref u) if u.as_str() == "SPX"));
}
#[test]
fn test_underlying_rejects_colon_and_lowercase_and_length() {
assert!(Underlying::new("SPX").is_ok());
assert!(Underlying::new("BRK.B").is_ok());
assert!(Underlying::new("A_1").is_ok());
assert!(Underlying::new("a").is_err());
assert!(Underlying::new("S:PX").is_err());
assert!(Underlying::new("").is_err());
assert!(Underlying::new("X".repeat(33)).is_err());
assert!(Underlying::new("X".repeat(32)).is_ok());
}
#[test]
fn test_unresolved_days_expiration_cannot_build_contract_id() {
let mut key = resolved_key();
key.expiration = ExpirationDate::Days(days_30());
assert!(matches!(
key.to_contract_id(),
Err(BacktestError::Conversion(_))
));
}
#[test]
fn test_contract_key_equality_is_exact_across_variants() {
let resolved = resolved_key();
let mut relative = resolved.clone();
relative.expiration = ExpirationDate::Days(days_30());
assert_ne!(resolved, relative);
assert_eq!(resolved, resolved.clone());
assert_eq!(relative, relative.clone());
}
#[test]
fn test_contract_key_works_as_exact_map_key() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(resolved_key(), 1u32);
assert_eq!(map.get(&resolved_key()), Some(&1));
let mut other = resolved_key();
other.strike = PriceCents::new(520_000);
assert_eq!(map.get(&other), None);
}
}