libitofin 0.51.0

A ground-up Rust port of QuantLib: quantitative-finance primitives for pricing, risk, and numerical methods.
Documentation
use super::*;
use crate::exercise::{AmericanExercise, EuropeanExercise};
use crate::handle::Handle;
use crate::instrument::Instrument;
use crate::instruments::{CashOrNothingPayoff, EuropeanOption, StrikedTypePayoff};
use crate::option::OptionType;
use crate::pricingengines::vanilla::test_market::{
    Market, market, quote_handle, time_to_days, today,
};
use crate::quotes::SimpleQuote;
use crate::shared::{shared, shared_mut};

struct JumpMarket {
    market: Market,
    intensity: Shared<SimpleQuote>,
    mean: Shared<SimpleQuote>,
    jump_vol: Shared<SimpleQuote>,
    process: Shared<Merton76Process>,
}

impl JumpMarket {
    fn new(intensity: Real, mean: Real, jump_vol: Real) -> Self {
        let market = market();
        market.set(100.0, 0.02, 0.05, 0.20);
        let intensity = shared(SimpleQuote::new(intensity));
        let mean = shared(SimpleQuote::new(mean));
        let jump_vol = shared(SimpleQuote::new(jump_vol));
        let process = shared(
            Merton76Process::from_black_scholes(
                Shared::clone(&market.process),
                quote_handle(&intensity),
                quote_handle(&mean),
                quote_handle(&jump_vol),
            )
            .unwrap(),
        );
        Self {
            market,
            intensity,
            mean,
            jump_vol,
            process,
        }
    }

    fn option(
        &self,
        option_type: OptionType,
        strike: Real,
        tolerance: Real,
        budget: usize,
    ) -> EuropeanOption {
        let mut option = self
            .market
            .option(option_type, strike, today() + time_to_days(1.0));
        let engine =
            JumpDiffusionEngine::new(Shared::clone(&self.process), tolerance, budget).unwrap();
        option
            .base_mut()
            .set_pricing_engine(shared_mut(engine) as crate::shared::SharedMut<dyn PricingEngine>);
        option
    }
}

fn close(actual: Real, expected: Real, tolerance: Real) {
    assert!(
        (actual - expected).abs() <= tolerance,
        "actual={actual:.17}, expected={expected:.17}, tolerance={tolerance}"
    );
}

fn values(option: &mut EuropeanOption) -> [Real; 7] {
    [
        option.npv().unwrap(),
        option.delta().unwrap(),
        option.gamma().unwrap(),
        option.theta().unwrap(),
        option.vega().unwrap(),
        option.rho().unwrap(),
        option.dividend_rho().unwrap(),
    ]
}

#[test]
fn positive_jump_intensity_underflow_is_rejected_and_recoverable() {
    let m = JumpMarket::new(1.0, -744.0, 0.0);
    m.market.set(100.0, 0.0, 0.0, 0.0);
    let mut option = m.market.option(OptionType::Put, 100.0, today() + 1);
    let engine = JumpDiffusionEngine::new(Shared::clone(&m.process), 1e-12, 1000).unwrap();
    option.base_mut().set_pricing_engine(shared_mut(engine));
    assert!(option.npv().is_err());
    m.mean.set_value(0.0);
    close(option.npv().unwrap(), 0.0, 0.0);
    m.intensity.set_value(1e-300);
    m.mean.set_value(-100.0);
    assert!(
        Merton76Process::from_black_scholes(
            Shared::clone(&m.market.process),
            quote_handle(&m.intensity),
            quote_handle(&m.mean),
            quote_handle(&m.jump_vol),
        )
        .is_err()
    );
}

#[test]
fn zero_jump_is_black_scholes_including_greeks() {
    for option_type in [OptionType::Call, OptionType::Put] {
        let m = JumpMarket::new(0.0, -0.10, 0.30);
        let mut jump = m.option(option_type, 100.0, 1e-12, 1);
        let mut black = m
            .market
            .option(option_type, 100.0, today() + time_to_days(1.0));
        for (actual, expected) in values(&mut jump).into_iter().zip(values(&mut black)) {
            close(actual, expected, 1e-12);
        }
        close(
            jump.theta_per_day().unwrap(),
            jump.theta().unwrap() / 365.0,
            0.0,
        );
    }
}

