use crate::error::{PricingError, StaticArbitrageError, SviError};
use chrono::{DateTime, TimeDelta, Utc};
use optionstratlib_core::error::DecimalError;
use optionstratlib_core::model::types::OptionStyle;
use optionstratlib_core::model::{ExpirationDateError, Positive, PositiveError};
use rust_decimal::Decimal;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OptionQuote {
pub expiry: DateTime<Utc>,
pub strike: Positive,
pub style: OptionStyle,
pub bid: Positive,
pub ask: Positive,
pub quoted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum QuoteRejectionReason {
LaterThanAsOf,
ExpiredAtAsOf,
ZeroStrike,
Crossed,
ZeroBid,
#[non_exhaustive]
Stale {
age: TimeDelta,
limit: TimeDelta,
},
Superseded,
#[non_exhaustive]
OutsideNoArbitrageBand {
mid: Decimal,
lower: Decimal,
upper: Decimal,
},
BelowVolatilityFloor,
AboveVolatilityCap,
#[non_exhaustive]
NoConvergence {
last_volatility: Positive,
},
#[non_exhaustive]
NumericalFailure {
reason: String,
},
NoWeight,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct QuoteRejection {
pub quote: OptionQuote,
pub reason: QuoteRejectionReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnfittedReason {
ExpiredAtAsOf,
#[non_exhaustive]
TooFewCleanQuotes {
clean: usize,
required: usize,
},
#[non_exhaustive]
TooFewDistinctStrikes {
distinct: usize,
required: usize,
},
#[non_exhaustive]
RmseAboveLimit {
relative_rmse: Positive,
limit: Positive,
rmse: Positive,
},
#[non_exhaustive]
CalibrationFailed {
reason: String,
},
}
impl From<SviError> for UnfittedReason {
fn from(error: SviError) -> Self {
match error {
SviError::TooFewObservations { got, required } => Self::TooFewCleanQuotes {
clean: got,
required,
},
SviError::InsufficientDistinctStrikes { distinct, required } => {
Self::TooFewDistinctStrikes { distinct, required }
}
SviError::RmseAboveLimit {
relative_rmse,
limit,
rmse,
} => Self::RmseAboveLimit {
relative_rmse,
limit,
rmse,
},
other => Self::CalibrationFailed {
reason: other.to_string(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UnfittedExpiry {
pub expiry: DateTime<Utc>,
pub time_to_expiry: Option<Positive>,
pub clean_quotes: usize,
pub reason: UnfittedReason,
}
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ImpliedVolatilitySurfaceError {
#[error("the spot of a volatility surface must be positive")]
ZeroSpot,
#[error("the largest quote age must not be negative, got {limit}")]
NegativeQuoteAgeLimit {
limit: TimeDelta,
},
#[error("the arbitrage grid has {points} points per expiry, outside [{minimum}, {maximum}]")]
ArbitrageGridPointsOutOfRange {
points: usize,
minimum: usize,
maximum: usize,
},
#[error(
"no expiry could be fitted: {} unfitted expiries and {} rejected quotes",
.unfitted.len(),
.rejections.len()
)]
NoFittedExpiry {
unfitted: Vec<UnfittedExpiry>,
rejections: Vec<QuoteRejection>,
},
#[error("expiry {expiry} is not after the as-of time {as_of}")]
ExpiryNotAfterAsOf {
expiry: DateTime<Utc>,
as_of: DateTime<Utc>,
},
#[error("the time to expiry asked of a volatility surface must be positive")]
ZeroTimeToExpiry,
#[error("instant {instant} is before the as-of time {as_of}")]
InstantBeforeAsOf {
instant: DateTime<Utc>,
as_of: DateTime<Utc>,
},
#[error(
"log moneyness {log_moneyness} at expiry {time_to_expiry} is {extension} beyond quotes spanning [{lowest}, {highest}], and the policy allows {limit}"
)]
WingOutOfPolicy {
log_moneyness: Decimal,
time_to_expiry: Positive,
lowest: Decimal,
highest: Decimal,
extension: Positive,
limit: Positive,
},
#[error(
"time to expiry {time_to_expiry} is before the first fitted expiry {first} by more than the policy allows, {limit}"
)]
ShortExpiryOutOfPolicy {
time_to_expiry: Positive,
first: Positive,
limit: Positive,
},
#[error(
"time to expiry {time_to_expiry} is after the last fitted expiry {last} by more than the policy allows, {limit}"
)]
LongExpiryOutOfPolicy {
time_to_expiry: Positive,
last: Positive,
limit: Positive,
},
#[error("the export grid of a volatility surface must have a point on each axis")]
EmptyExportGrid,
#[error(
"a shift of {shift} takes the at-the-money volatility {at_the_money} of expiry {time_to_expiry} to zero or below"
)]
ShockedVolatilityNotPositive {
time_to_expiry: Positive,
at_the_money: Positive,
shift: Decimal,
},
#[error("a shock gives expiry {time_to_expiry} invalid svi parameters: {source}")]
InvalidShockedParameters {
time_to_expiry: Positive,
source: SviError,
},
#[error(transparent)]
Svi(#[from] SviError),
#[error(transparent)]
Pricing(#[from] PricingError),
#[error(transparent)]
StaticArbitrage(#[from] StaticArbitrageError),
#[error(transparent)]
Decimal(#[from] DecimalError),
#[error(transparent)]
Positive(#[from] PositiveError),
#[error(transparent)]
ExpirationDate(#[from] ExpirationDateError),
}
#[cfg(test)]
mod tests_implied_volatility_surface_error {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_surface_error_no_fitted_expiry_names_both_counts() {
let message = ImpliedVolatilitySurfaceError::NoFittedExpiry {
unfitted: Vec::new(),
rejections: Vec::new(),
}
.to_string();
assert!(message.contains("0 unfitted"), "{message}");
assert!(message.contains("0 rejected"), "{message}");
}
#[test]
fn test_surface_error_wing_names_the_extension_and_the_limit() {
let message = ImpliedVolatilitySurfaceError::WingOutOfPolicy {
log_moneyness: dec!(0.7),
time_to_expiry: Positive::ONE,
lowest: dec!(-0.5),
highest: dec!(0.5),
extension: Positive::new_decimal(dec!(0.2)).unwrap(),
limit: Positive::ZERO,
}
.to_string();
assert!(message.contains("0.7"), "{message}");
assert!(message.contains("0.2"), "{message}");
assert!(message.contains("[-0.5, 0.5]"), "{message}");
}
#[test]
fn test_surface_error_invalid_shock_names_the_expiry_and_the_cause() {
let message = ImpliedVolatilitySurfaceError::InvalidShockedParameters {
time_to_expiry: Positive::TWO,
source: SviError::ZeroForward,
}
.to_string();
assert!(message.contains("expiry 2"), "{message}");
assert!(message.contains("forward"), "{message}");
}
#[test]
fn test_surface_error_lower_errors_convert_with_from() {
let decimal: ImpliedVolatilitySurfaceError =
DecimalError::arithmetic_error("surface", "boom").into();
assert!(matches!(decimal, ImpliedVolatilitySurfaceError::Decimal(_)));
let svi: ImpliedVolatilitySurfaceError = SviError::ZeroForward.into();
assert!(matches!(svi, ImpliedVolatilitySurfaceError::Svi(_)));
let check: ImpliedVolatilitySurfaceError = StaticArbitrageError::EmptyGrid.into();
assert!(matches!(
check,
ImpliedVolatilitySurfaceError::StaticArbitrage(_)
));
}
}