diff --git a/crates/itofin-py/python/itofin/termstructures.pyi b/crates/itofin-py/python/itofin/termstructures.pyi index f3458d8..7dd663f 100644 --- a/crates/itofin-py/python/itofin/termstructures.pyi +++ b/crates/itofin-py/python/itofin/termstructures.pyi @@ -615,6 +615,12 @@ class OptionletVolatilityStructure: def black_variance( self, option_tenor: Period, strike: float, extrapolate: bool = False ) -> float: ... + def allows_extrapolation(self) -> bool: ... + def enable_extrapolation(self) -> None: + """A stripped surface ends at its last optionlet fixing, so a cap whose + own last caplet fixes there queries the boundary.""" + ... + def disable_extrapolation(self) -> None: ... def displacement(self) -> float: """The lognormal shift applied to forwards and strikes. This is what BlackCapFloorEngine checks a caller-supplied displacement against.""" @@ -625,7 +631,7 @@ class ConstantOptionletVolatility(OptionletVolatilityStructure): Both constructors pin the reference date, so every query's option time runs from reference_date rather than the evaluation date. The moving (floating - reference date) forms are not exposed.""" + reference date) forms are not exposed; tracked as #627.""" def __init__( self, @@ -661,10 +667,14 @@ class CapFloorTermVolSurface: OptionletVolatilityStructure. volatilities is a row per option tenor and a column per strike; both axes - must be strictly increasing. The moving (floating reference date) - constructors are not exposed - both forms here pin the reference date, so - every query's option time runs from reference_date rather than the - evaluation date.""" + must be strictly increasing. + + All four constructors are exposed. __init__ and with_quotes pin the + reference date, so every query's option time runs from reference_date rather + than the evaluation date. moving and moving_with_quotes float it + settlement_days off the evaluation date, and are what the optionlet + stripping pipeline runs on: StrippedOptionletAdapter reads its settlement + days back off this surface, and a pinned-reference surface has none.""" def __init__( self, @@ -693,6 +703,34 @@ class CapFloorTermVolSurface: """Reads each node from the caller's quote; a later set_value rebuilds the interpolation and notifies the surface's observers.""" ... + @staticmethod + def moving( + settlement_days: int, + calendar: Calendar, + business_day_convention: BusinessDayConvention, + option_tenors: list[Period], + strikes: list[float], + volatilities: list[list[float]], + day_counter: DayCounter, + settings: Settings, + ) -> CapFloorTermVolSurface: + """The reference date floats settlement_days business days off the + evaluation date. This is the form OptionletStripper1 and + StrippedOptionletAdapter need.""" + ... + @staticmethod + def moving_with_quotes( + settlement_days: int, + calendar: Calendar, + business_day_convention: BusinessDayConvention, + option_tenors: list[Period], + strikes: list[float], + volatilities: list[list[SimpleQuote]], + day_counter: DayCounter, + settings: Settings, + ) -> CapFloorTermVolSurface: + """The floating-reference surface over the caller's quotes.""" + ... def volatility( self, option_tenor: Period, strike: float, extrapolate: bool = False ) -> float: ... @@ -705,3 +743,56 @@ class CapFloorTermVolSurface: """length is a year fraction off the reference date in the surface's own day count.""" ... + +class OptionletStripper1: + """Bootstraps caplet volatilities out of a market cap/floor term-volatility + surface. + + Not itself a volatility surface: it produces a grid of caplet volatilities + that StrippedOptionletAdapter interpolates into one. Stripping is lazy and + cached, and re-runs only when a surface quote or the index changes. + + term_vol_surface must come from CapFloorTermVolSurface.moving or + moving_with_quotes; a pinned-reference surface carries no settlement days + and fails the adapter. VolatilityType.Normal is deferred (#440/#577) and + fails at the strip, not at construction.""" + + def __init__( + self, + term_vol_surface: CapFloorTermVolSurface, + ibor_index: Euribor, + volatility_type: VolatilityType, + accuracy: float = 1e-6, + max_iter: int = 100, + displacement: float = 0.0, + discount: YieldTermStructure | None = None, + optionlet_frequency: Period | None = None, + ) -> None: + """discount None falls back to the index's own forwarding curve; + optionlet_frequency None uses the index tenor as the caplet step.""" + ... + def switch_strike(self) -> float: + """The mean at-the-money caplet rate, which decides whether each strike + is stripped out of caps or out of floors. The first call strips.""" + ... + def atm_optionlet_rates(self) -> list[float]: + """The at-the-money forward rate of each caplet, one per maturity.""" + ... + +class StrippedOptionletAdapter(OptionletVolatilityStructure): + """Serves a stripper's caplet volatility grid as an + OptionletVolatilityStructure: linear in strike within each maturity, then + linear across maturities. + + This closes the cap/floor volatility loop - a BlackCapFloorEngine on this + surface reprices the caps the term volatilities were quoted on. The + reference date floats off the evaluation date carried by settings, advanced + by the term-volatility surface's settlement days. The surface ends at the + last caplet fixing, so pricing a cap that reaches it wants + enable_extrapolation().""" + + def __init__(self, stripper: OptionletStripper1, settings: Settings) -> None: + """Strips eagerly, to snapshot the strike domain and maximum date. + Raises ItofinError on a stripper whose term-volatility surface carries + no settlement days.""" + ... diff --git a/crates/itofin-py/src/capfloortermvol.rs b/crates/itofin-py/src/capfloortermvol.rs index 9104819..054f576 100644 --- a/crates/itofin-py/src/capfloortermvol.rs +++ b/crates/itofin-py/src/capfloortermvol.rs @@ -14,14 +14,19 @@ //! `SabrSmileSection` precedent). A base can be introduced if a second one ever //! lands. //! -//! Deferred (visible): the two MOVING constructors (`moving` / -//! `moving_from_matrix`, whose reference date floats `settlement_days` off the -//! evaluation date) are not exposed. Both fixed-reference forms are, so a -//! caller can pin the reference date and still read the surface off live -//! quotes, which is the path the stripper consumes. +//! All four core constructors are exposed: a fixed reference date or one +//! floating `settlement_days` off the evaluation date, each over fixed +//! volatilities or over the caller's quotes. The moving pair is not optional +//! polish here - it is what the optionlet stripping pipeline runs on. The +//! adapter that serves the stripped surface reads its settlement days from this +//! surface (`strippedoptionletadapter.rs:87` through +//! `optionletstripper.rs:241`), and a fixed-reference term structure has none, +//! so a surface built by the fixed forms fails the adapter with `"settlement +//! days not provided for this instance"`. use crate::PyQlError; use crate::market::PySimpleQuote; +use crate::settings::PySettings; use crate::time::{PyBusinessDayConvention, PyCalendar, PyDate, PyDayCounter, PyPeriod}; use libitofin::handle::Handle; use libitofin::math::matrix::Matrix; @@ -115,6 +120,78 @@ impl PyCapFloorTermVolSurface { Ok(PyCapFloorTermVolSurface { inner }) } + /// A surface whose reference date floats `settlement_days` business days + /// off the evaluation date, over fixed volatilities. + /// + /// This is the form the optionlet stripping pipeline needs: unlike the + /// pinned-reference constructors, it carries the settlement days + /// `StrippedOptionletAdapter` reads back off the stripper. + #[staticmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (settlement_days, calendar, business_day_convention, option_tenors, strikes, volatilities, day_counter, settings))] + fn moving( + settlement_days: u32, + calendar: &PyCalendar, + business_day_convention: &PyBusinessDayConvention, + option_tenors: Vec>, + strikes: Vec, + volatilities: Vec>, + day_counter: &PyDayCounter, + settings: &PySettings, + ) -> PyResult { + let volatilities = matrix_from_rows(&volatilities)?; + let inner = shared( + CapFloorTermVolSurface::moving_from_matrix( + settlement_days, + calendar.inner(), + business_day_convention.inner(), + tenors(&option_tenors), + strikes, + &volatilities, + day_counter.inner(), + settings.inner(), + ) + .map_err(PyQlError::from)?, + ); + Ok(PyCapFloorTermVolSurface { inner }) + } + + /// The same floating-reference surface reading each node from the caller's + /// quote. + #[staticmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (settlement_days, calendar, business_day_convention, option_tenors, strikes, volatilities, day_counter, settings))] + fn moving_with_quotes( + settlement_days: u32, + calendar: &PyCalendar, + business_day_convention: &PyBusinessDayConvention, + option_tenors: Vec>, + strikes: Vec, + volatilities: Vec>>, + day_counter: &PyDayCounter, + settings: &PySettings, + ) -> PyResult { + check_grid(&volatilities)?; + let volatilities: Vec>> = volatilities + .iter() + .map(|row| row.iter().map(|quote| quote.handle()).collect()) + .collect(); + let inner = shared( + CapFloorTermVolSurface::moving( + settlement_days, + calendar.inner(), + business_day_convention.inner(), + tenors(&option_tenors), + strikes, + volatilities, + day_counter.inner(), + settings.inner(), + ) + .map_err(PyQlError::from)?, + ); + Ok(PyCapFloorTermVolSurface { inner }) + } + /// The cap volatility for a cap tenor and strike. #[pyo3(signature = (option_tenor, strike, extrapolate = false))] fn volatility(&self, option_tenor: &PyPeriod, strike: f64, extrapolate: bool) -> PyResult { @@ -147,7 +224,6 @@ impl PyCapFloorTermVolSurface { impl PyCapFloorTermVolSurface { /// The wrapped core surface for the optionlet-stripper facade, which takes /// the concrete type rather than a handle. - #[allow(dead_code)] pub(crate) fn inner(&self) -> Shared { Shared::clone(&self.inner) } diff --git a/crates/itofin-py/src/lib.rs b/crates/itofin-py/src/lib.rs index ca12467..5f4cc6f 100644 --- a/crates/itofin-py/src/lib.rs +++ b/crates/itofin-py/src/lib.rs @@ -44,7 +44,10 @@ use hullwhite::{PyEuribor, PyHullWhite, PySwaptionHelper}; use libitofin::errors::QlError; use market::{PyBlackScholesProcess, PySimpleQuote}; use option::{PyOptionType, PyVanillaOption}; -use optionletvol::{PyConstantOptionletVolatility, PyOptionletVolatilityStructure}; +use optionletvol::{ + PyConstantOptionletVolatility, PyOptionletStripper1, PyOptionletVolatilityStructure, + PyStrippedOptionletAdapter, +}; use pyo3::create_exception; use pyo3::exceptions::PyException; use pyo3::prelude::*; @@ -155,6 +158,8 @@ fn itofin(m: &Bound<'_, PyModule>) -> PyResult<()> { termstructures.add_class::()?; termstructures.add_class::()?; termstructures.add_class::()?; + termstructures.add_class::()?; + termstructures.add_class::()?; let processes = PyModule::new(py, "processes")?; processes.add_class::()?; diff --git a/crates/itofin-py/src/optionletvol.rs b/crates/itofin-py/src/optionletvol.rs index 8c84a4d..28bf4ff 100644 --- a/crates/itofin-py/src/optionletvol.rs +++ b/crates/itofin-py/src/optionletvol.rs @@ -1,6 +1,8 @@ //! Facades for the optionlet (caplet/floorlet) volatility stack: the -//! [`PyOptionletVolatilityStructure`] base and the constant surface -//! [`PyConstantOptionletVolatility`]. +//! [`PyOptionletVolatilityStructure`] base, the constant surface +//! [`PyConstantOptionletVolatility`], and the stripping pair +//! [`PyOptionletStripper1`] / [`PyStrippedOptionletAdapter`] that turns market +//! cap term volatilities into a caplet surface. //! //! The base holds the erased `Handle` and //! exposes the queries every concrete surface inherits; concrete surfaces @@ -16,19 +18,25 @@ //! Deferred (visible): the MOVING `ConstantOptionletVolatility` constructors //! (`moving` / `moving_with_quote`, whose reference date floats off the //! evaluation date) are not exposed; only the fixed-reference-date `new` and -//! `with_quote` are, as for the constant swaption surface. +//! `with_quote` are, as for the constant swaption surface. Tracked as #627. //! `BlackCapFloorEngine.with_flat_vol` builds a moving surface internally, but //! that is the engine's business, not this facade's. use crate::PyQlError; +use crate::capfloortermvol::PyCapFloorTermVolSurface; +use crate::curve::PyYieldTermStructure; +use crate::hullwhite::PyEuribor; use crate::market::PySimpleQuote; +use crate::settings::PySettings; use crate::swaptionvol::PyVolatilityType; use crate::time::{PyBusinessDayConvention, PyCalendar, PyDate, PyDayCounter, PyPeriod}; use libitofin::handle::Handle; use libitofin::shared::{Shared, shared}; use libitofin::termstructures::volatility::{ - ConstantOptionletVolatility, OptionletVolatilityStructure, + ConstantOptionletVolatility, OptionletStripper1, OptionletVolatilityStructure, + StrippedOptionletAdapter, StrippedOptionletBase, }; +use libitofin::termstructures::yieldtermstructure::YieldTermStructure; use pyo3::prelude::*; /// Python `OptionletVolatilityStructure`: the shared base for every caplet @@ -90,6 +98,38 @@ impl PyOptionletVolatilityStructure { .map_err(PyQlError::from)?) } + /// Whether the surface answers dates/times beyond its maximum. + fn allows_extrapolation(&self) -> PyResult { + Ok(self + .inner + .current_link() + .map_err(PyQlError::from)? + .allows_extrapolation()) + } + + /// Allows extrapolation past the maximum date/time. + /// + /// A stripped surface ends at its last optionlet fixing, so a cap whose own + /// last caplet fixes there queries the boundary; the core's round-trip + /// fixture enables extrapolation before repricing + /// (`strippedoptionletadapter.rs:393`). + fn enable_extrapolation(&self) -> PyResult<()> { + self.inner + .current_link() + .map_err(PyQlError::from)? + .enable_extrapolation(); + Ok(()) + } + + /// Forbids extrapolation past the maximum date/time. + fn disable_extrapolation(&self) -> PyResult<()> { + self.inner + .current_link() + .map_err(PyQlError::from)? + .disable_extrapolation(); + Ok(()) + } + /// The lognormal shift applied to forwards and strikes; `0.0` for the /// unshifted lognormal and the normal model. /// @@ -194,3 +234,131 @@ impl PyConstantOptionletVolatility { ) } } + +/// Python `OptionletStripper1`: bootstraps caplet volatilities out of a market +/// cap/floor term-volatility surface +/// (`termstructures::volatility::OptionletStripper1`). +/// +/// The stripper is not itself a volatility surface: it produces a grid of +/// caplet volatilities that [`PyStrippedOptionletAdapter`] interpolates into +/// one. It prices a cap at each of its own lengths off `term_vol_surface`, +/// differences consecutive prices into a single caplet price, and inverts that +/// for the caplet's implied volatility. +/// +/// Stripping is lazy and cached: nothing runs until a query needs the grid, and +/// it re-runs only when a surface quote or the index changes. The +/// [`Normal`](crate::swaptionvol::PyVolatilityType::Normal) model is deferred +/// (#440/#577) and fails at the strip rather than at construction. +#[pyclass(name = "OptionletStripper1", unsendable)] +pub struct PyOptionletStripper1 { + inner: Shared, +} + +#[pymethods] +impl PyOptionletStripper1 { + /// A stripper over `term_vol_surface` and `ibor_index`. + /// + /// `term_vol_surface` must be one of the MOVING forms: the adapter reads + /// its settlement days back off the surface, and a pinned-reference surface + /// has none. `discount` is the curve the caps are priced on; `None` falls + /// back to the index's own forwarding curve. `accuracy` and `max_iter` size + /// the implied-volatility solve, and `optionlet_frequency` overrides the + /// index tenor as the caplet step. + #[new] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (term_vol_surface, ibor_index, volatility_type, accuracy = 1e-6, max_iter = 100, displacement = 0.0, discount = None, optionlet_frequency = None))] + fn new( + term_vol_surface: &PyCapFloorTermVolSurface, + ibor_index: &PyEuribor, + volatility_type: PyVolatilityType, + accuracy: f64, + max_iter: u32, + displacement: f64, + discount: Option<&PyYieldTermStructure>, + optionlet_frequency: Option<&PyPeriod>, + ) -> PyResult { + let discount = match discount { + Some(curve) => curve.handle(), + None => Handle::::empty(), + }; + Ok(PyOptionletStripper1 { + inner: shared( + OptionletStripper1::new( + term_vol_surface.inner(), + ibor_index.inner(), + discount, + accuracy, + max_iter, + volatility_type.inner(), + displacement, + optionlet_frequency.map(|period| period.inner()), + ) + .map_err(PyQlError::from)?, + ), + }) + } + + /// The floating switch strike: the mean at-the-money caplet rate, which + /// decides whether each strike is stripped out of caps or out of floors. + /// + /// Fallible, and the first call triggers the strip. + fn switch_strike(&self) -> PyResult { + Ok(self.inner.switch_strike().map_err(PyQlError::from)?) + } + + /// The at-the-money forward rate of each caplet, one per maturity. + fn atm_optionlet_rates(&self) -> PyResult> { + Ok(self.inner.atm_optionlet_rates().map_err(PyQlError::from)?) + } +} + +impl PyOptionletStripper1 { + /// The wrapped stripper, erased to the trait the adapter takes. + fn erased(&self) -> Shared { + Shared::clone(&self.inner) as Shared + } +} + +/// Python `StrippedOptionletAdapter`: serves a stripper's caplet volatility +/// grid as an [`PyOptionletVolatilityStructure`] +/// (`termstructures::volatility::StrippedOptionletAdapter`). +/// +/// This is what closes the cap/floor volatility loop: a +/// [`PyBlackCapFloorEngine`](crate::capfloorengine::PyBlackCapFloorEngine) +/// built on this surface reprices the caps the term volatilities were quoted +/// on. Linear in strike within each maturity, then linear across maturities. +/// +/// Extends [`PyOptionletVolatilityStructure`] and supplies only the +/// constructor; the query surface is inherited. Its reference date floats off +/// the evaluation date carried by `settings`, advanced by the settlement days +/// the underlying term-volatility surface carries. The surface ends at the last +/// caplet fixing, so pricing a cap that reaches it wants +/// `enable_extrapolation()`. +#[pyclass(name = "StrippedOptionletAdapter", extends = PyOptionletVolatilityStructure, unsendable)] +pub struct PyStrippedOptionletAdapter; + +#[pymethods] +impl PyStrippedOptionletAdapter { + /// The interpolated surface over `stripper`. + /// + /// Fallible, and it strips eagerly: the constructor reads the caplet + /// strikes and fixing dates to snapshot its strike domain and maximum date. + /// It fails on a stripper whose term-volatility surface carries no + /// settlement days, which is every pinned-reference surface. + #[new] + fn new( + stripper: &PyOptionletStripper1, + settings: &PySettings, + ) -> PyResult> { + let adapter = shared( + StrippedOptionletAdapter::new(stripper.erased(), settings.inner()) + .map_err(PyQlError::from)?, + ) as Shared; + Ok( + PyClassInitializer::from(PyOptionletVolatilityStructure::from_handle(Handle::new( + adapter, + ))) + .add_subclass(PyStrippedOptionletAdapter), + ) + } +} diff --git a/crates/itofin-py/tests/test_capfloor_term_vol.py b/crates/itofin-py/tests/test_capfloor_term_vol.py index 6ec6a77..f981f3c 100644 --- a/crates/itofin-py/tests/test_capfloor_term_vol.py +++ b/crates/itofin-py/tests/test_capfloor_term_vol.py @@ -7,8 +7,9 @@ core's dimension check outright, and a swapped axis fails numerically, so the node-recovery arm cannot pass on a facade that crosses the axes. -Neither constructor here needs a ``Settings``: both pin the reference date, so -no query reads an evaluation date. +The two pinned constructors need no ``Settings``: they fix the reference date, +so no query reads an evaluation date. The two moving ones do, and the fixture's +``SETTINGS`` sits on ``REFERENCE`` so all four agree on the reference date. Four arms: @@ -35,13 +36,22 @@ D. The shape guard. A ragged grid is rejected as an ``ItofinError`` before it reaches the core, where a short row would index a `Matrix` out of bounds and panic across the FFI boundary. +E. The two MOVING constructors (#623), whose reference date floats + ``settlement_days`` off the evaluation date rather than being pinned. They + are a distinct code path, not a thin wrapper: they carry the settlement days + the optionlet-stripping adapter reads back, and the quote form registers + market data the matrix form does not. Both go through arm A, so a + transposed grid cannot hide behind the flat fixture the stripping oracle + uses; the quote form also goes through arm B's bump. At zero settlement days + their reference date IS the evaluation date, which the fixture sets to + REFERENCE, so they must recover exactly the pinned surface's nodes. """ import datetime import pytest -from itofin import ItofinError +from itofin import ItofinError, Settings from itofin.quotes import SimpleQuote from itofin.termstructures import CapFloorTermVolSurface from itofin.time import BusinessDayConvention, Calendar, DayCounter, Date, Period @@ -59,6 +69,9 @@ OFF_NODE_TENOR = Period(18, "Months") OFF_NODE_STRIKE = 0.025 +SETTINGS = Settings() +SETTINGS.set_evaluation_date(REFERENCE) + def _matrix_surface(): return CapFloorTermVolSurface( @@ -87,6 +100,38 @@ def _quote_surface(): return surface, quotes +def _moving_matrix_surface(): + """The floating-reference surface over fixed volatilities, at zero settlement + days so its reference date is the evaluation date.""" + return CapFloorTermVolSurface.moving( + 0, + Calendar.target(), + BDC, + OPTION_TENORS, + STRIKES, + VOLS, + DayCounter.actual365_fixed(), + SETTINGS, + ) + + +def _moving_quote_surface(): + """The floating-reference surface over a distinct quote per node, plus those + quotes.""" + quotes = [[SimpleQuote(vol) for vol in row] for row in VOLS] + surface = CapFloorTermVolSurface.moving_with_quotes( + 0, + Calendar.target(), + BDC, + OPTION_TENORS, + STRIKES, + quotes, + DayCounter.actual365_fixed(), + SETTINGS, + ) + return surface, quotes + + def _year_fraction(start, end): """Actual/365F between two ``Date``s, computed off Python's own calendar.""" days = datetime.date(end.year, end.month, end.day) - datetime.date( @@ -95,7 +140,15 @@ def _year_fraction(start, end): return days.days / 365.0 -@pytest.mark.parametrize("build", [_matrix_surface, lambda: _quote_surface()[0]]) +@pytest.mark.parametrize( + "build", + [ + _matrix_surface, + lambda: _quote_surface()[0], + _moving_matrix_surface, + lambda: _moving_quote_surface()[0], + ], +) def test_every_node_vol_comes_back(build): surface = build() for i, tenor in enumerate(OPTION_TENORS): @@ -104,8 +157,9 @@ def test_every_node_vol_comes_back(build): assert got == pytest.approx(VOLS[i][j], abs=TOL), f"node ({i},{j})" -def test_a_quote_bump_refreshes_only_its_own_node(): - surface, quotes = _quote_surface() +@pytest.mark.parametrize("build", [_quote_surface, _moving_quote_surface]) +def test_a_quote_bump_refreshes_only_its_own_node(build): + surface, quotes = build() assert surface.volatility(OPTION_TENORS[0], STRIKES[0]) == pytest.approx( VOLS[0][0], abs=TOL ) diff --git a/crates/itofin-py/tests/test_optionlet_stripping.py b/crates/itofin-py/tests/test_optionlet_stripping.py new file mode 100644 index 0000000..585afc7 --- /dev/null +++ b/crates/itofin-py/tests/test_optionlet_stripping.py @@ -0,0 +1,239 @@ +"""Oracle for the optionlet-stripping facades (issue #623): the discriminating +round-trip for the whole cap/floor volatility vertical. + +The fixture mirrors the core's own round-trip test, +``flat_term_vol_round_trips_through_the_stripped_optionlets`` +(``strippedoptionletadapter.rs:334``), which is the Rust port of +``optionletstripper.cpp`` ``testFlatTermVolatilityStripping1`` (``:489-548``): +evaluation date 28-October-2013, a flat 4% Actual/365F curve, Euribor6M on it, +and a FLAT 18% cap/floor term-vol surface over 1Y..10Y x 1%..10%. + +The surface must be built by ``moving``, not by the pinned-reference +constructor. ``StrippedOptionletAdapter`` reads its settlement days back off the +term-vol surface (``strippedoptionletadapter.rs:87`` through +``optionletstripper.rs:241``), and a pinned-reference term structure has none, +so the pinned forms fail the adapter with "settlement days not provided for this +instance". The curve may stay pinned: the round-trip identity is curve-agnostic, +since both engines share it. + +Four arms: + +A. THE ROUND-TRIP, at 2.5e-8. For each of the 100 (tenor, strike) grid points, + the same cap is priced twice: once on a BlackCapFloorEngine over the stripped + adapter, once on an engine over a flat 18% quote. The two paths could hardly + differ more - the stripped path runs surface -> per-caplet bootstrap -> + optionlet grid -> linear interpolation in strike and time -> Black, while the + flat path is one constant surface - so agreement is a statement that the + strip round-trips, not a tautology. + + The tolerance is 2.5e-8, taken from the C++ ``vars.tolerance`` + (``optionletstripper.cpp:78``) and carried by the core's own test, NOT + widened. It is that tight for a structural reason: the stripper's cap lengths + step by the 6M index tenor (``optionletstripper.rs:128-151``), so each + stripped optionlet corresponds to exactly one caplet and the price + differencing telescopes exactly; and every cap here is priced at a grid tenor + and a grid strike, which are interpolation NODES, where the adapter's linear + interpolation is exact. What is left is the implied-volatility solve, whose + accuracy is 1e-6 in standard-deviation units. Do not widen this: a larger + error means the Python path diverges from the core's, not that the bound is + wrong. + + Each grid point builds a fresh cap per engine. An Instrument caches its NPV, + so reusing one cap would let the second engine serve the first one's number. + +B. The switch strike at 1e-12 against the mean of the at-the-money caplet rates, + mirroring ``optionletstripper1.rs:497-505``. The mean is computed here from + ``atm_optionlet_rates()`` rather than pinned as a literal, so the arm is + self-contained. + +C. VolatilityType.Normal is deferred (#440/#577) and is rejected AT THE STRIP, + not at construction: ``OptionletStripper1.__init__`` succeeds, and the error + surfaces from the first call that needs the grid (``optionletstripper1.rs: + 170-175``, mirrored by the core test at ``:510``). Both the stripper query and + the adapter constructor must raise, since the adapter's constructor strips. + +D. The pinned-reference surface is rejected by the adapter, which is why arm A + uses ``moving``. This pins the constraint that made the moving constructors + part of this pass rather than a deferral. + +One shared Settings drives everything: the instruments and the engines must +agree on the evaluation date or the NPVs are silently wrong. +""" + +import pytest + +from itofin import ItofinError, Settings +from itofin.indexes import Euribor +from itofin.instruments import CapFloor, CapFloorType +from itofin.pricingengines import BlackCapFloorEngine +from itofin.quotes import SimpleQuote +from itofin.termstructures import ( + CapFloorTermVolSurface, + FlatForward, + OptionletStripper1, + StrippedOptionletAdapter, + VolatilityType, +) +from itofin.time import BusinessDayConvention, Calendar, DayCounter, Date, Period + +EVAL = Date(28, 10, 2013) +CURVE_RATE = 0.04 +FLAT_VOL = 0.18 + +OPTION_TENORS = [Period(n, "Years") for n in range(1, 11)] +STRIKES = [j / 100.0 for j in range(1, 11)] +VOLS = [[FLAT_VOL] * len(STRIKES) for _ in OPTION_TENORS] + +SPOT = Period(0, "Days") +TOLERANCE = 2.5e-8 +SWITCH_STRIKE_TOLERANCE = 1e-12 + +SETTINGS = Settings() +SETTINGS.set_evaluation_date(EVAL) + + +def _curve(): + return FlatForward(EVAL, CURVE_RATE, DayCounter.actual365_fixed()) + + +def _moving_surface(): + """The flat term-vol surface with a floating reference date, the form the + stripper and the adapter need.""" + return CapFloorTermVolSurface.moving( + 0, + Calendar.target(), + BusinessDayConvention.Following, + OPTION_TENORS, + STRIKES, + VOLS, + DayCounter.actual365_fixed(), + SETTINGS, + ) + + +def _pinned_surface(): + return CapFloorTermVolSurface( + EVAL, + Calendar.target(), + BusinessDayConvention.Following, + OPTION_TENORS, + STRIKES, + VOLS, + DayCounter.actual365_fixed(), + ) + + +def _stripper( + curve, + surface=None, + volatility_type=VolatilityType.ShiftedLognormal, + optionlet_frequency=None, +): + surface = _moving_surface() if surface is None else surface + return OptionletStripper1( + surface, + Euribor.six_months(curve, SETTINGS), + volatility_type, + optionlet_frequency=optionlet_frequency, + ) + + +def _cap(tenor, strike, curve): + """A fresh cap, so no cached NPV survives an engine swap.""" + return CapFloor( + CapFloorType.Cap, + tenor, + Euribor.six_months(curve, SETTINGS), + strike, + SPOT, + SETTINGS, + ) + + +def test_a_flat_term_vol_surface_round_trips_through_the_stripped_optionlets(): + curve = _curve() + adapter = StrippedOptionletAdapter(_stripper(curve), SETTINGS) + adapter.enable_extrapolation() + + stripped_engine = BlackCapFloorEngine(adapter, curve, None) + flat_engine = BlackCapFloorEngine.with_flat_vol( + curve, SimpleQuote(FLAT_VOL), DayCounter.actual365_fixed(), 0.0, SETTINGS + ) + + worst = 0.0 + worst_at = None + for tenor in OPTION_TENORS: + for strike in STRIKES: + stripped = _cap(tenor, strike, curve) + stripped.set_black_engine(stripped_engine) + price_stripped = stripped.npv() + + flat = _cap(tenor, strike, curve) + flat.set_black_engine(flat_engine) + price_flat = flat.npv() + + error = abs(price_stripped - price_flat) + if error > worst: + worst, worst_at = error, (str(tenor), strike) + assert error < TOLERANCE, ( + f"tenor {tenor} strike {strike}: stripped {price_stripped!r} vs " + f"flat {price_flat!r}, error {error!r} > {TOLERANCE!r}" + ) + + print(f"\nworst round-trip |npv diff| = {worst!r} at {worst_at}") + assert worst > 0.0, ( + "the anti-tautology guard: a stripper that echoed the flat input straight " + "through would feed both engines the identical vol, forward, strike, time " + "and discount, and the two NPVs would agree bit-for-bit. A non-zero worst " + "error is what proves the stripped caplet vols really differ from the flat " + "term vol, so arm A is a round-trip and not a circular identity." + ) + + +def test_the_switch_strike_is_the_mean_atm_caplet_rate(): + stripper = _stripper(_curve()) + rates = stripper.atm_optionlet_rates() + assert len(rates) > 1 + expected = sum(rates) / len(rates) + assert stripper.switch_strike() == pytest.approx( + expected, abs=SWITCH_STRIKE_TOLERANCE + ) + + +def test_the_optionlet_frequency_overrides_the_index_tenor_as_the_caplet_step(): + """The caplet grid is built by stepping the index tenor across the surface + (``optionletstripper.rs:128-151``), so a 1Y override on a 6M index halves the + number of caplets. This is what pins that the argument reaches the core + rather than being dropped by the facade.""" + curve = _curve() + by_index_tenor = len(_stripper(curve).atm_optionlet_rates()) + by_override = len( + _stripper(curve, optionlet_frequency=Period(1, "Years")).atm_optionlet_rates() + ) + assert by_override == pytest.approx(by_index_tenor / 2, abs=1) + assert by_override < by_index_tenor + + +def test_a_normal_volatility_type_is_rejected_at_the_strip_not_at_construction(): + curve = _curve() + stripper = _stripper(curve, volatility_type=VolatilityType.Normal) + with pytest.raises(ItofinError): + stripper.switch_strike() + with pytest.raises(ItofinError): + StrippedOptionletAdapter(_stripper(curve, volatility_type=VolatilityType.Normal), SETTINGS) + + +def test_a_pinned_reference_surface_is_rejected_by_the_adapter(): + stripper = _stripper(_curve(), surface=_pinned_surface()) + with pytest.raises(ItofinError): + StrippedOptionletAdapter(stripper, SETTINGS) + + +def test_the_adapter_serves_the_flat_input_volatility_back(): + curve = _curve() + adapter = StrippedOptionletAdapter(_stripper(curve), SETTINGS) + for tenor in (Period(2, "Years"), Period(5, "Years")): + for strike in (0.02, 0.05): + assert adapter.volatility(tenor, strike) == pytest.approx( + FLAT_VOL, abs=0.02 + )