#[test]
fn haug_merton_cached_prices_preserve_quantlib_tolerance() {
    for (strike, intensity, t, expected) in [
        (80.0, 1.0, 0.10, 20.67),
        (100.0, 5.0, 0.25, 5.96),
        (110.0, 10.0, 0.50, 4.74),
        (120.0, 1.0, 0.50, 2.23),
    ] {
        let gamma: Real = 0.25;
        let diffusion = 0.25 * (1.0 - gamma).sqrt();
        let jump_vol = 0.25 * (gamma / intensity).sqrt();
        let m = JumpMarket::new(intensity, -0.5 * jump_vol * jump_vol, jump_vol);
        m.market.set(100.0, 0.0, 0.08, diffusion);
        let mut option = m
            .market
            .option(OptionType::Call, strike, today() + time_to_days(t));
        option
            .base_mut()
            .set_pricing_engine(shared_mut(
                JumpDiffusionEngine::new(Shared::clone(&m.process), 1e-12, 1000).unwrap(),
            ) as crate::shared::SharedMut<dyn PricingEngine>);
        close(option.npv().unwrap(), expected, 1e-2);
    }
}

#[test]
fn analytic_greeks_agree_with_price_derivatives() {
    let m = JumpMarket::new(0.7, -0.12, 0.3);
    let mut option = m.option(OptionType::Call, 100.0, 1e-14, 1000);
    let expected = values(&mut option);
    let h = 1e-3;
    m.market.spot.set_value(100.0 + h);
    let plus = option.npv().unwrap();
    m.market.spot.set_value(100.0 - h);
    let minus = option.npv().unwrap();
    m.market.spot.set_value(100.0);
    close((plus - minus) / (2.0 * h), expected[1], 2e-8);
    close(
        (plus + minus - 2.0 * expected[0]) / (h * h),
        expected[2],
        2e-7,
    );
    for (quote, value, greek) in [
        (&m.market.vol, 0.20, 4),
        (&m.market.r_rate, 0.05, 5),
        (&m.market.q_rate, 0.02, 6),
    ] {
        let h = 1e-5;
        quote.set_value(value + h);
        let plus = option.npv().unwrap();
        quote.set_value(value - h);
        let minus = option.npv().unwrap();
        quote.set_value(value);
        close((plus - minus) / (2.0 * h), expected[greek], 1e-7);
    }
}

#[test]
fn live_jump_and_market_quotes_invalidate_cached_option() {
    let m = JumpMarket::new(0.7, -0.12, 0.3);
    let mut option = m.option(OptionType::Call, 100.0, 1e-12, 1000);
    let original = option.npv().unwrap();
    for (quote, changed, initial) in [
        (&m.intensity, 1.2, 0.7),
        (&m.mean, -0.25, -0.12),
        (&m.jump_vol, 0.45, 0.3),
        (&m.market.spot, 110.0, 100.0),
        (&m.market.r_rate, 0.08, 0.05),
        (&m.market.q_rate, 0.04, 0.02),
        (&m.market.vol, 0.30, 0.20),
    ] {
        quote.set_value(changed);
        assert!((option.npv().unwrap() - original).abs() > 1e-5);
        quote.set_value(initial);
        close(option.npv().unwrap(), original, 0.0);
    }
}

