use super::svi::SviSlice;
use crate::error::StaticArbitrageError;
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{d_add, d_div, d_exp, d_mul, d_sub};
use rust_decimal::Decimal;
use tracing::{debug, instrument};
const MIN_CALENDAR_SLICES: usize = 2;
const MAX_RELATIVE_ROUNDING: Decimal = Decimal::from_parts(1, 0, 0, false, 12);
const G_ARITHMETIC_ROUNDING: Decimal = Decimal::from_parts(1, 0, 0, false, 26);
const FOUR: Decimal = Decimal::from_parts(4, 0, 0, false, 0);
pub trait TotalVarianceSlice {
fn time_to_expiry(&self) -> Positive;
fn forward(&self) -> Positive;
fn total_variance(&self, k: Decimal) -> Result<Positive, StaticArbitrageError>;
fn total_variance_slope(&self, k: Decimal) -> Result<Decimal, StaticArbitrageError>;
fn total_variance_curvature(&self, k: Decimal) -> Result<Decimal, StaticArbitrageError>;
fn total_variance_rounding(&self, k: Decimal) -> Result<Positive, StaticArbitrageError>;
}
impl<T: TotalVarianceSlice + ?Sized> TotalVarianceSlice for &T {
#[inline]
fn time_to_expiry(&self) -> Positive {
(**self).time_to_expiry()
}
#[inline]
fn forward(&self) -> Positive {
(**self).forward()
}
fn total_variance(&self, k: Decimal) -> Result<Positive, StaticArbitrageError> {
(**self).total_variance(k)
}
fn total_variance_slope(&self, k: Decimal) -> Result<Decimal, StaticArbitrageError> {
(**self).total_variance_slope(k)
}
fn total_variance_curvature(&self, k: Decimal) -> Result<Decimal, StaticArbitrageError> {
(**self).total_variance_curvature(k)
}
fn total_variance_rounding(&self, k: Decimal) -> Result<Positive, StaticArbitrageError> {
(**self).total_variance_rounding(k)
}
}
impl TotalVarianceSlice for SviSlice {
#[inline]
fn time_to_expiry(&self) -> Positive {
SviSlice::time_to_expiry(self)
}
#[inline]
fn forward(&self) -> Positive {
SviSlice::forward(self)
}
fn total_variance(&self, k: Decimal) -> Result<Positive, StaticArbitrageError> {
Ok(SviSlice::total_variance(self, k)?)
}
fn total_variance_slope(&self, k: Decimal) -> Result<Decimal, StaticArbitrageError> {
Ok(SviSlice::total_variance_slope(self, k)?)
}
fn total_variance_curvature(&self, k: Decimal) -> Result<Decimal, StaticArbitrageError> {
Ok(SviSlice::total_variance_curvature(self, k)?)
}
fn total_variance_rounding(&self, k: Decimal) -> Result<Positive, StaticArbitrageError> {
Ok(self.parameters().total_variance_rounding(k)?)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StaticArbitrageViolation {
#[non_exhaustive]
Butterfly {
time_to_expiry: Positive,
log_moneyness: Decimal,
strike: Positive,
g: Decimal,
size: Positive,
},
#[non_exhaustive]
Calendar {
earlier_expiry: Positive,
later_expiry: Positive,
log_moneyness: Decimal,
earlier_strike: Positive,
later_strike: Positive,
earlier_total_variance: Positive,
later_total_variance: Positive,
size: Positive,
},
}
impl StaticArbitrageViolation {
#[must_use]
#[inline]
pub fn log_moneyness(&self) -> Decimal {
match self {
Self::Butterfly { log_moneyness, .. } | Self::Calendar { log_moneyness, .. } => {
*log_moneyness
}
}
}
#[must_use]
#[inline]
pub fn size(&self) -> Positive {
match self {
Self::Butterfly { size, .. } | Self::Calendar { size, .. } => *size,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StaticArbitrageUnevaluatedPoint {
#[non_exhaustive]
Butterfly {
time_to_expiry: Positive,
log_moneyness: Decimal,
strike: Positive,
total_variance: Positive,
rounding: Positive,
},
#[non_exhaustive]
Calendar {
earlier_expiry: Positive,
later_expiry: Positive,
log_moneyness: Decimal,
earlier_strike: Positive,
later_strike: Positive,
earlier_total_variance: Positive,
later_total_variance: Positive,
earlier_rounding: Positive,
later_rounding: Positive,
},
}
impl StaticArbitrageUnevaluatedPoint {
#[must_use]
#[inline]
pub fn log_moneyness(&self) -> Decimal {
match self {
Self::Butterfly { log_moneyness, .. } | Self::Calendar { log_moneyness, .. } => {
*log_moneyness
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaticArbitrageReport {
violations: Vec<StaticArbitrageViolation>,
unevaluated: Vec<StaticArbitrageUnevaluatedPoint>,
}
impl StaticArbitrageReport {
fn empty() -> Self {
Self {
violations: Vec::new(),
unevaluated: Vec::new(),
}
}
#[must_use]
#[inline]
pub fn violations(&self) -> &[StaticArbitrageViolation] {
&self.violations
}
#[must_use]
#[inline]
pub fn unevaluated(&self) -> &[StaticArbitrageUnevaluatedPoint] {
&self.unevaluated
}
#[must_use]
#[inline]
pub fn passed(&self) -> bool {
self.violations.is_empty() && self.unevaluated.is_empty()
}
}
#[instrument(skip(slice, grid), fields(points = grid.len()), level = "debug")]
pub fn check_butterfly<S: TotalVarianceSlice>(
slice: &S,
grid: &[Decimal],
) -> Result<StaticArbitrageReport, StaticArbitrageError> {
check_grid(grid)?;
let read = read_slice(slice)?;
let mut report = StaticArbitrageReport::empty();
butterfly_into(&read, grid, &mut report)?;
debug!(
violations = report.violations.len(),
unevaluated = report.unevaluated.len(),
"butterfly check finished"
);
Ok(report)
}
#[instrument(skip(slices, grid), fields(slices = slices.len(), points = grid.len()), level = "debug")]
pub fn check_calendar<S: TotalVarianceSlice>(
slices: &[S],
grid: &[Decimal],
) -> Result<StaticArbitrageReport, StaticArbitrageError> {
check_grid(grid)?;
let sorted = sorted_by_expiry(slices)?;
let mut report = StaticArbitrageReport::empty();
calendar_into(&sorted, grid, &mut report)?;
debug!(
violations = report.violations.len(),
unevaluated = report.unevaluated.len(),
"calendar check finished"
);
Ok(report)
}
#[instrument(skip(slices, grid), fields(slices = slices.len(), points = grid.len()), level = "debug")]
pub fn check_static_arbitrage<S: TotalVarianceSlice>(
slices: &[S],
grid: &[Decimal],
) -> Result<StaticArbitrageReport, StaticArbitrageError> {
check_grid(grid)?;
let sorted = sorted_by_expiry(slices)?;
let mut report = StaticArbitrageReport::empty();
for read in &sorted {
butterfly_into(read, grid, &mut report)?;
}
calendar_into(&sorted, grid, &mut report)?;
debug!(
violations = report.violations.len(),
unevaluated = report.unevaluated.len(),
"static arbitrage check finished"
);
Ok(report)
}
fn check_grid(grid: &[Decimal]) -> Result<(), StaticArbitrageError> {
if grid.is_empty() {
return Err(StaticArbitrageError::EmptyGrid);
}
Ok(())
}
struct ReadSlice<'a, S> {
expiry: Positive,
forward: Positive,
slice: &'a S,
}
fn read_slice<S: TotalVarianceSlice>(slice: &S) -> Result<ReadSlice<'_, S>, StaticArbitrageError> {
let expiry = slice.time_to_expiry();
let forward = slice.forward();
let invalid = |reason| StaticArbitrageError::InvalidSlice {
time_to_expiry: expiry,
forward,
reason,
};
if forward.is_zero() {
return Err(invalid("the forward must be positive"));
}
if expiry.is_zero() {
return Err(invalid("the time to expiry must be positive"));
}
Ok(ReadSlice {
expiry,
forward,
slice,
})
}
fn sorted_by_expiry<S: TotalVarianceSlice>(
slices: &[S],
) -> Result<Vec<ReadSlice<'_, S>>, StaticArbitrageError> {
if slices.len() < MIN_CALENDAR_SLICES {
return Err(StaticArbitrageError::TooFewSlices {
got: slices.len(),
required: MIN_CALENDAR_SLICES,
});
}
let mut sorted = Vec::with_capacity(slices.len());
for slice in slices {
sorted.push(read_slice(slice)?);
}
sorted.sort_by_key(|read| read.expiry);
for pair in sorted.windows(2) {
if let [earlier, later] = pair
&& earlier.expiry == later.expiry
{
return Err(StaticArbitrageError::DuplicateExpiry {
time_to_expiry: earlier.expiry,
});
}
}
Ok(sorted)
}
fn strike_at(forward: Positive, k: Decimal) -> Result<Positive, StaticArbitrageError> {
const OP: &str = "arbitrage::strike";
let out_of_range = || StaticArbitrageError::StrikeOutOfRange {
forward,
log_moneyness: k,
};
let growth = d_exp(k, OP).map_err(|_| out_of_range())?;
let strike = d_mul(forward.to_dec(), growth, OP).map_err(|_| out_of_range())?;
if strike.is_zero() {
return Err(out_of_range());
}
Ok(Positive::new_decimal(strike)?)
}
#[derive(Debug, Clone, Copy)]
struct DensityFactor {
g: Decimal,
band: Decimal,
}
fn density_factor(
k: Decimal,
w: Decimal,
slope: Decimal,
curvature: Decimal,
rounding: Decimal,
) -> Result<DensityFactor, StaticArbitrageError> {
const OP: &str = "arbitrage::g";
let ratio = d_div(slope, w, OP)?;
let q = d_div(d_mul(k, ratio, OP)?, Decimal::TWO, OP)?;
let one_minus_q = d_sub(Decimal::ONE, q, OP)?;
let first = d_mul(one_minus_q, one_minus_q, OP)?;
let quarter_slope = d_div(slope, FOUR, OP)?;
let over_w = match slope.checked_mul(ratio) {
Some(product) => d_div(product, FOUR, OP)?,
None => d_mul(quarter_slope, ratio, OP)?,
};
let over_four = d_mul(quarter_slope, quarter_slope, OP)?;
let half_curvature = d_div(curvature, Decimal::TWO, OP)?;
let g = d_add(
d_sub(d_sub(first, over_w, OP)?, over_four, OP)?,
half_curvature,
OP,
)?;
let spread = d_add(d_add(Decimal::ONE, q.abs(), OP)?, k.abs(), OP)?;
let magnitude = d_add(
d_add(d_mul(spread, spread, OP)?, over_w, OP)?,
d_add(over_four, half_curvature.abs(), OP)?,
OP,
)?;
let relative = d_add(
d_mul(Decimal::TWO, d_div(rounding, w, OP)?, OP)?,
G_ARITHMETIC_ROUNDING,
OP,
)?;
let over_four_rounding = d_mul(quarter_slope.abs(), rounding, OP)?;
let quarter_rounding = d_div(rounding, FOUR, OP)?;
let second_order = d_add(
d_mul(quarter_rounding, quarter_rounding, OP)?,
d_mul(quarter_rounding, d_div(rounding, w, OP)?, OP)?,
OP,
)?;
Ok(DensityFactor {
g,
band: d_add(
d_add(d_mul(magnitude, relative, OP)?, over_four_rounding, OP)?,
second_order,
OP,
)?,
})
}
fn butterfly_into<S: TotalVarianceSlice>(
read: &ReadSlice<'_, S>,
grid: &[Decimal],
report: &mut StaticArbitrageReport,
) -> Result<(), StaticArbitrageError> {
let slice = read.slice;
for &k in grid {
let total_variance = slice.total_variance(k)?;
let rounding = slice.total_variance_rounding(k)?;
let w = total_variance.to_dec();
let limit = d_mul(MAX_RELATIVE_ROUNDING, w, "arbitrage::g::limit")?;
if w.is_zero() || rounding.to_dec() > limit {
report
.unevaluated
.push(StaticArbitrageUnevaluatedPoint::Butterfly {
time_to_expiry: read.expiry,
log_moneyness: k,
strike: strike_at(read.forward, k)?,
total_variance,
rounding,
});
continue;
}
let factor = density_factor(
k,
w,
slice.total_variance_slope(k)?,
slice.total_variance_curvature(k)?,
rounding.to_dec(),
)?;
if factor.g < -factor.band {
report.violations.push(StaticArbitrageViolation::Butterfly {
time_to_expiry: read.expiry,
log_moneyness: k,
strike: strike_at(read.forward, k)?,
g: factor.g,
size: Positive::new_decimal(-factor.g)?,
});
}
}
Ok(())
}
fn calendar_into<S: TotalVarianceSlice>(
sorted: &[ReadSlice<'_, S>],
grid: &[Decimal],
report: &mut StaticArbitrageReport,
) -> Result<(), StaticArbitrageError> {
const OP: &str = "arbitrage::calendar";
for pair in sorted.windows(2) {
let [earlier, later] = pair else {
continue;
};
for &k in grid {
let earlier_w = earlier.slice.total_variance(k)?;
let later_w = later.slice.total_variance(k)?;
let earlier_rounding = earlier.slice.total_variance_rounding(k)?;
let later_rounding = later.slice.total_variance_rounding(k)?;
let limit = d_mul(
MAX_RELATIVE_ROUNDING,
earlier_w.to_dec().max(later_w.to_dec()),
OP,
)?;
if earlier_rounding.to_dec() > limit || later_rounding.to_dec() > limit {
report
.unevaluated
.push(StaticArbitrageUnevaluatedPoint::Calendar {
earlier_expiry: earlier.expiry,
later_expiry: later.expiry,
log_moneyness: k,
earlier_strike: strike_at(earlier.forward, k)?,
later_strike: strike_at(later.forward, k)?,
earlier_total_variance: earlier_w,
later_total_variance: later_w,
earlier_rounding,
later_rounding,
});
continue;
}
let decrease = d_sub(earlier_w.to_dec(), later_w.to_dec(), OP)?;
let band = d_add(earlier_rounding.to_dec(), later_rounding.to_dec(), OP)?;
if decrease > band {
report.violations.push(StaticArbitrageViolation::Calendar {
earlier_expiry: earlier.expiry,
later_expiry: later.expiry,
log_moneyness: k,
earlier_strike: strike_at(earlier.forward, k)?,
later_strike: strike_at(later.forward, k)?,
earlier_total_variance: earlier_w,
later_total_variance: later_w,
size: Positive::new_decimal(decrease)?,
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::volatility::SviParameters;
use rust_decimal_macros::dec;
fn pos(value: Decimal) -> Positive {
Positive::new_decimal(value).unwrap()
}
#[derive(Debug, Clone, Copy)]
struct Fixed {
expiry: Positive,
forward: Positive,
w: Decimal,
slope: Decimal,
curvature: Decimal,
rounding: Decimal,
}
impl Fixed {
fn new(w: Decimal, slope: Decimal, curvature: Decimal, rounding: Decimal) -> Self {
Self {
expiry: Positive::ONE,
forward: Positive::HUNDRED,
w,
slope,
curvature,
rounding,
}
}
fn with_g_at_zero(g: Decimal, rounding: Decimal) -> Self {
Self::new(
Decimal::ONE,
Decimal::ZERO,
(g - Decimal::ONE) * Decimal::TWO,
rounding,
)
}
}
impl TotalVarianceSlice for Fixed {
fn time_to_expiry(&self) -> Positive {
self.expiry
}
fn forward(&self) -> Positive {
self.forward
}
fn total_variance(&self, _k: Decimal) -> Result<Positive, StaticArbitrageError> {
Ok(pos(self.w))
}
fn total_variance_slope(&self, _k: Decimal) -> Result<Decimal, StaticArbitrageError> {
Ok(self.slope)
}
fn total_variance_curvature(&self, _k: Decimal) -> Result<Decimal, StaticArbitrageError> {
Ok(self.curvature)
}
fn total_variance_rounding(&self, _k: Decimal) -> Result<Positive, StaticArbitrageError> {
Ok(pos(self.rounding))
}
}
#[test]
fn test_density_factor_each_term_of_equation_2_1() {
let factor =
density_factor(dec!(1), dec!(0.5), dec!(0.2), dec!(0.1), Decimal::ZERO).unwrap();
assert_eq!(factor.g, dec!(0.6675));
}
#[test]
fn test_density_factor_matches_references_on_the_positive_side() {
let parameters = SviParameters::try_new(
dec!(-0.0410),
dec!(0.1331),
dec!(0.3060),
dec!(0.3586),
dec!(0.4153),
)
.unwrap();
let slice = SviSlice::try_new(parameters, Positive::HUNDRED, Positive::ONE).unwrap();
for (k, expected) in [
(dec!(-1), dec!(0.2678703587364597144938115851)),
(dec!(0), dec!(1.0386497312817974641928775294)),
(dec!(0.5), dec!(0.0693792113008288652036289243)),
(dec!(0.6), dec!(0.0146894171485162655184938540)),
(dec!(1.5), dec!(0.0291238656581985846785593587)),
(dec!(2), dec!(0.0769961673097418127281712935)),
] {
let factor = density_factor(
k,
SviSlice::total_variance(&slice, k).unwrap().to_dec(),
SviSlice::total_variance_slope(&slice, k).unwrap(),
SviSlice::total_variance_curvature(&slice, k).unwrap(),
TotalVarianceSlice::total_variance_rounding(&slice, k)
.unwrap()
.to_dec(),
)
.unwrap();
let difference = (factor.g - expected).abs();
assert!(
difference < dec!(0.000000000000000000000001),
"k = {k}: {difference}"
);
assert!(
factor.band < dec!(0.0000000000000000000001),
"k = {k}: {}",
factor.band
);
}
}
#[test]
fn test_density_factor_at_a_tiny_root_matches_the_reference() {
let parameters = SviParameters::try_new(
dec!(1.3699999999999999986223081),
dec!(0.000003757),
dec!(0),
dec!(0.847),
dec!(0.0000000000003667),
)
.unwrap();
let slice = SviSlice::try_new(parameters, Positive::HUNDRED, Positive::ONE).unwrap();
let k = dec!(0.84700000000472);
let factor = density_factor(
k,
SviSlice::total_variance(&slice, k).unwrap().to_dec(),
SviSlice::total_variance_slope(&slice, k).unwrap(),
SviSlice::total_variance_curvature(&slice, k).unwrap(),
TotalVarianceSlice::total_variance_rounding(&slice, k)
.unwrap()
.to_dec(),
)
.unwrap();
let difference = (factor.g - dec!(2381.6022677796875342688299535)).abs();
assert!(difference < factor.band, "{difference} vs {}", factor.band);
assert!(difference < dec!(0.00000000000000000001), "{difference}");
}
#[test]
fn test_density_factor_far_from_a_tiny_sigma_matches_the_reference() {
let parameters = SviParameters::try_new(
dec!(0.684),
dec!(0.000001652),
dec!(0),
dec!(-1.153),
dec!(0.0000000000000000000000000664),
)
.unwrap();
let slice = SviSlice::try_new(parameters, Positive::HUNDRED, Positive::ONE).unwrap();
let k = dec!(-1.1530000000000000000104);
let factor = density_factor(
k,
SviSlice::total_variance(&slice, k).unwrap().to_dec(),
SviSlice::total_variance_slope(&slice, k).unwrap(),
SviSlice::total_variance_curvature(&slice, k).unwrap(),
TotalVarianceSlice::total_variance_rounding(&slice, k)
.unwrap()
.to_dec(),
)
.unwrap();
let difference = (factor.g - dec!(3238.5477894615521609485374)).abs();
assert!(difference < factor.band, "{difference} vs {}", factor.band);
}
fn factor_of(slice: &SviSlice, k: Decimal) -> DensityFactor {
density_factor(
k,
SviSlice::total_variance(slice, k).unwrap().to_dec(),
SviSlice::total_variance_slope(slice, k).unwrap(),
SviSlice::total_variance_curvature(slice, k).unwrap(),
TotalVarianceSlice::total_variance_rounding(slice, k)
.unwrap()
.to_dec(),
)
.unwrap()
}
#[test]
fn test_density_factor_large_b_is_not_a_false_violation() {
let parameters = SviParameters::try_new(
dec!(10198617),
dec!(39781903),
dec!(-0.999999),
dec!(0),
dec!(0.000011),
)
.unwrap();
let slice = SviSlice::try_new(parameters, Positive::HUNDRED, Positive::ONE).unwrap();
let k = dec!(0.030430593442154205258362455);
let truth = dec!(0.0000000000000000000009199435);
let factor = factor_of(&slice, k);
let difference = (factor.g - truth).abs();
assert!(difference <= factor.band, "{difference} vs {}", factor.band);
assert!(
check_butterfly(&slice, &[k])
.unwrap()
.violations()
.is_empty()
);
}
#[test]
fn test_density_factor_large_b_stays_within_its_band_with_b_applied_first() {
let parameters = SviParameters::try_new(
dec!(1066743530.278020518029584448),
dec!(354400000),
dec!(-0.999999),
dec!(0),
dec!(0.0009372),
)
.unwrap();
let slice = SviSlice::try_new(parameters, Positive::HUNDRED, Positive::ONE).unwrap();
let factor = factor_of(&slice, dec!(0.662699978302572854858704983));
let difference = (factor.g - dec!(535.7811679546582669017279115)).abs();
assert!(difference <= factor.band, "{difference} vs {}", factor.band);
}
#[test]
fn test_butterfly_slope_at_the_last_place_is_not_lost_to_a_false_pass() {
let last_place = Decimal::new(1, 28);
let exact = Fixed::new(last_place, last_place, dec!(-1), dec!(0));
let report = check_butterfly(&exact, &[Decimal::ONE]).unwrap();
assert!(!report.passed(), "{report:?}");
assert!(report.unevaluated().is_empty(), "{report:?}");
match report.violations() {
[violation] => assert_eq!(violation.size(), pos(dec!(0.25))),
other => panic!("expected one violation, got {other:?}"),
}
let factor = density_factor(
Decimal::ONE,
last_place,
dec!(0.0000000000000000000000000003),
dec!(0),
dec!(0),
)
.unwrap();
assert_eq!(factor.g, dec!(0.25) - dec!(0.0000000000000000000000000002));
}
#[test]
fn test_density_factor_band_covers_the_second_order_of_a_rounded_slope() {
let (w, rounding) = (dec!(1000000000000000), dec!(1000));
let factor = density_factor(dec!(0), w, dec!(0), dec!(0), rounding).unwrap();
assert_eq!(factor.g, dec!(1));
let second_order = dec!(62500.00000000025);
assert!(factor.band >= second_order, "{}", factor.band);
assert!(
factor.band <= second_order + dec!(0.00000000001),
"{}",
factor.band
);
for (g, reported) in [(dec!(-62000), false), (dec!(-63000), true)] {
let slice = Fixed {
curvature: (g - dec!(1)) * dec!(2),
..Fixed::new(w, dec!(0), dec!(0), rounding)
};
let report = check_butterfly(&slice, &[dec!(0)]).unwrap();
assert_eq!(report.violations().len(), usize::from(reported), "g = {g}");
assert!(report.unevaluated().is_empty(), "{report:?}");
}
}
#[test]
fn test_density_factor_large_slope_keeps_its_value_past_the_product() {
let slice = Fixed::new(dec!(0.1), dec!(100000000000000), dec!(0), dec!(0));
let report = check_butterfly(&slice, &[dec!(0)]).unwrap();
match report.violations() {
[StaticArbitrageViolation::Butterfly { g, .. }] => {
assert_eq!(*g, dec!(-25625000000000000000000000000) + dec!(1));
}
other => panic!("expected one violation, got {other:?}"),
}
}
#[test]
fn test_density_factor_small_slope_is_not_lost_below_the_last_place() {
let factor = density_factor(
dec!(0),
dec!(0.00000000000001),
dec!(0.000000000000001),
dec!(0),
dec!(0),
)
.unwrap();
assert_eq!(factor.g, dec!(0.999999999999999975));
}
#[test]
fn test_butterfly_band_is_pinned_from_both_sides_without_rounding() {
for (g, reported) in [
(dec!(-0.00000000000000000000000001), false),
(dec!(-0.00000000000000000000000002), false),
(dec!(-0.00000000000000000000000003), true),
(dec!(-0.0000000000000000000000001), true),
] {
let report =
check_butterfly(&Fixed::with_g_at_zero(g, Decimal::ZERO), &[dec!(0)]).unwrap();
assert_eq!(report.violations().len(), usize::from(reported), "g = {g}");
}
}
#[test]
fn test_butterfly_band_grows_with_the_rounding_of_w() {
let rounding = dec!(0.000000000000000000000001);
for (g, reported) in [
(dec!(-0.000000000000000000000004), false),
(dec!(-0.00000000000000000000000403), true),
] {
let report = check_butterfly(&Fixed::with_g_at_zero(g, rounding), &[dec!(0)]).unwrap();
assert_eq!(report.violations().len(), usize::from(reported), "g = {g}");
}
}
#[test]
fn test_butterfly_resolution_limit_is_pinned_from_both_sides() {
let at = Fixed::new(
dec!(0.000000000001),
dec!(0),
dec!(0),
dec!(0.000000000000000000000001),
);
let report = check_butterfly(&at, &[dec!(0)]).unwrap();
assert!(report.passed(), "{report:?}");
let above = Fixed {
rounding: dec!(0.0000000000000000000000010001),
..at
};
let report = check_butterfly(&above, &[dec!(0)]).unwrap();
assert!(report.violations().is_empty(), "{report:?}");
assert_eq!(report.unevaluated().len(), 1, "{report:?}");
assert!(matches!(
report.unevaluated(),
[StaticArbitrageUnevaluatedPoint::Butterfly { rounding, .. }] if *rounding == pos(above.rounding)
));
}
#[test]
fn test_butterfly_zero_variance_with_a_slope_is_unevaluated() {
let slice = Fixed::new(dec!(0), dec!(0.5), dec!(1), dec!(0));
let report = check_butterfly(&slice, &[dec!(0), dec!(0.5)]).unwrap();
assert!(report.violations().is_empty());
assert_eq!(report.unevaluated().len(), 2);
assert!(!report.passed());
}
#[test]
fn test_calendar_resolution_limit_is_pinned_from_both_sides() {
let at = dec!(0.000000000001);
let later = |rounding| Fixed {
expiry: pos(dec!(2)),
..Fixed::new(dec!(1), dec!(0), dec!(0), rounding)
};
for (w, reported) in [(dec!(1.000000000001), false), (dec!(1.000000000003), true)] {
let earlier = Fixed::new(w, dec!(0), dec!(0), at);
let report = check_calendar(&[earlier, later(at)], &[dec!(0)]).unwrap();
assert!(report.unevaluated().is_empty(), "w = {w}: {report:?}");
assert_eq!(report.violations().len(), usize::from(reported), "w = {w}");
}
let above = dec!(0.00000000000100001);
for (earlier_rounding, later_rounding) in [(above, at), (at, above)] {
let earlier = Fixed::new(dec!(1), dec!(0), dec!(0), earlier_rounding);
let report = check_calendar(&[earlier, later(later_rounding)], &[dec!(0)]).unwrap();
assert!(report.violations().is_empty(), "{report:?}");
assert!(!report.passed());
assert!(matches!(
report.unevaluated(),
[StaticArbitrageUnevaluatedPoint::Calendar {
earlier_rounding: found_earlier,
later_rounding: found_later,
..
}] if found_earlier.to_dec() == earlier_rounding && found_later.to_dec() == later_rounding
));
}
}
#[test]
fn test_calendar_large_declared_rounding_is_unevaluated_not_a_pass() {
for rounding in [dec!(0.5), dec!(1000000), Decimal::MAX] {
let earlier = Fixed {
expiry: pos(dec!(0.5)),
..Fixed::new(dec!(0.5), dec!(0), dec!(0), rounding)
};
let later = Fixed::new(dec!(0.1), dec!(0), dec!(0), rounding);
let report = check_calendar(&[earlier, later], &[dec!(0.25)]).unwrap();
assert!(!report.passed(), "{report:?}");
assert!(report.violations().is_empty(), "{report:?}");
match report.unevaluated() {
[point] => assert_eq!(point.log_moneyness(), dec!(0.25)),
other => panic!("expected one unevaluated point, got {other:?}"),
}
let both = check_static_arbitrage(&[earlier, later], &[dec!(0)]).unwrap();
assert_eq!(both.unevaluated().len(), 3, "{both:?}");
assert!(matches!(
both.unevaluated().last(),
Some(StaticArbitrageUnevaluatedPoint::Calendar { .. })
));
}
}
#[test]
fn test_calendar_exact_zero_total_variances_are_resolved() {
let earlier = Fixed::new(dec!(0), dec!(0), dec!(0), dec!(0));
let later = Fixed {
expiry: pos(dec!(2)),
..earlier
};
assert!(
check_calendar(&[earlier, later], &[dec!(0)])
.unwrap()
.passed()
);
}
#[test]
fn test_calendar_zero_beside_a_large_total_variance_is_resolved() {
let tiny = dec!(0.000000000000000000000000001);
let zero = Fixed::new(dec!(0), dec!(0), dec!(0), tiny);
let one = Fixed::new(dec!(1), dec!(0), dec!(0), tiny);
let later = |slice| Fixed {
expiry: pos(dec!(2)),
..slice
};
assert!(
check_calendar(&[zero, later(one)], &[dec!(0)])
.unwrap()
.passed()
);
let report = check_calendar(&[one, later(zero)], &[dec!(0)]).unwrap();
assert_eq!(report.violations().len(), 1, "{report:?}");
assert!(report.unevaluated().is_empty(), "{report:?}");
}
#[test]
fn test_calendar_band_is_pinned_from_both_sides_without_rounding() {
let later = Fixed {
expiry: pos(dec!(2)),
..Fixed::new(dec!(1), dec!(0), dec!(0), dec!(0))
};
let earlier = Fixed::new(dec!(1), dec!(0), dec!(0), dec!(0));
let equal = check_calendar(&[earlier, later], &[dec!(0)]).unwrap();
assert!(equal.passed(), "{equal:?}");
let last_place = Fixed {
w: dec!(1.0000000000000000000000000001),
..earlier
};
let report = check_calendar(&[last_place, later], &[dec!(0)]).unwrap();
assert_eq!(report.violations().len(), 1, "{report:?}");
}
#[test]
fn test_calendar_band_is_the_sum_of_both_roundings() {
let earlier_rounding = dec!(0.000000000000000000000001);
let later_rounding = dec!(0.000000000000000000000002);
let earlier = |w| Fixed::new(w, dec!(0), dec!(0), earlier_rounding);
let later = Fixed {
expiry: pos(dec!(2)),
..Fixed::new(dec!(1), dec!(0), dec!(0), later_rounding)
};
for (w, reported) in [
(dec!(1.000000000000000000000003), false),
(dec!(1.000000000000000000000004), true),
] {
let report = check_calendar(&[earlier(w), later], &[dec!(0)]).unwrap();
assert_eq!(report.violations().len(), usize::from(reported), "w = {w}");
}
}
#[test]
fn test_check_slice_zero_forward_or_expiry_is_a_typed_error() {
let no_forward = Fixed {
forward: Positive::ZERO,
..Fixed::new(dec!(1), dec!(0), dec!(0), dec!(0))
};
assert!(matches!(
check_butterfly(&no_forward, &[dec!(0)]),
Err(StaticArbitrageError::InvalidSlice { reason, .. }) if reason.contains("forward")
));
let no_expiry = Fixed {
expiry: Positive::ZERO,
..Fixed::new(dec!(1), dec!(0), dec!(0), dec!(0))
};
let good = Fixed::new(dec!(1), dec!(0), dec!(0), dec!(0));
assert!(matches!(
check_calendar(&[good, no_expiry], &[dec!(0)]),
Err(StaticArbitrageError::InvalidSlice { reason, .. }) if reason.contains("expiry")
));
}
#[test]
fn test_strike_at_edges_of_the_range_are_typed_errors() {
assert_eq!(
strike_at(Positive::HUNDRED, dec!(0)).unwrap(),
Positive::HUNDRED
);
for k in [dec!(1000), dec!(-1000), Decimal::MAX, Decimal::MIN] {
assert!(matches!(
strike_at(Positive::HUNDRED, k),
Err(StaticArbitrageError::StrikeOutOfRange { .. })
));
}
}
#[test]
fn test_read_slice_reads_the_expiry_and_the_forward_once() {
use std::cell::Cell;
struct Counting<'a> {
expiry: Positive,
reads: &'a Cell<u32>,
}
impl TotalVarianceSlice for Counting<'_> {
fn time_to_expiry(&self) -> Positive {
self.reads.set(self.reads.get() + 1);
self.expiry
}
fn forward(&self) -> Positive {
self.reads.set(self.reads.get() + 1);
Positive::HUNDRED
}
fn total_variance(&self, _k: Decimal) -> Result<Positive, StaticArbitrageError> {
Ok(pos(dec!(10) - self.expiry.to_dec()))
}
fn total_variance_slope(&self, _k: Decimal) -> Result<Decimal, StaticArbitrageError> {
Ok(dec!(0))
}
fn total_variance_curvature(
&self,
_k: Decimal,
) -> Result<Decimal, StaticArbitrageError> {
Ok(dec!(-4))
}
fn total_variance_rounding(
&self,
_k: Decimal,
) -> Result<Positive, StaticArbitrageError> {
Ok(Positive::ZERO)
}
}
let reads = Cell::new(0);
let slices: Vec<Counting> = (1..=5)
.rev()
.map(|expiry| Counting {
expiry: pos(Decimal::from(expiry)),
reads: &reads,
})
.collect();
let report = check_static_arbitrage(&slices, &[dec!(0), dec!(0.1)]).unwrap();
assert_eq!(report.violations().len(), 2 * 5 + 2 * 4);
assert_eq!(reads.get(), 2 * 5);
}
#[test]
fn test_references_to_slices_are_slices_themselves() {
let earlier = Fixed::new(dec!(0.5), dec!(0), dec!(0), dec!(0));
let later = Fixed {
expiry: pos(dec!(2)),
..Fixed::new(dec!(0.1), dec!(0), dec!(0), dec!(0))
};
let by_value = check_calendar(&[earlier, later], &[dec!(0)]).unwrap();
assert_eq!(by_value.violations().len(), 1);
let by_reference = check_calendar(&[&earlier, &later], &[dec!(0)]).unwrap();
assert_eq!(by_reference, by_value);
let by_object: [&dyn TotalVarianceSlice; 2] = [&earlier, &later];
assert_eq!(check_calendar(&by_object, &[dec!(0)]).unwrap(), by_value);
assert_eq!(
check_butterfly(&&earlier, &[dec!(0)]).unwrap(),
check_butterfly(&earlier, &[dec!(0)]).unwrap()
);
}
#[test]
fn test_svi_slice_implements_the_trait_from_its_own_values() {
let parameters =
SviParameters::try_new(dec!(0.04), dec!(0.2), dec!(-0.4), dec!(0.05), dec!(0.3))
.unwrap();
let slice = SviSlice::try_new(parameters, pos(dec!(90)), pos(dec!(0.5))).unwrap();
let k = dec!(0.1);
let read: &dyn TotalVarianceSlice = &slice;
assert_eq!(read.time_to_expiry(), pos(dec!(0.5)));
assert_eq!(read.forward(), pos(dec!(90)));
assert_eq!(
read.total_variance(k).unwrap(),
slice.total_variance(k).unwrap()
);
assert_eq!(
read.total_variance_slope(k).unwrap(),
slice.total_variance_slope(k).unwrap()
);
assert_eq!(
read.total_variance_curvature(k).unwrap(),
slice.total_variance_curvature(k).unwrap()
);
assert!(read.total_variance_rounding(k).unwrap() < pos(dec!(0.000000000000000000000001)));
}
}