From 3c04589e85cd29b21419a71301b41e2e6c8d0a09 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Mon, 27 Jul 2026 10:33:35 +0800 Subject: [PATCH 1/3] feat(itofin-py): add SwapIndex Python binding This pull request exposes the core swap index to Python, so the volatility cubes can read the at-the-money forward from a vanilla swap's fair rate. **New SwapIndex binding:** * Added `crates/itofin-py/src/swapindex.rs` defining `PySwapIndex`, assembled from the index tenor, forecasting Euribor index, and fixed-leg conventions. * Registered `PySwapIndex` in the `indexes` module in `src/lib.rs`. * Supports both the default constructor (forecasting and discounting off the ibor index's forwarding curve) and `with_exogenous_discount` (discounting off a separate curve), plus `fixing`, `fixed_leg_tenor`, and `exogenous_discount` inspectors. **Supporting changes:** * Added a `PyPeriod::from_inner` helper in `src/time.rs` for inspectors that return a `Period`. * Extended the hand-written stubs in `indexes.pyi` with the `SwapIndex` type and its new imports. --- crates/itofin-py/python/itofin/indexes.pyi | 56 ++++++++- crates/itofin-py/src/lib.rs | 3 + crates/itofin-py/src/swapindex.rs | 137 +++++++++++++++++++++ crates/itofin-py/src/time.rs | 5 + 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 crates/itofin-py/src/swapindex.rs diff --git a/crates/itofin-py/python/itofin/indexes.pyi b/crates/itofin-py/python/itofin/indexes.pyi index ab247d0..f3f169e 100644 --- a/crates/itofin-py/python/itofin/indexes.pyi +++ b/crates/itofin-py/python/itofin/indexes.pyi @@ -1,8 +1,15 @@ -# Hand-written stubs for itofin.indexes; sync manually with src/hullwhite.rs and src/helpers.rs (#517). +# Hand-written stubs for itofin.indexes; sync manually with src/hullwhite.rs, src/helpers.rs +# and src/swapindex.rs (#517). from itofin import Settings from itofin.termstructures import YieldTermStructure -from itofin.time import Date, Period +from itofin.time import ( + BusinessDayConvention, + Calendar, + Date, + DayCounter, + Period, +) class Euribor: """The Euribor IBOR index family.""" @@ -22,3 +29,48 @@ class Estr: def __init__(self, curve: YieldTermStructure | None, settings: Settings) -> None: ... def fixing(self, fixing_date: Date, forecast_todays_fixing: bool) -> float: ... + +class SwapIndex: + """The index whose fixing is the fair rate of an on-the-fly vanilla swap, + assembled from the index tenor, the forecasting Euribor index and the + fixed-leg conventions. + + The currency is hard-coded to EUR: there is no Currency facade yet, and the + currency is inert for every ported consumer.""" + + def __init__( + self, + family_name: str, + tenor: Period, + settlement_days: int, + calendar: Calendar, + fixed_leg_tenor: Period, + fixed_leg_convention: BusinessDayConvention, + fixed_leg_day_counter: DayCounter, + ibor_index: Euribor, + settings: Settings, + ) -> None: + """Forecasts and discounts off the ibor index's forwarding curve.""" + ... + @staticmethod + def with_exogenous_discount( + family_name: str, + tenor: Period, + settlement_days: int, + calendar: Calendar, + fixed_leg_tenor: Period, + fixed_leg_convention: BusinessDayConvention, + fixed_leg_day_counter: DayCounter, + ibor_index: Euribor, + discount: YieldTermStructure, + settings: Settings, + ) -> SwapIndex: + """Forecasts off the ibor index's forwarding curve but discounts off the + separate discount curve.""" + ... + def fixing(self, fixing_date: Date, forecast_todays_fixing: bool = False) -> float: + """The underlying swap's fair rate, the at-the-money forward the + volatility cubes read.""" + ... + def fixed_leg_tenor(self) -> Period: ... + def exogenous_discount(self) -> bool: ... diff --git a/crates/itofin-py/src/lib.rs b/crates/itofin-py/src/lib.rs index fbcbaf7..322204c 100644 --- a/crates/itofin-py/src/lib.rs +++ b/crates/itofin-py/src/lib.rs @@ -14,6 +14,7 @@ mod market; mod option; mod settings; mod swap; +mod swapindex; mod swaption; mod swaptionengine; mod swaptionvol; @@ -41,6 +42,7 @@ use pyo3::prelude::*; use pyo3::types::PyDict; use settings::PySettings; use swap::{PySwapType, PyVanillaSwap}; +use swapindex::PySwapIndex; use swaption::{PyEuropeanExercise, PySettlementMethod, PySettlementType, PySwaption}; use swaptionengine::{PyBlackSwaptionEngine, PyCashAnnuityModel}; use swaptionvol::{ @@ -142,6 +144,7 @@ fn itofin(m: &Bound<'_, PyModule>) -> PyResult<()> { let indexes = PyModule::new(py, "indexes")?; indexes.add_class::()?; indexes.add_class::()?; + indexes.add_class::()?; let instruments = PyModule::new(py, "instruments")?; instruments.add_class::()?; diff --git a/crates/itofin-py/src/swapindex.rs b/crates/itofin-py/src/swapindex.rs new file mode 100644 index 0000000..0981b7a --- /dev/null +++ b/crates/itofin-py/src/swapindex.rs @@ -0,0 +1,137 @@ +//! Facade for the swap-rate index [`PySwapIndex`] (`indexes::SwapIndex`), the +//! index whose fixing is a vanilla swap's fair rate. +//! +//! The swaption volatility cubes take two of these (a long and a short base) and +//! read the at-the-money forward off them, so this is the index the cube facades +//! stack on rather than one the Python user prices with directly. +//! +//! Deferred (visible): the currency is hard-coded to EUR. There is no +//! `PyCurrency` facade yet, and the index's currency is inert for every ported +//! consumer - `underlying_swap` never reads it, and the cube's at-the-money +//! forward is a fair rate, not an amount. A `PyCurrency` ticket carries the +//! general form; until then a non-EUR index is not expressible here. The +//! `clone` family (re-curving / re-tenoring) is deferred in the core itself. + +use crate::PyQlError; +use crate::curve::PyYieldTermStructure; +use crate::hullwhite::PyEuribor; +use crate::settings::PySettings; +use crate::time::{PyBusinessDayConvention, PyCalendar, PyDate, PyDayCounter, PyPeriod}; +use libitofin::currency::Currency; +use libitofin::indexes::{Index, SwapIndex}; +use libitofin::shared::{Shared, shared}; +use libitofin::types::Natural; +use pyo3::prelude::*; + +/// Python `SwapIndex`: the index whose fixing is the fair rate of an on-the-fly +/// vanilla swap (`indexes::SwapIndex`). +/// +/// The swap is assembled from the index tenor, the forecasting `Euribor` index +/// and the fixed-leg conventions, off the value date the fixing date implies. +/// [`new`](PySwapIndex::new) forecasts and discounts off the ibor index's +/// forwarding curve; [`with_exogenous_discount`](PySwapIndex::with_exogenous_discount) +/// discounts off a separate curve. +#[pyclass(name = "SwapIndex", unsendable)] +pub struct PySwapIndex { + inner: Shared, +} + +#[pymethods] +impl PySwapIndex { + /// A swap index forecasting and discounting off `ibor_index`'s forwarding + /// curve, registering with that index so a relinked curve notifies + /// observers. + #[new] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (family_name, tenor, settlement_days, calendar, fixed_leg_tenor, fixed_leg_convention, fixed_leg_day_counter, ibor_index, settings))] + fn new( + family_name: String, + tenor: &PyPeriod, + settlement_days: Natural, + calendar: &PyCalendar, + fixed_leg_tenor: &PyPeriod, + fixed_leg_convention: &PyBusinessDayConvention, + fixed_leg_day_counter: &PyDayCounter, + ibor_index: &PyEuribor, + settings: &PySettings, + ) -> Self { + PySwapIndex { + inner: shared(SwapIndex::new( + family_name, + tenor.inner(), + settlement_days, + Currency::eur(), + calendar.inner(), + fixed_leg_tenor.inner(), + fixed_leg_convention.inner(), + fixed_leg_day_counter.inner(), + ibor_index.inner(), + settings.inner(), + )), + } + } + + /// A swap index forecasting off `ibor_index`'s forwarding curve but + /// discounting off the separate `discount` curve, registering with both. + #[staticmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (family_name, tenor, settlement_days, calendar, fixed_leg_tenor, fixed_leg_convention, fixed_leg_day_counter, ibor_index, discount, settings))] + fn with_exogenous_discount( + family_name: String, + tenor: &PyPeriod, + settlement_days: Natural, + calendar: &PyCalendar, + fixed_leg_tenor: &PyPeriod, + fixed_leg_convention: &PyBusinessDayConvention, + fixed_leg_day_counter: &PyDayCounter, + ibor_index: &PyEuribor, + discount: &PyYieldTermStructure, + settings: &PySettings, + ) -> Self { + PySwapIndex { + inner: shared(SwapIndex::with_exogenous_discount( + family_name, + tenor.inner(), + settlement_days, + Currency::eur(), + calendar.inner(), + fixed_leg_tenor.inner(), + fixed_leg_convention.inner(), + fixed_leg_day_counter.inner(), + ibor_index.inner(), + discount.handle(), + settings.inner(), + )), + } + } + + /// The index's fixing for `fixing_date`: the fair rate of the underlying + /// swap, the number the volatility cubes read as the at-the-money forward. + /// Fallible: an empty forwarding handle, an unset evaluation date or an + /// invalid fixing date is an error. + #[pyo3(signature = (fixing_date, forecast_todays_fixing = false))] + fn fixing(&self, fixing_date: &PyDate, forecast_todays_fixing: bool) -> PyResult { + Ok(self + .inner + .fixing(fixing_date.inner(), forecast_todays_fixing) + .map_err(PyQlError::from)?) + } + + /// The fixed-leg tenor. + fn fixed_leg_tenor(&self) -> PyPeriod { + PyPeriod::from_inner(self.inner.fixed_leg_tenor()) + } + + /// Whether the index discounts off a separate curve. + fn exogenous_discount(&self) -> bool { + self.inner.exogenous_discount() + } +} + +impl PySwapIndex { + /// A clone of the inner index for the volatility cube facades. + #[allow(dead_code)] + pub(crate) fn inner(&self) -> Shared { + Shared::clone(&self.inner) + } +} diff --git a/crates/itofin-py/src/time.rs b/crates/itofin-py/src/time.rs index 141c8ac..e959180 100644 --- a/crates/itofin-py/src/time.rs +++ b/crates/itofin-py/src/time.rs @@ -229,6 +229,11 @@ impl PyPeriod { pub(crate) fn inner(&self) -> Period { self.inner } + + /// A facade over a core [`Period`], for the inspectors that return one. + pub(crate) fn from_inner(inner: Period) -> Self { + PyPeriod { inner } + } } /// Maps a `{"Days", "Weeks", "Months", "Years"}` string to a [`TimeUnit`], From 17d1eae2d0a53e9980c70fca35144c324285a8de Mon Sep 17 00:00:00 2001 From: benbenbang Date: Mon, 27 Jul 2026 10:36:39 +0800 Subject: [PATCH 2/3] feat(itofin-py): expose InterpolatedSwaptionVolatilityCube facade This adds a Python binding for the smile cube that layers bilinearly-interpolated volatility spreads over an at-the-money surface. **New volatility cube facade:** * Added `PyInterpolatedSwaptionVolatilityCube` in `swaptionvol.rs`, extending `PySwaptionVolatilityStructure` so the inherited `volatility` query takes a real strike, reading the at-the-money forward and vol off the base swap indexes and `atm_vol` and adding the interpolated spread. * Validated the `vol_spreads` grid shape here, enforcing one row per `(option tenor, swap tenor)` node and one column per strike spread, and wired the quotes into the core cube. * Exposed `atm_strike_from_tenor` on the concrete cube to serve the at-the-money strike a caller needs to place a smile query. **Registration and supporting changes:** * Registered the new class in the `termstructures` module in `lib.rs`. * Dropped the `dead_code` allowance on `PySwapIndex::inner`, now consumed by the cube facade. * Added hand-written stubs for `InterpolatedSwaptionVolatilityCube` in `termstructures.pyi`. --- .../python/itofin/termstructures.pyi | 32 ++++- crates/itofin-py/src/lib.rs | 5 +- crates/itofin-py/src/swapindex.rs | 1 - crates/itofin-py/src/swaptionvol.rs | 110 +++++++++++++++++- 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/crates/itofin-py/python/itofin/termstructures.pyi b/crates/itofin-py/python/itofin/termstructures.pyi index 85a93d3..e8fe5ba 100644 --- a/crates/itofin-py/python/itofin/termstructures.pyi +++ b/crates/itofin-py/python/itofin/termstructures.pyi @@ -2,7 +2,7 @@ # src/helpers.rs and src/swaptionvol.rs (#517). from itofin import Settings -from itofin.indexes import Estr, Euribor +from itofin.indexes import Estr, Euribor, SwapIndex from itofin.quotes import SimpleQuote from itofin.time import ( BusinessDayConvention, @@ -468,3 +468,33 @@ class SwaptionVolatilityMatrix(SwaptionVolatilityStructure): each node from the caller's quote: a later set_value on any of them rebuilds the interpolation and notifies the grid's observers.""" ... + +class InterpolatedSwaptionVolatilityCube(SwaptionVolatilityStructure): + """A smile cube adding bilinearly-interpolated volatility spreads to an + at-the-money surface. + + The inherited volatility query now takes a real strike: the cube reads the + at-the-money forward off its base swap indexes, the at-the-money volatility + off atm_vol, and adds the spread interpolated at strike - atm_strike. + + vol_spreads is row-major over the (option tenor, swap tenor) nodes: row + i * len(swap_tenors) + j is the smile at (option_tenors[i], swap_tenors[j]), + holding one quote per entry of strike_spreads. A later set_value on any of + those quotes rebuilds the per-strike interpolators.""" + + def __init__( + self, + atm_vol: SwaptionVolatilityStructure, + option_tenors: list[Period], + swap_tenors: list[Period], + strike_spreads: list[float], + vol_spreads: list[list[SimpleQuote]], + swap_index_base: SwapIndex, + short_swap_index_base: SwapIndex, + settings: Settings, + vega_weighted_smile_fit: bool = False, + ) -> None: ... + def atm_strike_from_tenor(self, option_tenor: Period, swap_tenor: Period) -> float: + """The at-the-money strike for an option tenor and swap tenor: the fixing + of whichever base swap index the swap tenor selects.""" + ... diff --git a/crates/itofin-py/src/lib.rs b/crates/itofin-py/src/lib.rs index 322204c..7ec7fa6 100644 --- a/crates/itofin-py/src/lib.rs +++ b/crates/itofin-py/src/lib.rs @@ -46,8 +46,8 @@ use swapindex::PySwapIndex; use swaption::{PyEuropeanExercise, PySettlementMethod, PySettlementType, PySwaption}; use swaptionengine::{PyBlackSwaptionEngine, PyCashAnnuityModel}; use swaptionvol::{ - PyConstantSwaptionVolatility, PySwaptionVolatilityMatrix, PySwaptionVolatilityStructure, - PyVolatilityType, + PyConstantSwaptionVolatility, PyInterpolatedSwaptionVolatilityCube, PySwaptionVolatilityMatrix, + PySwaptionVolatilityStructure, PyVolatilityType, }; use time::{ PyBusinessDayConvention, PyCalendar, PyDate, PyDayCounter, PyFrequency, PyPeriod, PySchedule, @@ -136,6 +136,7 @@ fn itofin(m: &Bound<'_, PyModule>) -> PyResult<()> { 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/swapindex.rs b/crates/itofin-py/src/swapindex.rs index 0981b7a..d75f418 100644 --- a/crates/itofin-py/src/swapindex.rs +++ b/crates/itofin-py/src/swapindex.rs @@ -130,7 +130,6 @@ impl PySwapIndex { impl PySwapIndex { /// A clone of the inner index for the volatility cube facades. - #[allow(dead_code)] pub(crate) fn inner(&self) -> Shared { Shared::clone(&self.inner) } diff --git a/crates/itofin-py/src/swaptionvol.rs b/crates/itofin-py/src/swaptionvol.rs index 8141aff..96c2e66 100644 --- a/crates/itofin-py/src/swaptionvol.rs +++ b/crates/itofin-py/src/swaptionvol.rs @@ -1,7 +1,8 @@ //! Facades for the swaption volatility stack: the [`PySwaptionVolatilityStructure`] //! base, the [`PyVolatilityType`] flag, the constant surface -//! [`PyConstantSwaptionVolatility`] and the at-the-money grid -//! [`PySwaptionVolatilityMatrix`]. +//! [`PyConstantSwaptionVolatility`], the at-the-money grid +//! [`PySwaptionVolatilityMatrix`] and the spread cube over it, +//! [`PyInterpolatedSwaptionVolatilityCube`]. //! //! The base holds the erased `Handle` and //! exposes the queries every concrete surface inherits; concrete surfaces @@ -19,14 +20,15 @@ use crate::PyQlError; use crate::market::PySimpleQuote; use crate::settings::PySettings; +use crate::swapindex::PySwapIndex; use crate::time::{PyBusinessDayConvention, PyCalendar, PyDate, PyDayCounter, PyPeriod}; use libitofin::handle::Handle; use libitofin::math::matrix::Matrix; use libitofin::quotes::Quote; use libitofin::shared::{Shared, shared}; use libitofin::termstructures::volatility::{ - ConstantSwaptionVolatility, SwaptionVolatilityMatrix, SwaptionVolatilityStructure, - VolatilityType, + ConstantSwaptionVolatility, InterpolatedSwaptionVolatilityCube, SwaptionVolatilityMatrix, + SwaptionVolatilityStructure, VolatilityType, }; use libitofin::time::period::Period; use pyo3::prelude::*; @@ -348,6 +350,106 @@ impl PySwaptionVolatilityMatrix { } } +/// Python `InterpolatedSwaptionVolatilityCube`: a smile cube adding +/// bilinearly-interpolated volatility spreads to an at-the-money surface +/// (`termstructures::volatility::InterpolatedSwaptionVolatilityCube`). +/// +/// Extends [`PySwaptionVolatilityStructure`], so the inherited `volatility` +/// query now takes a real strike: the cube reads the at-the-money forward off +/// its base swap indexes, the at-the-money volatility off `atm_vol`, and adds +/// the spread interpolated at `strike - atm_strike`. `atm_strike` is served by +/// [`atm_strike_from_tenor`](PyInterpolatedSwaptionVolatilityCube::atm_strike_from_tenor), +/// which is what a caller needs to place a query on the smile. +/// +/// `vol_spreads` is **row-major over the `(option tenor, swap tenor)` nodes**: +/// row `i * len(swap_tenors) + j` is the smile at `(option_tenors[i], +/// swap_tenors[j])`, holding one quote per entry of `strike_spreads`. The shape +/// is checked here rather than left to the core's dimension error. A later +/// `set_value` on any of those quotes rebuilds the per-strike interpolators. +/// +/// `swap_index_base` is the long base index and `short_swap_index_base` the +/// short one; the cube picks between them per query by swap tenor, so the short +/// index's tenor must not exceed the long one's. +#[pyclass(name = "InterpolatedSwaptionVolatilityCube", extends = PySwaptionVolatilityStructure, unsendable)] +pub struct PyInterpolatedSwaptionVolatilityCube { + concrete: Shared, +} + +#[pymethods] +impl PyInterpolatedSwaptionVolatilityCube { + #[new] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (atm_vol, option_tenors, swap_tenors, strike_spreads, vol_spreads, swap_index_base, short_swap_index_base, settings, vega_weighted_smile_fit = false))] + fn new( + atm_vol: &PySwaptionVolatilityStructure, + option_tenors: Vec>, + swap_tenors: Vec>, + strike_spreads: Vec, + vol_spreads: Vec>>, + swap_index_base: &PySwapIndex, + short_swap_index_base: &PySwapIndex, + settings: &PySettings, + vega_weighted_smile_fit: bool, + ) -> PyResult> { + let columns = check_grid(&vol_spreads, "vol spread")?; + let nodes = option_tenors.len() * swap_tenors.len(); + if vol_spreads.len() != nodes { + return Err(crate::ItofinError::new_err(format!( + "vol spread grid must have one row per (option tenor, swap tenor) node: \ + expected {nodes}, got {}", + vol_spreads.len() + ))); + } + if columns != strike_spreads.len() { + return Err(crate::ItofinError::new_err(format!( + "vol spread rows must have one column per strike spread: expected {}, got {columns}", + strike_spreads.len() + ))); + } + let vol_spreads: Vec>> = vol_spreads + .iter() + .map(|row| row.iter().map(|quote| quote.handle()).collect()) + .collect(); + let cube = shared( + InterpolatedSwaptionVolatilityCube::new( + atm_vol.handle(), + tenors(&option_tenors), + tenors(&swap_tenors), + strike_spreads, + vol_spreads, + swap_index_base.inner(), + short_swap_index_base.inner(), + vega_weighted_smile_fit, + settings.inner(), + ) + .map_err(PyQlError::from)?, + ); + let erased = Handle::new(Shared::clone(&cube) as Shared); + Ok( + PyClassInitializer::from(PySwaptionVolatilityStructure::from_handle(erased)) + .add_subclass(PyInterpolatedSwaptionVolatilityCube { concrete: cube }), + ) + } + + /// The at-the-money strike for an option tenor and swap tenor: the fixing of + /// whichever base swap index the swap tenor selects, off the option date the + /// tenor resolves to against the cube's reference date and calendar. + /// + /// Lives on the concrete cube rather than the inherited base because it is + /// the cube framework's, not the volatility structure trait's. + fn atm_strike_from_tenor( + &self, + option_tenor: &PyPeriod, + swap_tenor: &PyPeriod, + ) -> PyResult { + Ok(self + .concrete + .cube() + .atm_strike_from_tenor(option_tenor.inner(), swap_tenor.inner()) + .map_err(PyQlError::from)?) + } +} + /// The wrapped core periods, in order. fn tenors(periods: &[PyRef<'_, PyPeriod>]) -> Vec { periods.iter().map(|period| period.inner()).collect() From f991cf708a1953c8ff5e4ac54816cdc11a706c2e Mon Sep 17 00:00:00 2001 From: benbenbang Date: Mon, 27 Jul 2026 10:39:34 +0800 Subject: [PATCH 3/3] test(itofin-py): add interpolated swaption cube tests Add a new test file covering the interpolated swaption cube functionality to validate its behavior. Closes #614 close #614 --- .../tests/test_interpolated_swaption_cube.py | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 crates/itofin-py/tests/test_interpolated_swaption_cube.py diff --git a/crates/itofin-py/tests/test_interpolated_swaption_cube.py b/crates/itofin-py/tests/test_interpolated_swaption_cube.py new file mode 100644 index 0000000..ad781af --- /dev/null +++ b/crates/itofin-py/tests/test_interpolated_swaption_cube.py @@ -0,0 +1,323 @@ +"""Oracle for the interpolated (spread) swaption vol cube facade (issue #614). + +The fixture is the core module test's, in turn QuantLib's +``swaptionvolstructuresutilities.hpp`` data: the ``AtmVolatility`` 6x4 grid as a +moving ``SwaptionVolatilityMatrix`` (TARGET, ModifiedFollowing, Actual/365F), +the ``VolatilityCube`` 3x3 node by 5 strike-spread grid over it, and two +hand-built ``EuriborSwapIsdaFixA``-convention swap indexes (long 2Y over 6M +Euribor, short 1Y over 3M Euribor) off a 5% flat curve, all against evaluation +date 15-June-2026. + +``VOL_SPREADS`` is row-major over the ``(option tenor, swap tenor)`` nodes, the +ordering the facade documents and validates: row ``i * len(SWAP_TENORS) + j`` is +the smile at ``(OPTION_TENORS[i], SWAP_TENORS[j])``. + +Four arms: + +A. ATM recovery at strike spread 0 (C++ ``makeAtmVolTest``). At the cube's own + at-the-money strike the interpolated spread is the input 0.0000 column, so + the cube must serve exactly what the ATM matrix serves. This is what pins + that ``atm_strike_from_tenor`` reads the same forward the smile is centred + on: a facade that routed it through the wrong base index would shift the + query off the zero column and fail here. +B. Vol-spread recovery at every smile node (C++ ``makeVolSpreadsTest``). At + ``atm_strike + spread`` the served vol minus the ATM vol must be the input + quote, for all 9 nodes x 5 spreads. This is the arm that pins the row-major + ordering end to end: a transposed grid still recovers arm A (the zero column + is symmetric) but scrambles 24 of these 45 values (the three diagonal nodes + are transpose-fixed, and the zero column matches on the six swapped ones). +C. Quote-bump refresh, mirroring the core's third arm. Bumping one node's + spread quote must move that node's smile at that strike, leave the same + node's other strikes alone, and leave a different node alone - the + per-strike interpolators are rebuilt from the quotes rather than served + stale, and the rebuild writes only where the bump was. +D. Engine integration. The same swaption, struck 50bp above the money so the + smile is doing work, priced on the cube and on the bare ATM matrix. The + engine reads the vol at the swaption's own strike, so the cube must serve the + ATM vol plus a nonzero interpolated spread there and the two NPVs must + differ. Both remain positive. + +The core asserts 1e-16 on arms A-C; this pass keeps 1e-12. Every query passes +``extrapolate=True``, as the core test does: the 30Y option tenor sits on the +ATM grid's last node, so the default would range-check against it. +""" + +import pytest + +from itofin import ItofinError, Settings +from itofin.indexes import Euribor, SwapIndex +from itofin.instruments import ( + EuropeanExercise, + SettlementMethod, + SettlementType, + Swaption, + SwapType, + VanillaSwap, +) +from itofin.pricingengines import BlackSwaptionEngine, CashAnnuityModel +from itofin.quotes import SimpleQuote +from itofin.termstructures import ( + FlatForward, + InterpolatedSwaptionVolatilityCube, + SwaptionVolatilityMatrix, + VolatilityType, +) +from itofin.time import ( + BusinessDayConvention, + Calendar, + Date, + DayCounter, + Frequency, + Period, + Schedule, +) + +EVAL = Date(15, 6, 2026) +BDC = BusinessDayConvention.ModifiedFollowing +TOL = 1e-12 +RATE = 0.05 + +ATM_OPTION_TENORS = [ + Period(1, "Months"), + Period(6, "Months"), + Period(1, "Years"), + Period(5, "Years"), + Period(10, "Years"), + Period(30, "Years"), +] +ATM_SWAP_TENORS = [ + Period(1, "Years"), + Period(5, "Years"), + Period(10, "Years"), + Period(30, "Years"), +] +ATM_VOLS = [ + [0.1300, 0.1560, 0.1390, 0.1220], + [0.1440, 0.1580, 0.1460, 0.1260], + [0.1600, 0.1590, 0.1470, 0.1290], + [0.1640, 0.1470, 0.1370, 0.1220], + [0.1400, 0.1300, 0.1250, 0.1100], + [0.1130, 0.1090, 0.1070, 0.0930], +] + +OPTION_TENORS = [Period(1, "Years"), Period(10, "Years"), Period(30, "Years")] +SWAP_TENORS = [Period(2, "Years"), Period(10, "Years"), Period(30, "Years")] +STRIKE_SPREADS = [-0.020, -0.005, 0.000, 0.005, 0.020] +VOL_SPREADS = [ + [0.0599, 0.0049, 0.0000, -0.0001, 0.0127], + [0.0729, 0.0086, 0.0000, -0.0024, 0.0098], + [0.0738, 0.0102, 0.0000, -0.0039, 0.0065], + [0.0465, 0.0063, 0.0000, -0.0032, -0.0010], + [0.0558, 0.0084, 0.0000, -0.0050, -0.0057], + [0.0576, 0.0083, 0.0000, -0.0043, -0.0014], + [0.0437, 0.0059, 0.0000, -0.0030, -0.0006], + [0.0533, 0.0078, 0.0000, -0.0045, -0.0046], + [0.0545, 0.0079, 0.0000, -0.0042, -0.0020], +] + +SETTINGS = Settings() +SETTINGS.set_evaluation_date(EVAL) + +BUMP = 0.0100 +BUMPED_NODE = len(SWAP_TENORS) + 1 + +ENGINE_OPTION_TENOR = Period(2, "Years") +ENGINE_SWAP_TENOR = Period(7, "Years") +ENGINE_MONEYNESS = 0.005 +EXERCISE = Calendar.target().advance(EVAL, 2, "Years", BDC, False) +SWAP_END = Date(EXERCISE.day, EXERCISE.month, EXERCISE.year + 7) + + +def _fixture(): + """The curve, ATM matrix, cube and the cube's own spread quotes. + + Rebuilt per arm: arm C mutates its quotes, and a shared cube would let a + later arm read the bumped smile. + """ + curve = FlatForward(EVAL, RATE, DayCounter.actual365_fixed()) + atm = SwaptionVolatilityMatrix.moving( + Calendar.target(), + BDC, + ATM_OPTION_TENORS, + ATM_SWAP_TENORS, + [[SimpleQuote(vol) for vol in row] for row in ATM_VOLS], + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + SETTINGS, + ) + quotes = [[SimpleQuote(spread) for spread in row] for row in VOL_SPREADS] + cube = InterpolatedSwaptionVolatilityCube( + atm, + OPTION_TENORS, + SWAP_TENORS, + STRIKE_SPREADS, + quotes, + _swap_index(Period(2, "Years"), Euribor.six_months(curve, SETTINGS)), + _swap_index(Period(1, "Years"), Euribor.three_months(curve, SETTINGS)), + SETTINGS, + ) + return curve, atm, cube, quotes + + +def _swap_index(tenor, ibor_index): + """An EuriborSwapIsdaFixA-convention index: annual 30/360 bond-basis fixed + leg on TARGET, 2 settlement days, over the given forecasting index.""" + return SwapIndex( + "EuriborSwapIsdaFixA", + tenor, + 2, + Calendar.target(), + Period(1, "Years"), + BDC, + DayCounter.thirty360_bond_basis(), + ibor_index, + SETTINGS, + ) + + +def test_the_cube_recovers_the_atm_vols_at_strike_spread_zero(): + _curve, atm, cube, _quotes = _fixture() + for option_tenor in OPTION_TENORS: + for swap_tenor in SWAP_TENORS: + strike = cube.atm_strike_from_tenor(option_tenor, swap_tenor) + expected = atm.volatility(option_tenor, swap_tenor, strike, True) + got = cube.volatility(option_tenor, swap_tenor, strike, True) + assert got == pytest.approx(expected, abs=TOL), f"{option_tenor} x {swap_tenor}" + + +def test_the_cube_recovers_the_input_vol_spreads_at_every_smile_node(): + _curve, atm, cube, _quotes = _fixture() + for i, option_tenor in enumerate(OPTION_TENORS): + for j, swap_tenor in enumerate(SWAP_TENORS): + atm_strike = cube.atm_strike_from_tenor(option_tenor, swap_tenor) + atm_vol = atm.volatility(option_tenor, swap_tenor, atm_strike, True) + inputs = VOL_SPREADS[i * len(SWAP_TENORS) + j] + for k, strike_spread in enumerate(STRIKE_SPREADS): + served = cube.volatility( + option_tenor, swap_tenor, atm_strike + strike_spread, True + ) + assert served - atm_vol == pytest.approx(inputs[k], abs=TOL), ( + f"{option_tenor} x {swap_tenor} at strike spread {strike_spread}" + ) + + +def test_a_vol_spread_quote_bump_refreshes_the_smile(): + _curve, atm, cube, quotes = _fixture() + inputs = VOL_SPREADS[BUMPED_NODE] + + def spread_at(node, i, j, k): + option_tenor = OPTION_TENORS[i] + swap_tenor = SWAP_TENORS[j] + atm_strike = cube.atm_strike_from_tenor(option_tenor, swap_tenor) + atm_vol = atm.volatility(option_tenor, swap_tenor, atm_strike, True) + served = cube.volatility( + option_tenor, swap_tenor, atm_strike + STRIKE_SPREADS[k], True + ) + assert node == i * len(SWAP_TENORS) + j + return served - atm_vol + + assert spread_at(BUMPED_NODE, 1, 1, 0) == pytest.approx(inputs[0], abs=TOL) + + quotes[BUMPED_NODE][0].set_value(inputs[0] + BUMP) + + assert spread_at(BUMPED_NODE, 1, 1, 0) == pytest.approx(inputs[0] + BUMP, abs=TOL) + assert spread_at(BUMPED_NODE, 1, 1, 3) == pytest.approx(inputs[3], abs=TOL), ( + "an untouched strike on the bumped node must be unchanged" + ) + assert spread_at(0, 0, 0, 0) == pytest.approx(VOL_SPREADS[0][0], abs=TOL), ( + "a different node must be unchanged: a mis-indexed write would flood it" + ) + + +def _npv_on(surface, curve, strike): + fixed = Schedule( + EXERCISE, + SWAP_END, + Frequency.Annual, + Calendar.target(), + BusinessDayConvention.Unadjusted, + ) + floating = Schedule( + EXERCISE, + SWAP_END, + Frequency.Semiannual, + Calendar.target(), + BusinessDayConvention.Unadjusted, + ) + swap = VanillaSwap( + SwapType.Payer, + 100.0, + fixed, + strike, + DayCounter.thirty360_bond_basis(), + floating, + Euribor.six_months(curve, SETTINGS), + 0.0, + DayCounter.actual360(), + SETTINGS, + ) + swaption = Swaption( + swap, + EuropeanExercise(EXERCISE), + SettlementType.Physical, + SettlementMethod.PhysicalOTC, + SETTINGS, + ) + swaption.set_black_engine( + BlackSwaptionEngine(surface, curve, SETTINGS, CashAnnuityModel.SwapRate) + ) + return swaption.npv() + + +def test_the_engine_prices_off_the_cubes_smile_not_the_atm_surface(): + curve, atm, cube, _quotes = _fixture() + coordinates = (ENGINE_OPTION_TENOR, ENGINE_SWAP_TENOR) + atm_strike = cube.atm_strike_from_tenor(*coordinates) + strike = atm_strike + ENGINE_MONEYNESS + + cube_vol = cube.volatility(*coordinates, strike, True) + atm_vol = atm.volatility(*coordinates, strike, True) + assert abs(cube_vol - atm_vol) > 1e-6, ( + "the fixture must place the engine's strike where the smile is nonzero" + ) + + npv_cube = _npv_on(cube, curve, strike) + npv_atm = _npv_on(atm, curve, strike) + print( + f"\natm strike at 2Yx7Y = {atm_strike!r}" + f"\nvol on the cube = {cube_vol!r}" + f"\nvol on the matrix = {atm_vol!r}" + f"\nnpv on the cube = {npv_cube!r}" + f"\nnpv on the matrix = {npv_atm!r}" + ) + assert npv_cube > 0.0 + assert npv_atm > 0.0 + assert abs(npv_cube - npv_atm) > 1e-8 + + +def test_the_cube_rejects_a_mis_shaped_vol_spread_grid(): + """The row count is the node count and the column count the strike-spread + count; both are checked in the facade, before the core's dimension error.""" + _curve, atm, _cube, _quotes = _fixture() + curve = FlatForward(EVAL, RATE, DayCounter.actual365_fixed()) + bases = ( + _swap_index(Period(2, "Years"), Euribor.six_months(curve, SETTINGS)), + _swap_index(Period(1, "Years"), Euribor.three_months(curve, SETTINGS)), + ) + + def build(quotes): + return InterpolatedSwaptionVolatilityCube( + atm, + OPTION_TENORS, + SWAP_TENORS, + STRIKE_SPREADS, + quotes, + *bases, + SETTINGS, + ) + + full = [[SimpleQuote(spread) for spread in row] for row in VOL_SPREADS] + with pytest.raises(ItofinError, match="one row per"): + build(full[:-1]) + with pytest.raises(ItofinError, match="one column per strike spread"): + build([row[:-1] for row in full])