#[test]
fn invalid_live_quotes_fail_and_recover() {
    let m = JumpMarket::new(0.7, -0.12, 0.3);
    let mut option = m.option(OptionType::Call, 100.0, 1e-12, 1000);
    let original = option.npv().unwrap();
    for (quote, invalid, initial) in [
        (&m.intensity, -1.0, 0.7),
        (&m.intensity, Real::INFINITY, 0.7),
        (&m.mean, Real::NAN, -0.12),
        (&m.mean, 1000.0, -0.12),
        (&m.jump_vol, -0.1, 0.3),
        (&m.jump_vol, Real::MAX, 0.3),
        (&m.market.spot, Real::INFINITY, 100.0),
        (&m.market.spot, 0.0, 100.0),
        (&m.market.vol, Real::INFINITY, 0.20),
        (&m.market.vol, -0.1, 0.20),
    ] {
        quote.set_value(invalid);
        assert!(option.npv().is_err());
        quote.set_value(initial);
        close(option.npv().unwrap(), original, 0.0);
    }
}

#[test]
fn parameters_and_series_budget_are_strictly_bounded() {
    let m = JumpMarket::new(0.7, -0.12, 0.3);
    for tolerance in [0.0, -1.0, Real::NAN, Real::INFINITY] {
        assert!(JumpDiffusionEngine::new(Shared::clone(&m.process), tolerance, 1000).is_err());
    }
    for budget in [0, 100001, usize::MAX] {
        assert!(JumpDiffusionEngine::new(Shared::clone(&m.process), 1e-12, budget).is_err());
    }
    assert!(m.option(OptionType::Call, 100.0, 1e-12, 1).npv().is_err());
    assert!(m.option(OptionType::Call, 100.0, 2.0, 1000).npv().unwrap() > 0.0);
    m.intensity.set_value(100.0);
    assert!(m.option(OptionType::Call, 100.0, 1e-12, 10).npv().is_err());
    m.mean.set_value(-10.0);
    m.jump_vol.set_value(0.0);
    assert!(m.option(OptionType::Put, 100.0, 1e-12, 10).npv().is_err());
    assert!(
        Merton76Process::from_black_scholes(
            Shared::clone(&m.market.process),
            Handle::empty(),
            quote_handle(&m.mean),
            quote_handle(&m.jump_vol)
        )
        .is_err()
    );
}

#[test]
fn zero_diffusion_and_zero_strike_do_not_create_nan_greeks() {
    for (intensity, mean, jump_vol) in [(0.0, 0.0, 0.0), (0.7, -0.12, 0.3), (0.7, -0.12, 0.0)] {
        let m = JumpMarket::new(intensity, mean, jump_vol);
        m.market.vol.set_value(0.0);
        for option_type in [OptionType::Call, OptionType::Put] {
            for strike in [0.0, 100.0, 120.0] {
                assert!(
                    values(&mut m.option(option_type, strike, 1e-12, 1000))
                        .iter()
                        .all(|x| x.is_finite())
                );
            }
        }
    }
}

#[test]
fn unsupported_payoffs_exercises_and_path_methods_return_errors() {
    let m = JumpMarket::new(0.7, -0.12, 0.3);
    assert!(m.process.drift(0.0, 100.0).is_err());
    assert!(m.process.diffusion(0.0, 100.0).is_err());
    assert!(m.process.apply(100.0, 0.1).is_err());
    close(m.process.x0().unwrap(), 100.0, 0.0);
    close(m.process.time(&(today() + 360)).unwrap(), 1.0, 0.0);
    let mut engine = JumpDiffusionEngine::new(Shared::clone(&m.process), 1e-12, 1000).unwrap();
    let args = (engine.arguments_mut() as &mut dyn Any)
        .downcast_mut::<OptionArguments>()
        .unwrap();
    args.payoff = Some(
        shared(CashOrNothingPayoff::new(OptionType::Call, 100.0, 10.0))
            as Shared<dyn StrikedTypePayoff>,
    );
    args.exercise = Some(shared(EuropeanExercise::new(today() + 360)));
    assert!(engine.calculate().is_err());
    let args = (engine.arguments_mut() as &mut dyn Any)
        .downcast_mut::<OptionArguments>()
        .unwrap();
    args.payoff = Some(shared(PlainVanillaPayoff::new(OptionType::Call, 100.0)));
    args.exercise = Some(shared(
        AmericanExercise::new(today(), today() + 360, false).unwrap(),
    ));
    assert!(engine.calculate().is_err());
}

mod oracle;