sanos 0.2.1

SANOS: Smooth strictly Arbitrage-free Non-parametric Option Surfaces (Rust implementation)
Documentation
use log::{debug, info};

use crate::backbone::bs::bs_implied_atm_var_from_call;
use crate::backbone::config::BsTimeChangedConfig;
use crate::backbone::lognormal_tc::TimeChangedLognormal;
use crate::error::{SanosError, SanosResult};
use crate::market::OptionBook;
use crate::term::PiecewiseLinearCurve;

pub fn build_time_changed_lognormal_from_book(
    book: &OptionBook,
    cfg: &BsTimeChangedConfig,
) -> SanosResult<TimeChangedLognormal> {
    cfg.validate()?;

    let atm_policy = cfg.atm_policy.build()?;
    let atm_calls = book.atm_mids(atm_policy.as_ref())?;

    if atm_calls.is_empty() {
        return Err(SanosError::EmptyCollection {
            what: "OptionBook.atm_mids",
        });
    }

    info!(
        "Building BS time-changed backbone from {} maturities (var_floor={}, enforce_non_decreasing={})",
        atm_calls.len(),
        cfg.var_floor,
        cfg.enforce_non_decreasing
    );

    let mut knots: Vec<(f64, f64)> = Vec::with_capacity(atm_calls.len());
    for (t, c_atm) in atm_calls {
        if !t.is_finite() {
            return Err(SanosError::NonFinite {
                field: "maturity",
                value: t,
            });
        }
        if t <= 0.0 {
            return Err(SanosError::InvalidBound {
                field: "maturity",
                value: t,
                min: f64::MIN_POSITIVE,
                max: f64::INFINITY,
            });
        }

        let w = bs_implied_atm_var_from_call(c_atm)?;
        let w = w.max(cfg.var_floor);
        debug!("ATM var knot: T={} => W(T)={} (C_atm={})", t, w, c_atm);
        knots.push((t, w));
    }

    if cfg.enforce_non_decreasing {
        let mut prev = knots[0].1;
        for knot in knots.iter_mut().skip(1) {
            if knot.1 < prev {
                debug!(
                    "Clamping ATM total variance: T={} W_old={} W_new={}",
                    knot.0, knot.1, prev
                );
                knot.1 = prev;
            } else {
                prev = knot.1;
            }
        }
    }

    let curve = PiecewiseLinearCurve::new(knots)?;
    Ok(TimeChangedLognormal::new(curve, cfg.eta)
        .with_effective_var_floor(cfg.effective_var_floor))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backbone::bs::bs_call_forward_norm;
    use crate::backbone::config::AtmMidPolicyConfig;
    use crate::market::{CallQuote, OptionChain};

    fn assert_close(actual: f64, expected: f64, tol: f64) {
        assert!(
            (actual - expected).abs() <= tol,
            "expected {expected}, got {actual}, tol={tol}"
        );
    }

    fn chain_from_atm_var(maturity: f64, atm_var: f64) -> OptionChain {
        let atm_call = bs_call_forward_norm(1.0, atm_var).unwrap();
        let q = CallQuote::new(1.0, atm_call, atm_call, 1.0).unwrap();
        OptionChain::new(maturity, vec![q]).unwrap()
    }

    fn book_from_pairs(pairs: &[(f64, f64)]) -> OptionBook {
        let chains: Vec<OptionChain> = pairs
            .iter()
            .map(|&(t, w)| chain_from_atm_var(t, w))
            .collect();
        OptionBook::new(chains).unwrap()
    }

    #[test]
    fn builder_recovers_input_atm_variances() {
        let book = book_from_pairs(&[(0.5, 0.04), (1.0, 0.09)]);
        let cfg = BsTimeChangedConfig {
            atm_policy: AtmMidPolicyConfig::default(),
            var_floor: 0.0,
            enforce_non_decreasing: false,
            eta: 0.25,
            ..BsTimeChangedConfig::default()
        };

        let model = build_time_changed_lognormal_from_book(&book, &cfg).unwrap();
        assert_close(model.var(0.5).unwrap(), 0.04, 1e-10);
        assert_close(model.var(1.0).unwrap(), 0.09, 1e-10);
    }

    #[test]
    fn builder_applies_var_floor() {
        let book = book_from_pairs(&[(0.5, 0.0)]);
        let cfg = BsTimeChangedConfig {
            atm_policy: AtmMidPolicyConfig::default(),
            var_floor: 0.02,
            enforce_non_decreasing: true,
            eta: 0.25,
            ..BsTimeChangedConfig::default()
        };

        let model = build_time_changed_lognormal_from_book(&book, &cfg).unwrap();
        assert_close(model.var(0.5).unwrap(), 0.02, 1e-14);
    }

    #[test]
    fn builder_clamps_decreasing_variance_when_enabled() {
        let book = book_from_pairs(&[(0.5, 0.09), (1.0, 0.04)]);
        let cfg = BsTimeChangedConfig {
            atm_policy: AtmMidPolicyConfig::default(),
            var_floor: 0.0,
            enforce_non_decreasing: true,
            eta: 0.25,
            ..BsTimeChangedConfig::default()
        };

        let model = build_time_changed_lognormal_from_book(&book, &cfg).unwrap();
        assert_close(model.var(0.5).unwrap(), 0.09, 1e-10);
        assert_close(model.var(1.0).unwrap(), 0.09, 1e-10);
    }

    #[test]
    fn builder_keeps_decreasing_variance_when_disabled() {
        let book = book_from_pairs(&[(0.5, 0.09), (1.0, 0.04)]);
        let cfg = BsTimeChangedConfig {
            atm_policy: AtmMidPolicyConfig::default(),
            var_floor: 0.0,
            enforce_non_decreasing: false,
            eta: 0.25,
            ..BsTimeChangedConfig::default()
        };

        let model = build_time_changed_lognormal_from_book(&book, &cfg).unwrap();
        assert_close(model.var(0.5).unwrap(), 0.09, 1e-10);
        assert_close(model.var(1.0).unwrap(), 0.04, 1e-10);
    }
}