From 91a890d5c0a1ac19a6aaf5b4326449c5928d9371 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Mon, 27 Jul 2026 10:13:17 +0800 Subject: [PATCH 1/3] feat(itofin-py): add SwaptionVolatilityMatrix Python bindings This adds Python bindings for the at-the-money swaption volatility grid, bilinear over an option-tenor x swap-tenor lattice. **New surface class:** * Added `PySwaptionVolatilityMatrix` in `swaptionvol.rs`, extending `PySwaptionVolatilityStructure` and exposing two constructors: a pinned reference-date grid over fixed volatilities and a `moving` grid that floats off the evaluation date, reading each node from a live quote so a later `set_value` rebuilds the interpolation and notifies observers. * Both constructors accept optional per-node shifts and a `flat_extrapolation` flag that clamps out-of-grid queries to the nearest edge or corner vol. **Grid validation helpers:** * Added `check_grid` to reject empty or ragged `list[list[...]]` inputs before they reach the core dimension checks, and `matrix_from_rows` to convert a `list[list[float]]` into a core `Matrix`. **Registration and stubs:** * Registered `PySwaptionVolatilityMatrix` in `lib.rs` under the `termstructures` module and added the corresponding type stub in `termstructures.pyi`. --- .../python/itofin/termstructures.pyi | 44 +++++ crates/itofin-py/src/lib.rs | 6 +- crates/itofin-py/src/swaptionvol.rs | 178 +++++++++++++++++- 3 files changed, 224 insertions(+), 4 deletions(-) diff --git a/crates/itofin-py/python/itofin/termstructures.pyi b/crates/itofin-py/python/itofin/termstructures.pyi index 21dc696..85a93d3 100644 --- a/crates/itofin-py/python/itofin/termstructures.pyi +++ b/crates/itofin-py/python/itofin/termstructures.pyi @@ -424,3 +424,47 @@ class ConstantSwaptionVolatility(SwaptionVolatilityStructure): """Reads the volatility from the caller's quote; a later set_value notifies the surface's observers.""" ... + +class SwaptionVolatilityMatrix(SwaptionVolatilityStructure): + """An at-the-money volatility grid, bilinear over an option-tenor x + swap-tenor lattice. + + Every grid is a row per option tenor and a column per swap tenor; shifts, + when given, must match that shape, and None means all-zero shifts. The grid + is at the money, so a query's strike is range-checked and then ignored. + flat_extrapolation clamps a query past the grid to the nearest edge or + corner vol instead of extending the boundary surface.""" + + def __init__( + self, + reference_date: Date, + calendar: Calendar, + business_day_convention: BusinessDayConvention, + option_tenors: list[Period], + swap_tenors: list[Period], + volatilities: list[list[float]], + day_counter: DayCounter, + volatility_type: VolatilityType, + shifts: list[list[float]] | None = None, + flat_extrapolation: bool = False, + ) -> None: + """Pins the reference date, so every query's option time runs from + reference_date rather than the evaluation date.""" + ... + @staticmethod + def moving( + calendar: Calendar, + business_day_convention: BusinessDayConvention, + option_tenors: list[Period], + swap_tenors: list[Period], + volatilities: list[list[SimpleQuote]], + day_counter: DayCounter, + volatility_type: VolatilityType, + settings: Settings, + shifts: list[list[float]] | None = None, + flat_extrapolation: bool = False, + ) -> SwaptionVolatilityMatrix: + """A grid whose reference date floats off the evaluation date, reading + each node from the caller's quote: a later set_value on any of them + rebuilds the interpolation and notifies the grid's observers.""" + ... diff --git a/crates/itofin-py/src/lib.rs b/crates/itofin-py/src/lib.rs index f6a759c..fbcbaf7 100644 --- a/crates/itofin-py/src/lib.rs +++ b/crates/itofin-py/src/lib.rs @@ -43,7 +43,10 @@ use settings::PySettings; use swap::{PySwapType, PyVanillaSwap}; use swaption::{PyEuropeanExercise, PySettlementMethod, PySettlementType, PySwaption}; use swaptionengine::{PyBlackSwaptionEngine, PyCashAnnuityModel}; -use swaptionvol::{PyConstantSwaptionVolatility, PySwaptionVolatilityStructure, PyVolatilityType}; +use swaptionvol::{ + PyConstantSwaptionVolatility, PySwaptionVolatilityMatrix, PySwaptionVolatilityStructure, + PyVolatilityType, +}; use time::{ PyBusinessDayConvention, PyCalendar, PyDate, PyDayCounter, PyFrequency, PyPeriod, PySchedule, }; @@ -130,6 +133,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/swaptionvol.rs b/crates/itofin-py/src/swaptionvol.rs index aa0a9f7..8141aff 100644 --- a/crates/itofin-py/src/swaptionvol.rs +++ b/crates/itofin-py/src/swaptionvol.rs @@ -1,6 +1,7 @@ //! Facades for the swaption volatility stack: the [`PySwaptionVolatilityStructure`] -//! base, the [`PyVolatilityType`] flag and the constant surface -//! [`PyConstantSwaptionVolatility`]. +//! base, the [`PyVolatilityType`] flag, the constant surface +//! [`PyConstantSwaptionVolatility`] and the at-the-money grid +//! [`PySwaptionVolatilityMatrix`]. //! //! The base holds the erased `Handle` and //! exposes the queries every concrete surface inherits; concrete surfaces @@ -17,12 +18,17 @@ 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; +use libitofin::quotes::Quote; use libitofin::shared::{Shared, shared}; use libitofin::termstructures::volatility::{ - ConstantSwaptionVolatility, SwaptionVolatilityStructure, VolatilityType, + ConstantSwaptionVolatility, SwaptionVolatilityMatrix, SwaptionVolatilityStructure, + VolatilityType, }; +use libitofin::time::period::Period; use pyo3::prelude::*; /// Python `SwaptionVolatilityStructure`: the shared base for every swaption @@ -215,3 +221,169 @@ impl PyConstantSwaptionVolatility { ) } } + +/// Python `SwaptionVolatilityMatrix`: the at-the-money volatility grid, +/// bilinear over an option-tenor x swap-tenor lattice +/// (`termstructures::volatility::SwaptionVolatilityMatrix`). +/// +/// Extends [`PySwaptionVolatilityStructure`] and supplies only the +/// constructors. Every grid is a row per option tenor and a column per swap +/// tenor; `shifts`, when given, must match that shape, and `None` means +/// all-zero shifts. The grid is at the money, so a query's strike argument is +/// range-checked and then ignored. `flat_extrapolation` selects C++'s +/// `flatExtrapolation = true`, under which a query past the grid clamps to the +/// nearest edge or corner vol instead of extending the boundary surface. +#[pyclass(name = "SwaptionVolatilityMatrix", extends = PySwaptionVolatilityStructure, unsendable)] +pub struct PySwaptionVolatilityMatrix; + +#[pymethods] +impl PySwaptionVolatilityMatrix { + /// A grid on a pinned reference date over fixed volatilities: every query's + /// option time runs from `reference_date`, not from the evaluation date. + #[new] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (reference_date, calendar, business_day_convention, option_tenors, swap_tenors, volatilities, day_counter, volatility_type, shifts = None, flat_extrapolation = false))] + fn new( + reference_date: &PyDate, + calendar: &PyCalendar, + business_day_convention: &PyBusinessDayConvention, + option_tenors: Vec>, + swap_tenors: Vec>, + volatilities: Vec>, + day_counter: &PyDayCounter, + volatility_type: PyVolatilityType, + shifts: Option>>, + flat_extrapolation: bool, + ) -> PyResult> { + let volatilities = matrix_from_rows(&volatilities, "volatility")?; + let shifts = match shifts { + Some(rows) => matrix_from_rows(&rows, "shift")?, + None => Matrix::new(), + }; + let build = if flat_extrapolation { + SwaptionVolatilityMatrix::new_flat + } else { + SwaptionVolatilityMatrix::new + }; + let surface = shared( + build( + reference_date.inner(), + calendar.inner(), + business_day_convention.inner(), + tenors(&option_tenors), + tenors(&swap_tenors), + &volatilities, + day_counter.inner(), + volatility_type.inner(), + &shifts, + ) + .map_err(PyQlError::from)?, + ) as Shared; + Ok( + PyClassInitializer::from(PySwaptionVolatilityStructure::from_handle(Handle::new( + surface, + ))) + .add_subclass(PySwaptionVolatilityMatrix), + ) + } + + /// A grid whose reference date floats off `settings`' evaluation date (zero + /// settlement days), reading each node from the caller's quote: a later + /// `set_value` on any of them rebuilds the interpolation and notifies the + /// grid's observers. + #[staticmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (calendar, business_day_convention, option_tenors, swap_tenors, volatilities, day_counter, volatility_type, settings, shifts = None, flat_extrapolation = false))] + fn moving( + py: Python<'_>, + calendar: &PyCalendar, + business_day_convention: &PyBusinessDayConvention, + option_tenors: Vec>, + swap_tenors: Vec>, + volatilities: Vec>>, + day_counter: &PyDayCounter, + volatility_type: PyVolatilityType, + settings: &PySettings, + shifts: Option>>, + flat_extrapolation: bool, + ) -> PyResult> { + check_grid(&volatilities, "volatility")?; + let volatilities: Vec>> = volatilities + .iter() + .map(|row| row.iter().map(|quote| quote.handle()).collect()) + .collect(); + let shifts = match shifts { + Some(rows) => { + check_grid(&rows, "shift")?; + rows + } + None => Vec::new(), + }; + let build = if flat_extrapolation { + SwaptionVolatilityMatrix::moving_flat + } else { + SwaptionVolatilityMatrix::moving + }; + let surface = shared( + build( + calendar.inner(), + business_day_convention.inner(), + tenors(&option_tenors), + tenors(&swap_tenors), + volatilities, + day_counter.inner(), + volatility_type.inner(), + shifts, + settings.inner(), + ) + .map_err(PyQlError::from)?, + ) as Shared; + Py::new( + py, + PyClassInitializer::from(PySwaptionVolatilityStructure::from_handle(Handle::new( + surface, + ))) + .add_subclass(PySwaptionVolatilityMatrix), + ) + } +} + +/// The wrapped core periods, in order. +fn tenors(periods: &[PyRef<'_, PyPeriod>]) -> Vec { + periods.iter().map(|period| period.inner()).collect() +} + +/// The shape check both grid constructors share, rejecting an empty or ragged +/// `list[list[...]]` before it reaches the core's dimension checks. Returns the +/// common row length. +fn check_grid(rows: &[Vec], label: &str) -> PyResult { + let Some(first) = rows.first() else { + return Err(crate::ItofinError::new_err(format!( + "swaption {label} grid must have at least one row" + ))); + }; + let columns = first.len(); + if columns == 0 { + return Err(crate::ItofinError::new_err(format!( + "swaption {label} grid rows must have at least one column" + ))); + } + if rows.iter().any(|row| row.len() != columns) { + return Err(crate::ItofinError::new_err(format!( + "swaption {label} grid rows must all have the same length" + ))); + } + Ok(columns) +} + +/// A `list[list[float]]` as a core [`Matrix`], one row per option tenor. +fn matrix_from_rows(rows: &[Vec], label: &str) -> PyResult { + let columns = check_grid(rows, label)?; + let mut matrix = Matrix::with_size(rows.len(), columns); + for (i, row) in rows.iter().enumerate() { + for (j, &value) in row.iter().enumerate() { + matrix[(i, j)] = value; + } + } + Ok(matrix) +} From 366517555ed8611c3e137ccc2c49821810097ddc Mon Sep 17 00:00:00 2001 From: benbenbang Date: Mon, 27 Jul 2026 10:16:52 +0800 Subject: [PATCH 2/3] test(itofin-py): add swaption vol matrix tests Add a new test file for the swaption volatility matrix functionality in the itofin-py crate. Closes #613 --- .../tests/test_swaption_vol_matrix.py | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 crates/itofin-py/tests/test_swaption_vol_matrix.py diff --git a/crates/itofin-py/tests/test_swaption_vol_matrix.py b/crates/itofin-py/tests/test_swaption_vol_matrix.py new file mode 100644 index 0000000..36641b3 --- /dev/null +++ b/crates/itofin-py/tests/test_swaption_vol_matrix.py @@ -0,0 +1,269 @@ +"""Oracle for the interpolated ATM swaption vol matrix facade (issue #613). + +The fixture is the core module test's, in turn QuantLib's +``swaptionvolstructuresutilities.hpp`` grid: TARGET, ModifiedFollowing, +Actual/365F, option tenors [1M,6M,1Y,5Y,10Y,30Y] x swap tenors [1Y,5Y,10Y,30Y] +and the 6x4 vols, all read against evaluation date 15-June-2026. + +Three arms: + +A. Node recovery on the fixed-reference constructor. Bilinear is exact at the + nodes, so all 24 come back; the core asserts 1e-16 and this pass keeps 1e-12. + The query strike is 0.05, not the core's 0.0, so it also pins that the ATM + grid range-checks the strike and then ignores it. +B. Quote-bump refresh on the moving constructor. Reading a node, bumping its + quote and re-reading pins the observer chain end to end through Python: an + interpolation built once and never rebuilt serves the stale vol. Bumping a + DIFFERENT node's quote and re-reading the first pins that the grid rebuilds + from the quotes rather than flooding one value across the lattice. +C. Engine integration. The same swaption priced on the matrix and on a + ``ConstantSwaptionVolatility`` at the vol the matrix serves for that + swaption's own coordinates must agree. + + Arm C's coincidence is exact by construction, not to a tolerance. The engine + reads ``black_variance(exercise_date, swap_length, strike)``, i.e. + ``vol^2 * time_from_reference(exercise_date)``; both surfaces are Act/365F off + 15-June-2026 with a zero shift, so given the same vol the two routes run the + identical float sequence over the identical swap, curve and annuity. + + The coordinates are made to line up rather than assumed. EXERCISE is built by + advancing EVAL 2Y on TARGET/ModifiedFollowing, which is exactly what the + surface's own tenor-to-date conversion does, so ``volatility(2Y, ...)`` reads + the engine's option date. The swap runs EXERCISE to EXERCISE+7y = 2556 days, + and the engine's ``swap_length`` rounds ``2556/365.25*12 = 83.98`` to 84 + months = 7.0 years exactly, which is what the 7Y swap tenor resolves to. + + 2Y x 7Y sits strictly inside the grid but on no node, so the matrix must + interpolate: an interpolator that snapped to a node would be caught by + ``test_the_engine_coordinates_are_off_the_nodes``. What arm C pins is that + the engine reads THIS surface at the swaption's own coordinates - both sides + read the interpolated number from the same matrix, so it is not an + independent check of that number. Arm A and the core's 1e-16 test cover that. +""" + +import pytest + +from itofin import Settings +from itofin.indexes import Euribor +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 ( + ConstantSwaptionVolatility, + FlatForward, + SwaptionVolatilityMatrix, + VolatilityType, +) +from itofin.time import ( + BusinessDayConvention, + Calendar, + Date, + DayCounter, + Frequency, + Period, + Schedule, +) + +EVAL = Date(15, 6, 2026) +BDC = BusinessDayConvention.ModifiedFollowing + +OPTION_TENORS = [ + Period(1, "Months"), + Period(6, "Months"), + Period(1, "Years"), + Period(5, "Years"), + Period(10, "Years"), + Period(30, "Years"), +] +SWAP_TENORS = [ + Period(1, "Years"), + Period(5, "Years"), + Period(10, "Years"), + Period(30, "Years"), +] +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], +] + +TOL = 1e-12 +STRIKE = 0.03 +QUERY_STRIKE = 0.05 +BUMP = 0.05 + +SETTINGS = Settings() +SETTINGS.set_evaluation_date(EVAL) + +EXERCISE = Calendar.target().advance(EVAL, 2, "Years", BDC, False) +SWAP_END = Date(EXERCISE.day, EXERCISE.month, EXERCISE.year + 7) +ENGINE_OPTION_TENOR = Period(2, "Years") +ENGINE_SWAP_TENOR = Period(7, "Years") +SPAN_DAYS = 2556 + + +def _fixed_matrix(): + return SwaptionVolatilityMatrix( + EVAL, + Calendar.target(), + BDC, + OPTION_TENORS, + SWAP_TENORS, + VOLS, + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + ) + + +def _moving_matrix(): + """The grid over a distinct ``SimpleQuote`` per node, plus those quotes.""" + quotes = [[SimpleQuote(vol) for vol in row] for row in VOLS] + matrix = SwaptionVolatilityMatrix.moving( + Calendar.target(), + BDC, + OPTION_TENORS, + SWAP_TENORS, + quotes, + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + SETTINGS, + ) + return matrix, quotes + + +def _fixture(): + """A fresh curve/swap/swaption on the one shared ``Settings``. + + Every arm rebuilds its own: an ``Instrument`` caches its NPV and the engine + silently installs its own discounting engine on the swap it prices, so a + reused swaption would let arm C pass on a stale number. + """ + curve = FlatForward(EVAL, 0.03, DayCounter.actual365_fixed()) + 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, + ) + return curve, floating, swaption + + +def _npv_on(surface): + curve, _floating, swaption = _fixture() + swaption.set_black_engine( + BlackSwaptionEngine(surface, curve, SETTINGS, CashAnnuityModel.SwapRate) + ) + return swaption.npv() + + +def test_fixed_reference_matrix_recovers_every_node(): + matrix = _fixed_matrix() + for i, option_tenor in enumerate(OPTION_TENORS): + for j, swap_tenor in enumerate(SWAP_TENORS): + got = matrix.volatility(option_tenor, swap_tenor, QUERY_STRIKE) + assert got == pytest.approx(VOLS[i][j], abs=TOL), f"node ({i},{j})" + + +def test_the_atm_matrix_ignores_the_query_strike(): + matrix = _fixed_matrix() + coordinates = (ENGINE_OPTION_TENOR, ENGINE_SWAP_TENOR) + assert matrix.volatility(*coordinates, 0.0) == matrix.volatility(*coordinates, 0.20) + + +def test_a_quote_bump_refreshes_the_moving_matrix(): + matrix, quotes = _moving_matrix() + node = (OPTION_TENORS[0], SWAP_TENORS[0], STRIKE) + neighbour = (OPTION_TENORS[0], SWAP_TENORS[1], STRIKE) + + assert matrix.volatility(*node) == pytest.approx(VOLS[0][0], abs=TOL) + + quotes[0][0].set_value(VOLS[0][0] + BUMP) + assert matrix.volatility(*node) == pytest.approx(VOLS[0][0] + BUMP, abs=TOL) + + quotes[0][1].set_value(VOLS[0][1] + BUMP) + assert matrix.volatility(*node) == pytest.approx(VOLS[0][0] + BUMP, abs=TOL), ( + "bumping a neighbour's quote must leave this node alone" + ) + assert matrix.volatility(*neighbour) == pytest.approx(VOLS[0][1] + BUMP, abs=TOL) + + +def test_the_engine_coordinates_are_off_the_nodes(): + """2Y x 7Y is inside the grid but on no node, so the served vol is genuinely + interpolated: it lies strictly between its four surrounding node vols.""" + matrix = _fixed_matrix() + served = matrix.volatility(ENGINE_OPTION_TENOR, ENGINE_SWAP_TENOR, STRIKE) + corners = [VOLS[2][1], VOLS[2][2], VOLS[3][1], VOLS[3][2]] + assert served not in [vol for row in VOLS for vol in row] + assert min(corners) < served < max(corners) + + +def test_the_swap_spans_the_seven_year_tenor_the_matrix_is_queried_at(): + """The engine reads swap_length off the floating schedule's endpoints, which + are the unadjusted EXERCISE and SWAP_END only because the schedule says so. + 2556 days round to 84 months, the 7Y the matrix query resolves to.""" + _curve, floating, _swaption = _fixture() + dates = floating.dates() + assert dates[0] == EXERCISE + assert dates[-1] == SWAP_END + assert EXERCISE + SPAN_DAYS == SWAP_END + assert round(SPAN_DAYS / 365.25 * 12.0) / 12.0 == 7.0 + + +def test_the_engine_prices_off_this_matrix(): + matrix = _fixed_matrix() + served = matrix.volatility(ENGINE_OPTION_TENOR, ENGINE_SWAP_TENOR, STRIKE) + constant = ConstantSwaptionVolatility( + EVAL, + Calendar.null_calendar(), + BusinessDayConvention.Following, + served, + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + 0.0, + ) + npv_matrix = _npv_on(matrix) + npv_constant = _npv_on(constant) + print( + f"\nserved vol at 2Yx7Y = {served!r}" + f"\nnpv on the matrix = {npv_matrix!r}" + f"\nnpv on the constant = {npv_constant!r}" + ) + assert npv_matrix > 0.0 + assert npv_matrix == npv_constant From 44b734e54303c84f2cf6401425a61e545daaa359 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Mon, 27 Jul 2026 10:20:15 +0800 Subject: [PATCH 3/3] test(swaption-vol): verify flat extrapolation clamps past the grid Add a test covering flat extrapolation behavior for both the fixed and moving `SwaptionVolatilityMatrix` constructors. Past the 30Y option tenor, the plain grid extends the boundary surface while the flat one clamps to the 30Y x 30Y corner vol exactly. Pinning both constructors guards against an inverted flag or a route that ignores it, which would otherwise be invisible. close #613 --- .../tests/test_swaption_vol_matrix.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/itofin-py/tests/test_swaption_vol_matrix.py b/crates/itofin-py/tests/test_swaption_vol_matrix.py index 36641b3..5213b05 100644 --- a/crates/itofin-py/tests/test_swaption_vol_matrix.py +++ b/crates/itofin-py/tests/test_swaption_vol_matrix.py @@ -246,6 +246,47 @@ def test_the_swap_spans_the_seven_year_tenor_the_matrix_is_queried_at(): assert round(SPAN_DAYS / 365.25 * 12.0) / 12.0 == 7.0 +def test_flat_extrapolation_clamps_past_the_grid(): + """Both constructors route on flat_extrapolation, so both are pinned: an + inverted flag or a route that ignores it is otherwise invisible. Past the + 30Y option tenor the plain grid extends the boundary surface while the flat + one clamps to the 30Y x 30Y corner vol exactly.""" + past = (Period(40, "Years"), SWAP_TENORS[-1], STRIKE, True) + corner = VOLS[-1][-1] + + plain_fixed = _fixed_matrix() + flat_fixed = SwaptionVolatilityMatrix( + EVAL, + Calendar.target(), + BDC, + OPTION_TENORS, + SWAP_TENORS, + VOLS, + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + None, + True, + ) + assert flat_fixed.volatility(*past) == pytest.approx(corner, abs=TOL) + assert plain_fixed.volatility(*past) != flat_fixed.volatility(*past) + + plain_moving, _quotes = _moving_matrix() + flat_moving = SwaptionVolatilityMatrix.moving( + Calendar.target(), + BDC, + OPTION_TENORS, + SWAP_TENORS, + [[SimpleQuote(vol) for vol in row] for row in VOLS], + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + SETTINGS, + None, + True, + ) + assert flat_moving.volatility(*past) == pytest.approx(corner, abs=TOL) + assert plain_moving.volatility(*past) != flat_moving.volatility(*past) + + def test_the_engine_prices_off_this_matrix(): matrix = _fixed_matrix() served = matrix.volatility(ENGINE_OPTION_TENOR, ENGINE_SWAP_TENOR, STRIKE)