use crate::utils::ChainError;
use chrono::{DateTime, Utc};
use positive::Positive;
use rust_decimal::Decimal;
use std::fmt;
use uuid::Uuid;
pub const CURRENT_SNAPSHOT_GENERATION: u64 = 2;
const SNAPSHOT_NAMESPACE: Uuid = Uuid::from_u128(0x6f_63_73_2d_73_6e_61_70_73_68_6f_74_2d_76_31_00);
#[must_use]
#[inline]
pub fn snapshot_id(simulation: Uuid, generation: u64, step: usize) -> Uuid {
let name = format!("{simulation}:{generation}:{step}");
Uuid::new_v5(&SNAPSHOT_NAMESPACE, name.as_bytes())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum ContractSide {
Call,
Put,
}
impl ContractSide {
#[must_use]
#[inline]
pub fn as_str(self) -> &'static str {
match self {
ContractSide::Call => "call",
ContractSide::Put => "put",
}
}
}
impl fmt::Display for ContractSide {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct QuoteRow {
pub strike: Positive,
pub implied_volatility: Positive,
pub call_bid: Option<Positive>,
pub call_ask: Option<Positive>,
pub call_mid: Option<Positive>,
pub put_bid: Option<Positive>,
pub put_ask: Option<Positive>,
pub put_mid: Option<Positive>,
pub delta_call: Option<Decimal>,
pub delta_put: Option<Decimal>,
pub gamma: Option<Decimal>,
}
impl QuoteRow {
#[must_use]
pub fn new(strike: Positive, implied_volatility: Positive) -> Self {
Self {
strike,
implied_volatility,
call_bid: None,
call_ask: None,
call_mid: None,
put_bid: None,
put_ask: None,
put_mid: None,
delta_call: None,
delta_put: None,
gamma: None,
}
}
#[must_use = "builders do nothing unless the value is used"]
pub fn with_call(
mut self,
bid: Option<Positive>,
ask: Option<Positive>,
mid: Option<Positive>,
delta: Option<Decimal>,
) -> Self {
self.call_bid = bid;
self.call_ask = ask;
self.call_mid = mid;
self.delta_call = delta;
self
}
#[must_use = "builders do nothing unless the value is used"]
pub fn with_put(
mut self,
bid: Option<Positive>,
ask: Option<Positive>,
mid: Option<Positive>,
delta: Option<Decimal>,
) -> Self {
self.put_bid = bid;
self.put_ask = ask;
self.put_mid = mid;
self.delta_put = delta;
self
}
#[must_use = "builders do nothing unless the value is used"]
pub fn with_gamma(mut self, gamma: Option<Decimal>) -> Self {
self.gamma = gamma;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExpirationRecord {
pub expires_at: DateTime<Utc>,
pub days_to_expiration: Positive,
pub labels: Vec<String>,
pub quotes: Vec<QuoteRow>,
}
impl ExpirationRecord {
#[must_use]
pub fn new(
expires_at: DateTime<Utc>,
days_to_expiration: Positive,
labels: Vec<String>,
quotes: Vec<QuoteRow>,
) -> Self {
Self {
expires_at,
days_to_expiration,
labels,
quotes,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SnapshotRecord {
pub simulation: Uuid,
pub generation: u64,
pub step: usize,
pub simulated_at: DateTime<Utc>,
pub symbol: String,
pub spot: Positive,
pub base_volatility: Positive,
pub expirations: Vec<ExpirationRecord>,
}
impl SnapshotRecord {
#[must_use]
#[allow(
clippy::too_many_arguments,
reason = "a storage record is wide by nature"
)]
pub fn new(
simulation: Uuid,
generation: u64,
step: usize,
simulated_at: DateTime<Utc>,
symbol: String,
spot: Positive,
base_volatility: Positive,
expirations: Vec<ExpirationRecord>,
) -> Self {
Self {
simulation,
generation,
step,
simulated_at,
symbol,
spot,
base_volatility,
expirations,
}
}
#[must_use]
#[inline]
pub fn snapshot_id(&self) -> Uuid {
snapshot_id(self.simulation, self.generation, self.step)
}
#[must_use]
pub fn quote_count(&self) -> usize {
self.expirations
.iter()
.map(|expiration| expiration.quotes.len())
.sum()
}
pub fn validate(&self) -> Result<(), ChainError> {
if self.symbol.trim().is_empty() {
return Err(ChainError::Validation {
field: "symbol".to_string(),
reason: "must not be empty".to_string(),
});
}
let mut previous_expiry: Option<DateTime<Utc>> = None;
for expiration in &self.expirations {
if let Some(previous) = previous_expiry
&& expiration.expires_at <= previous
{
return Err(ChainError::Validation {
field: "expirations".to_string(),
reason: format!(
"must be strictly ascending by expires_at, got {} after {}",
expiration.expires_at, previous
),
});
}
previous_expiry = Some(expiration.expires_at);
let mut previous_strike: Option<Positive> = None;
for quote in &expiration.quotes {
if let Some(previous) = previous_strike
&& quote.strike <= previous
{
return Err(ChainError::Validation {
field: "quotes".to_string(),
reason: format!(
"strikes must be strictly ascending within {}, got {} after {}",
expiration.expires_at, quote.strike, previous
),
});
}
previous_strike = Some(quote.strike);
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContractQuote {
pub step: usize,
pub simulated_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub days_to_expiration: Positive,
pub strike: Positive,
pub side: ContractSide,
pub implied_volatility: Positive,
pub bid: Option<Positive>,
pub ask: Option<Positive>,
pub mid: Option<Positive>,
pub delta: Option<Decimal>,
pub gamma: Option<Decimal>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
fn instant(day: u32) -> DateTime<Utc> {
match Utc.with_ymd_and_hms(2026, 1, day, 14, 30, 0).single() {
Some(instant) => instant,
None => panic!("the test instant must be valid"),
}
}
fn quote(strike: f64) -> QuoteRow {
QuoteRow::new(pos_or_panic!(strike), pos_or_panic!(0.18))
.with_call(
Some(pos_or_panic!(1.0)),
Some(pos_or_panic!(1.2)),
Some(pos_or_panic!(1.1)),
Some(dec!(0.51)),
)
.with_put(
Some(pos_or_panic!(0.9)),
Some(pos_or_panic!(1.1)),
Some(pos_or_panic!(1.0)),
Some(dec!(-0.49)),
)
.with_gamma(Some(dec!(0.0031)))
}
fn expiration(day: u32, strikes: &[f64]) -> ExpirationRecord {
ExpirationRecord::new(
instant(day),
pos_or_panic!(f64::from(day)),
vec!["weeklies".to_string()],
strikes.iter().copied().map(quote).collect(),
)
}
fn record(simulation: Uuid, generation: u64, step: usize) -> SnapshotRecord {
SnapshotRecord::new(
simulation,
generation,
step,
instant(5),
"SPX".to_string(),
pos_or_panic!(5000.0),
pos_or_panic!(0.18),
vec![
expiration(6, &[4975.0, 5000.0, 5025.0]),
expiration(9, &[4975.0, 5000.0]),
],
)
}
#[test]
fn test_the_snapshot_id_is_deterministic() {
let simulation = Uuid::from_u128(7);
assert_eq!(
snapshot_id(simulation, 3, 11),
snapshot_id(simulation, 3, 11)
);
}
#[test]
fn test_the_snapshot_id_separates_its_components() {
let simulation = Uuid::from_u128(7);
let other = Uuid::from_u128(8);
assert_ne!(snapshot_id(simulation, 3, 11), snapshot_id(other, 3, 11));
assert_ne!(
snapshot_id(simulation, 3, 11),
snapshot_id(simulation, 4, 11)
);
assert_ne!(
snapshot_id(simulation, 3, 11),
snapshot_id(simulation, 3, 12)
);
assert_ne!(
snapshot_id(simulation, 1, 23),
snapshot_id(simulation, 12, 3)
);
}
#[test]
fn test_the_current_generation_is_addressable() {
let simulation = Uuid::from_u128(7);
assert_eq!(CURRENT_SNAPSHOT_GENERATION, 2);
assert_eq!(
record(simulation, CURRENT_SNAPSHOT_GENERATION, 0).snapshot_id(),
snapshot_id(simulation, CURRENT_SNAPSHOT_GENERATION, 0)
);
}
#[test]
fn test_a_generation_bump_addresses_a_different_row() {
let simulation = Uuid::from_u128(7);
let previous = snapshot_id(simulation, CURRENT_SNAPSHOT_GENERATION - 1, 3);
let current = snapshot_id(simulation, CURRENT_SNAPSHOT_GENERATION, 3);
assert_ne!(
previous, current,
"the same step under two generations must be two rows"
);
assert_eq!(
current,
snapshot_id(simulation, CURRENT_SNAPSHOT_GENERATION, 3),
"and the current one must still be reproducible"
);
}
#[test]
fn test_the_snapshot_id_is_a_v5_uuid() {
assert_eq!(
snapshot_id(Uuid::from_u128(7), 1, 0).get_version(),
Some(uuid::Version::Sha1)
);
}
#[test]
fn test_a_record_derives_its_own_id() {
let simulation = Uuid::from_u128(9);
let record = record(simulation, 2, 4);
assert_eq!(record.snapshot_id(), snapshot_id(simulation, 2, 4));
}
#[test]
fn test_the_quote_count_sums_every_expiration() {
assert_eq!(record(Uuid::from_u128(9), 1, 0).quote_count(), 5);
}
#[test]
fn test_a_well_formed_record_validates() {
match record(Uuid::from_u128(9), 1, 0).validate() {
Ok(()) => {}
Err(error) => panic!("the reference record must validate: {error}"),
}
}
#[test]
fn test_an_empty_snapshot_is_legal() {
let mut empty = record(Uuid::from_u128(9), 1, 0);
empty.expirations.clear();
assert_eq!(empty.quote_count(), 0);
assert!(empty.validate().is_ok());
}
#[test]
fn test_unordered_expirations_are_rejected() {
let mut unordered = record(Uuid::from_u128(9), 1, 0);
unordered.expirations.reverse();
match unordered.validate() {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "expirations");
assert!(reason.contains("ascending"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_repeated_expiration_is_rejected() {
let mut repeated = record(Uuid::from_u128(9), 1, 0);
repeated.expirations = vec![expiration(6, &[5000.0]), expiration(6, &[5000.0])];
assert!(repeated.validate().is_err());
}
#[test]
fn test_unordered_strikes_are_rejected() {
let mut unordered = record(Uuid::from_u128(9), 1, 0);
unordered.expirations = vec![expiration(6, &[5025.0, 5000.0])];
match unordered.validate() {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "quotes");
assert!(reason.contains("ascending"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_repeated_strike_is_rejected() {
let mut repeated = record(Uuid::from_u128(9), 1, 0);
repeated.expirations = vec![expiration(6, &[5000.0, 5000.0])];
assert!(repeated.validate().is_err());
}
#[test]
fn test_an_empty_symbol_is_rejected() {
let mut blank = record(Uuid::from_u128(9), 1, 0);
blank.symbol = " ".to_string();
match blank.validate() {
Err(ChainError::Validation { field, .. }) => assert_eq!(field, "symbol"),
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_the_contract_side_renders_its_name() {
assert_eq!(ContractSide::Call.to_string(), "call");
assert_eq!(ContractSide::Put.as_str(), "put");
}
}