diff --git a/crates/itofin-py/python/itofin/instruments.pyi b/crates/itofin-py/python/itofin/instruments.pyi index e582294..bb1063d 100644 --- a/crates/itofin-py/python/itofin/instruments.pyi +++ b/crates/itofin-py/python/itofin/instruments.pyi @@ -1,10 +1,10 @@ # Hand-written stubs for itofin.instruments; sync manually with src/option.rs, -# src/swap.rs and src/swaption.rs (#517). +# src/swap.rs, src/swaption.rs and src/capfloor.rs (#517). from itofin import Settings from itofin.indexes import Euribor from itofin.models import HestonModel, HullWhite -from itofin.pricingengines import BlackSwaptionEngine +from itofin.pricingengines import BlackCapFloorEngine, BlackSwaptionEngine from itofin.processes import BlackScholesProcess from itofin.termstructures import YieldTermStructure from itofin.time import Date, DayCounter, Period, Schedule @@ -115,3 +115,43 @@ class Swaption: model. The engine must carry the same Settings object as this swaption.""" ... def npv(self) -> float: ... + +class CapFloorType: + """Whether the instrument caps or floors its floating leg. + + The core enum's third variant, Collar, is not exposed: MakeCapFloor rejects + it and the raw-leg constructor needs an IborLeg facade that does not exist + yet, so a collar has no construction path from Python.""" + + Cap: CapFloorType + Floor: CapFloorType + +class CapFloor: + """A cap or floor over a floating (ibor) leg, built through the standard + market builder MakeCapFloor. + + The leg carries a unit nominal and one strike, padded across every coupon. A + zero forward_start excludes the spot caplet, so the leg is one coupon shorter + than the schedule: that is what lets the cap price without a historical index + fixing at the evaluation date.""" + + def __init__( + self, + cap_floor_type: CapFloorType, + tenor: Period, + ibor_index: Euribor, + strike: float, + forward_start: Period, + settings: Settings, + ) -> None: ... + def cap_rates(self) -> list[float]: ... + def floor_rates(self) -> list[float]: ... + def coupon_count(self) -> int: ... + def set_black_engine(self, engine: BlackCapFloorEngine) -> None: + """Price each optionlet off an optionlet volatility surface. The engine + must resolve its dates against the same Settings object as this + cap/floor.""" + ... + def npv(self) -> float: + """Raises ItofinError with no engine attached.""" + ... diff --git a/crates/itofin-py/python/itofin/pricingengines.pyi b/crates/itofin-py/python/itofin/pricingengines.pyi index ef50aa1..c0e673e 100644 --- a/crates/itofin-py/python/itofin/pricingengines.pyi +++ b/crates/itofin-py/python/itofin/pricingengines.pyi @@ -1,8 +1,13 @@ -# Hand-written stubs for itofin.pricingengines; sync manually with src/swaptionengine.rs (#517). +# Hand-written stubs for itofin.pricingengines; sync manually with src/swaptionengine.rs +# and src/capfloorengine.rs (#517). from itofin import Settings from itofin.quotes import SimpleQuote -from itofin.termstructures import SwaptionVolatilityStructure, YieldTermStructure +from itofin.termstructures import ( + OptionletVolatilityStructure, + SwaptionVolatilityStructure, + YieldTermStructure, +) from itofin.time import DayCounter class CashAnnuityModel: @@ -41,3 +46,37 @@ class BlackSwaptionEngine: constant surface on a null calendar whose reference date tracks the evaluation date. displacement is that surface's lognormal shift.""" ... + +class BlackCapFloorEngine: + """The shifted-lognormal Black-formula cap/floor engine, one Black 1976 + optionlet per coupon. + + Only the shifted-lognormal path is priced in the core, so a normal-volatility + surface is rejected by the constructor rather than bound to a Bachelier + engine. The instrument this engine prices must resolve its dates against the + same Settings object the engine does.""" + + def __init__( + self, + vol: OptionletVolatilityStructure, + discount: YieldTermStructure, + displacement: float | None = None, + ) -> None: + """Raises ItofinError on a normal-volatility surface, and when a given + displacement differs from the surface's own. None adopts the surface's + displacement.""" + ... + @staticmethod + def with_flat_vol( + discount: YieldTermStructure, + vol: SimpleQuote, + day_counter: DayCounter, + displacement: float, + settings: Settings, + ) -> BlackCapFloorEngine: + """An engine over a flat volatility quote, wrapped internally in a + constant optionlet surface on a null calendar whose reference date + tracks the evaluation date. displacement is that surface's lognormal + shift.""" + ... + def displacement(self) -> float: ... diff --git a/crates/itofin-py/python/itofin/termstructures.pyi b/crates/itofin-py/python/itofin/termstructures.pyi index 4f16f77..6d428ff 100644 --- a/crates/itofin-py/python/itofin/termstructures.pyi +++ b/crates/itofin-py/python/itofin/termstructures.pyi @@ -1,5 +1,5 @@ # Hand-written stubs for itofin.termstructures; sync manually with src/curve.rs, src/vol.rs, -# src/helpers.rs, src/swaptionvol.rs and src/smilesection.rs (#517). +# src/helpers.rs, src/swaptionvol.rs, src/optionletvol.rs and src/smilesection.rs (#517). from itofin import Settings from itofin.indexes import Estr, Euribor, SwapIndex @@ -598,3 +598,55 @@ class SabrSwaptionVolatilityCube(SwaptionVolatilityStructure): """The at-the-money strike for an option tenor and swap tenor: the strike the fitted smile is centred on.""" ... + +class OptionletVolatilityStructure: + """Shared base for every caplet/floorlet volatility surface: volatility, + Black variance and the lognormal displacement. + + A single option axis, unlike the swaption surfaces: a query takes one option + tenor (or date) and a strike.""" + + def volatility( + self, option_tenor: Period, strike: float, extrapolate: bool = False + ) -> float: ... + def volatility_date( + self, option_date: Date, strike: float, extrapolate: bool = False + ) -> float: ... + def black_variance( + self, option_tenor: Period, strike: float, extrapolate: bool = False + ) -> float: ... + def displacement(self) -> float: + """The lognormal shift applied to forwards and strikes. This is what + BlackCapFloorEngine checks a caller-supplied displacement against.""" + ... + +class ConstantOptionletVolatility(OptionletVolatilityStructure): + """A single caplet volatility with no option-time or strike dependence. + + 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.""" + + def __init__( + self, + reference_date: Date, + calendar: Calendar, + business_day_convention: BusinessDayConvention, + volatility: float, + day_counter: DayCounter, + volatility_type: VolatilityType, + displacement: float = 0.0, + ) -> None: ... + @staticmethod + def with_quote( + reference_date: Date, + calendar: Calendar, + business_day_convention: BusinessDayConvention, + volatility: SimpleQuote, + day_counter: DayCounter, + volatility_type: VolatilityType, + displacement: float = 0.0, + ) -> ConstantOptionletVolatility: + """Reads the volatility from the caller's quote; a later set_value + notifies the surface's observers.""" + ... diff --git a/crates/itofin-py/src/capfloor.rs b/crates/itofin-py/src/capfloor.rs new file mode 100644 index 0000000..a24980c --- /dev/null +++ b/crates/itofin-py/src/capfloor.rs @@ -0,0 +1,132 @@ +//! Facades for the cap/floor stack: the [`PyCapFloorType`] flag and the +//! [`PyCapFloor`] instrument. +//! +//! The core [`CapFloor`] constructors (`CapFloor::cap` / `floor` / `collar`) +//! take a leg of concrete `IborCoupon`s, which Python cannot build: there is no +//! `IborLeg` facade. [`PyCapFloor`] therefore wraps the standard market builder +//! [`MakeCapFloor`] instead, the same shape the core's own cap/floor fixtures +//! use: a tenor, an ibor index, a single strike and a forward start. +//! +//! The builder derives the floating leg off `MakeVanillaSwap`, so a zero +//! `forward_start` drops the spot caplet (`makecapfloor.cpp:34`): the cap needs +//! no historical fixing at the evaluation date, which is what makes it buildable +//! under the explicit-fixings design (D5/D11). +//! +//! Deferred (visible): `CapFloorType.Collar` is not exposed. [`MakeCapFloor`] +//! rejects a collar - it has no single-strike form +//! (`makecapfloor.rs:135`) - and the raw-leg `CapFloor::collar` constructor needs +//! an `IborLeg` facade that does not exist yet, so a collar has no reachable +//! construction path from Python. Exposing it needs that leg facade first; +//! tracked as #626. + +use crate::PyQlError; +use crate::capfloorengine::PyBlackCapFloorEngine; +use crate::hullwhite::PyEuribor; +use crate::settings::PySettings; +use crate::time::PyPeriod; +use libitofin::instrument::Instrument; +use libitofin::instruments::{CapFloor, CapFloorType, MakeCapFloor}; +use pyo3::prelude::*; + +/// Python `CapFloorType`: whether the instrument caps or floors its floating leg +/// (`instruments::capfloor::CapFloorType`). +/// +/// A fieldless pyo3 enum. The core enum's third variant, `Collar`, is not +/// exposed: see the module docs for why it has no construction path here. +#[pyclass(name = "CapFloorType", eq, eq_int, from_py_object)] +#[derive(Clone, Copy, PartialEq)] +pub enum PyCapFloorType { + Cap, + Floor, +} + +impl PyCapFloorType { + /// The core [`CapFloorType`] this variant stands for. + fn inner(&self) -> CapFloorType { + match self { + PyCapFloorType::Cap => CapFloorType::Cap, + PyCapFloorType::Floor => CapFloorType::Floor, + } + } +} + +/// Python `CapFloor`: a cap or floor over a floating (ibor) leg +/// (`instruments::capfloor::CapFloor`). +/// +/// Built through [`MakeCapFloor`] (fallible: it derives the floating leg, so a +/// degenerate schedule or an unset evaluation date surfaces as an +/// `ItofinError`). The leg carries a unit nominal and one strike, padded across +/// every coupon by the core constructor. +/// +/// Pricing needs an engine: call +/// [`set_black_engine`](Self::set_black_engine) before [`npv`](Self::npv). +#[pyclass(name = "CapFloor", unsendable)] +pub struct PyCapFloor { + inner: CapFloor, +} + +#[pymethods] +impl PyCapFloor { + /// A standard market cap or floor of `tenor` on `ibor_index`, struck at + /// `strike` and starting `forward_start` after spot. + /// + /// A zero `forward_start` excludes the spot caplet, so the leg is one coupon + /// shorter than the schedule; see the module docs. + #[new] + fn new( + cap_floor_type: PyCapFloorType, + tenor: &PyPeriod, + ibor_index: &PyEuribor, + strike: f64, + forward_start: &PyPeriod, + settings: &PySettings, + ) -> PyResult { + Ok(PyCapFloor { + inner: MakeCapFloor::new( + cap_floor_type.inner(), + tenor.inner(), + ibor_index.inner(), + strike, + forward_start.inner(), + settings.inner(), + ) + .build() + .map_err(PyQlError::from)?, + }) + } + + /// The cap strikes, one per coupon; empty for a floor. + fn cap_rates(&self) -> Vec { + self.inner.cap_rates().to_vec() + } + + /// The floor strikes, one per coupon; empty for a cap. + fn floor_rates(&self) -> Vec { + self.inner.floor_rates().to_vec() + } + + /// The number of optionlets, one per floating coupon. + fn coupon_count(&self) -> usize { + self.inner.coupons().len() + } + + /// Attaches a [`PyBlackCapFloorEngine`] so the cap/floor prices each + /// optionlet off an optionlet volatility surface. + /// + /// The engine is built separately and installed here, so the same engine can + /// be shared across instruments. It must resolve its dates against the same + /// `Settings` object this cap/floor was built with: two different settings + /// would price the leg and the optionlets on different dates without any + /// error being raised. + fn set_black_engine(&mut self, engine: &PyBlackCapFloorEngine) { + self.inner.base_mut().set_pricing_engine(engine.engine()); + } + + /// The cap/floor NPV under the attached engine. + /// + /// Fallible: with no engine attached the core reports `"null pricing + /// engine"` as an `ItofinError`. + fn npv(&mut self) -> PyResult { + Ok(self.inner.npv().map_err(PyQlError::from)?) + } +} diff --git a/crates/itofin-py/src/capfloorengine.rs b/crates/itofin-py/src/capfloorengine.rs new file mode 100644 index 0000000..46e761f --- /dev/null +++ b/crates/itofin-py/src/capfloorengine.rs @@ -0,0 +1,103 @@ +//! Facade for the Black cap/floor pricing engine: [`PyBlackCapFloorEngine`]. +//! +//! The engine prices each optionlet of a [`CapFloor`](crate::capfloor::PyCapFloor) +//! with the Black 1976 formula over an optionlet volatility surface, discounting +//! on a separate yield curve. +//! +//! Both core constructors are exposed. The surface form takes a +//! [`PyOptionletVolatilityStructure`] and an optional displacement; the flat-vol +//! form takes a single quote and wraps it in a moving constant surface with zero +//! settlement days on a null calendar, so that surface's reference date IS the +//! evaluation date carried by `settings`. +//! +//! Deferred (visible): the Bachelier cap/floor engine is not exposed - the core +//! prices only the shifted-lognormal path +//! (`blackcapfloorengine.rs:22-25`), so a normal-volatility surface is rejected +//! by the constructor rather than bound to a second engine here. + +use crate::PyQlError; +use crate::curve::PyYieldTermStructure; +use crate::market::PySimpleQuote; +use crate::optionletvol::PyOptionletVolatilityStructure; +use crate::settings::PySettings; +use crate::time::PyDayCounter; +use libitofin::pricingengine::PricingEngine; +use libitofin::pricingengines::capfloor::BlackCapFloorEngine; +use libitofin::shared::{SharedMut, shared_mut}; +use pyo3::prelude::*; + +/// Python `BlackCapFloorEngine`: the shifted-lognormal Black-formula cap/floor +/// engine (`pricingengines::capfloor::BlackCapFloorEngine`). +/// +/// The `settings` behind the instrument this engine prices must be the same +/// object the engine resolves its own dates against, or the two disagree on the +/// evaluation date and the NPV is silently wrong. +#[pyclass(name = "BlackCapFloorEngine", unsendable)] +pub struct PyBlackCapFloorEngine { + inner: SharedMut, +} + +#[pymethods] +impl PyBlackCapFloorEngine { + /// An engine reading volatilities off `vol` and discounting on `discount`. + /// + /// Fallible at construction, unlike the swaption engine: the surface must be + /// shifted-lognormal, and a `displacement` given here must equal the + /// surface's own (`blackcapfloorengine.rs:75-82`). `None` adopts the + /// surface's displacement. + #[new] + #[pyo3(signature = (vol, discount, displacement = None))] + fn new( + vol: &PyOptionletVolatilityStructure, + discount: &PyYieldTermStructure, + displacement: Option, + ) -> PyResult { + Ok(PyBlackCapFloorEngine { + inner: shared_mut( + BlackCapFloorEngine::new(discount.handle(), vol.handle(), displacement) + .map_err(PyQlError::from)?, + ), + }) + } + + /// An engine over a flat volatility quote, which it wraps in a constant + /// optionlet surface on a null calendar whose reference date tracks the + /// evaluation date. `displacement` is the surface's lognormal shift. + /// + /// `displacement` carries no default, mirroring + /// [`BlackSwaptionEngine.with_flat_vol`](crate::swaptionengine::PyBlackSwaptionEngine): + /// a trailing `settings` cannot follow a defaulted argument. + #[staticmethod] + fn with_flat_vol( + discount: &PyYieldTermStructure, + vol: &PySimpleQuote, + day_counter: &PyDayCounter, + displacement: f64, + settings: &PySettings, + ) -> PyResult { + Ok(PyBlackCapFloorEngine { + inner: shared_mut( + BlackCapFloorEngine::with_flat_vol( + discount.handle(), + vol.handle(), + day_counter.inner(), + displacement, + settings.inner(), + ) + .map_err(PyQlError::from)?, + ), + }) + } + + /// The lognormal shift the engine applies to forwards and strikes. + fn displacement(&self) -> f64 { + self.inner.borrow().displacement() + } +} + +impl PyBlackCapFloorEngine { + /// The erased engine the instrument facades install via `set_pricing_engine`. + pub(crate) fn engine(&self) -> SharedMut { + SharedMut::clone(&self.inner) as SharedMut + } +} diff --git a/crates/itofin-py/src/lib.rs b/crates/itofin-py/src/lib.rs index 4c5ba51..584d884 100644 --- a/crates/itofin-py/src/lib.rs +++ b/crates/itofin-py/src/lib.rs @@ -6,12 +6,15 @@ //! tickets (#485-#487). mod calibration; +mod capfloor; +mod capfloorengine; mod curve; mod helpers; mod heston; mod hullwhite; mod market; mod option; +mod optionletvol; mod settings; mod smilesection; mod swap; @@ -23,6 +26,8 @@ mod time; mod vol; use calibration::{PyCalibrationErrorType, PyEndCriteria, PyLevenbergMarquardt}; +use capfloor::{PyCapFloor, PyCapFloorType}; +use capfloorengine::PyBlackCapFloorEngine; use curve::{ PyDiscountCurve, PyFlatForward, PyForwardCurve, PyPiecewiseFlatForward, PyPiecewiseLinearForward, PyPiecewiseLinearZero, PyPiecewiseLogLinearDiscount, @@ -37,6 +42,7 @@ use hullwhite::{PyEuribor, PyHullWhite, PySwaptionHelper}; use libitofin::errors::QlError; use market::{PyBlackScholesProcess, PySimpleQuote}; use option::{PyOptionType, PyVanillaOption}; +use optionletvol::{PyConstantOptionletVolatility, PyOptionletVolatilityStructure}; use pyo3::create_exception; use pyo3::exceptions::PyException; use pyo3::prelude::*; @@ -144,6 +150,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::()?; @@ -164,6 +172,8 @@ fn itofin(m: &Bound<'_, PyModule>) -> PyResult<()> { instruments.add_class::()?; instruments.add_class::()?; instruments.add_class::()?; + instruments.add_class::()?; + instruments.add_class::()?; let models = PyModule::new(py, "models")?; models.add_class::()?; @@ -175,6 +185,7 @@ fn itofin(m: &Bound<'_, PyModule>) -> PyResult<()> { let pricingengines = PyModule::new(py, "pricingengines")?; pricingengines.add_class::()?; pricingengines.add_class::()?; + pricingengines.add_class::()?; let optimization = PyModule::new(py, "optimization")?; optimization.add_class::()?; diff --git a/crates/itofin-py/src/optionletvol.rs b/crates/itofin-py/src/optionletvol.rs new file mode 100644 index 0000000..8c84a4d --- /dev/null +++ b/crates/itofin-py/src/optionletvol.rs @@ -0,0 +1,196 @@ +//! Facades for the optionlet (caplet/floorlet) volatility stack: the +//! [`PyOptionletVolatilityStructure`] base and the constant surface +//! [`PyConstantOptionletVolatility`]. +//! +//! The base holds the erased `Handle` and +//! exposes the queries every concrete surface inherits; concrete surfaces +//! subclass it and supply only their constructor. They build the base through +//! [`from_handle`](PyOptionletVolatilityStructure::from_handle) rather than a +//! struct literal, so the surfaces stacking on it in later tickets never need +//! access to the private field. +//! +//! Unlike the swaption surfaces, an optionlet surface has a single option axis: +//! a query takes one option tenor (or date) and a strike, not an option/swap +//! tenor pair. +//! +//! 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. +//! `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::market::PySimpleQuote; +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, +}; +use pyo3::prelude::*; + +/// Python `OptionletVolatilityStructure`: the shared base for every caplet +/// volatility surface +/// (`termstructures::volatility::OptionletVolatilityStructure`). +/// +/// The option axis is addressed by tenor, the form the surfaces are quoted in; +/// the core resolves the tenor against the surface's reference date and calendar +/// before reading the volatility. The date form is exposed too, since the +/// optionlet stripper and the cap/floor engine both address the surface by a +/// coupon's fixing date. +#[pyclass(name = "OptionletVolatilityStructure", subclass, unsendable)] +pub struct PyOptionletVolatilityStructure { + inner: Handle, +} + +#[pymethods] +impl PyOptionletVolatilityStructure { + /// The caplet volatility for an option tenor and strike. + #[pyo3(signature = (option_tenor, strike, extrapolate = false))] + fn volatility(&self, option_tenor: &PyPeriod, strike: f64, extrapolate: bool) -> PyResult { + Ok(self + .inner + .current_link() + .map_err(PyQlError::from)? + .volatility_tenor(option_tenor.inner(), strike, extrapolate) + .map_err(PyQlError::from)?) + } + + /// The caplet volatility for an option date and strike. + #[pyo3(signature = (option_date, strike, extrapolate = false))] + fn volatility_date( + &self, + option_date: &PyDate, + strike: f64, + extrapolate: bool, + ) -> PyResult { + Ok(self + .inner + .current_link() + .map_err(PyQlError::from)? + .volatility_date(option_date.inner(), strike, extrapolate) + .map_err(PyQlError::from)?) + } + + /// The Black variance (`vol^2 * option_time`) for an option tenor and strike. + #[pyo3(signature = (option_tenor, strike, extrapolate = false))] + fn black_variance( + &self, + option_tenor: &PyPeriod, + strike: f64, + extrapolate: bool, + ) -> PyResult { + Ok(self + .inner + .current_link() + .map_err(PyQlError::from)? + .black_variance_tenor(option_tenor.inner(), strike, extrapolate) + .map_err(PyQlError::from)?) + } + + /// The lognormal shift applied to forwards and strikes; `0.0` for the + /// unshifted lognormal and the normal model. + /// + /// This is what `BlackCapFloorEngine` checks a caller-supplied displacement + /// against, so it is the number to read before pinning one on the engine. + fn displacement(&self) -> PyResult { + Ok(self + .inner + .current_link() + .map_err(PyQlError::from)? + .displacement()) + } +} + +impl PyOptionletVolatilityStructure { + /// A clone of the inner surface handle for the engine facades. + #[allow(dead_code)] + pub(crate) fn handle(&self) -> Handle { + self.inner.clone() + } + + /// The base a concrete surface's `#[new]` extends, built from its erased + /// handle. The named constructor keeps the private field an implementation + /// detail of this module. + pub(crate) fn from_handle(inner: Handle) -> Self { + PyOptionletVolatilityStructure { inner } + } +} + +/// Python `ConstantOptionletVolatility`: a single caplet volatility with no +/// option-time or strike dependence +/// (`termstructures::volatility::ConstantOptionletVolatility`). +/// +/// Extends [`PyOptionletVolatilityStructure`] and supplies only the +/// constructors; the query surface is inherited. Unbounded in time and strike, +/// so queries never need extrapolation enabled. Both forms pin the reference +/// date, so the option time every query measures runs from `reference_date`, not +/// from the evaluation date. +#[pyclass(name = "ConstantOptionletVolatility", extends = PyOptionletVolatilityStructure, unsendable)] +pub struct PyConstantOptionletVolatility; + +#[pymethods] +impl PyConstantOptionletVolatility { + /// A constant surface at a fixed `volatility`, wrapped in an internal quote + /// the caller cannot later mutate. + #[new] + #[pyo3(signature = (reference_date, calendar, business_day_convention, volatility, day_counter, volatility_type, displacement = 0.0))] + fn new( + reference_date: &PyDate, + calendar: &PyCalendar, + business_day_convention: &PyBusinessDayConvention, + volatility: f64, + day_counter: &PyDayCounter, + volatility_type: PyVolatilityType, + displacement: f64, + ) -> PyClassInitializer { + let surface = shared(ConstantOptionletVolatility::new( + reference_date.inner(), + calendar.inner(), + business_day_convention.inner(), + volatility, + day_counter.inner(), + volatility_type.inner(), + displacement, + )) as Shared; + PyClassInitializer::from(PyOptionletVolatilityStructure::from_handle(Handle::new( + surface, + ))) + .add_subclass(PyConstantOptionletVolatility) + } + + /// A constant surface reading `volatility` from the caller's quote; a later + /// `set_value` on that quote notifies the surface's observers. + #[staticmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (reference_date, calendar, business_day_convention, volatility, day_counter, volatility_type, displacement = 0.0))] + fn with_quote( + py: Python<'_>, + reference_date: &PyDate, + calendar: &PyCalendar, + business_day_convention: &PyBusinessDayConvention, + volatility: &PySimpleQuote, + day_counter: &PyDayCounter, + volatility_type: PyVolatilityType, + displacement: f64, + ) -> PyResult> { + let surface = shared(ConstantOptionletVolatility::with_quote( + reference_date.inner(), + calendar.inner(), + business_day_convention.inner(), + volatility.handle(), + day_counter.inner(), + volatility_type.inner(), + displacement, + )) as Shared; + Py::new( + py, + PyClassInitializer::from(PyOptionletVolatilityStructure::from_handle(Handle::new( + surface, + ))) + .add_subclass(PyConstantOptionletVolatility), + ) + } +} diff --git a/crates/itofin-py/tests/test_capfloor.py b/crates/itofin-py/tests/test_capfloor.py new file mode 100644 index 0000000..73bbd25 --- /dev/null +++ b/crates/itofin-py/tests/test_capfloor.py @@ -0,0 +1,234 @@ +"""Oracle for the cap/floor + optionlet-vol + Black-engine facades (issue #621). + +The bindings oracle is the wrapped Rust numbers, and this pass is SMOKE and +STRUCTURAL by design. The core's ``blackcapfloorengine.rs`` cached NPV is built +over a hand-rolled ``IborLeg`` (notional 100, spot caplet kept) that ``MakeCapFloor`` +cannot reproduce - it uses a unit nominal and drops the spot caplet - and there is +no ``IborLeg`` facade, so that literal is unreachable from Python. The +discriminating numeric oracle for this vertical is the optionlet-stripper +round-trip in #623, which is construction-agnostic. + +What is pinned here: + +A. A 5Y Euribor6M cap at 4% on a flat 5% curve builds, refuses to price with no + engine, and prices finite and positive once a Black engine is attached; the + matching floor at 6% prices positive too. The strike reaches the engine: the + 4% cap is worth more than a 6% cap on the same leg. +B. The two engine constructors agree bit-for-bit. ``with_flat_vol`` wraps the + quote in a MOVING constant optionlet surface with 0 settlement days on a null + calendar, so its reference date IS the evaluation date - the same surface arm + B builds explicitly with a fixed reference at that date. The two routes run + the identical float sequence, so this is an equality, not a tolerance. This is + what makes the smoke arms construction-pinning without a cached literal. +C. The engine's displacement guard (``blackcapfloorengine.rs:75-82``) is reachable + from Python: a displacement that differs from the surface's own is an error. +D. ``CapFloorType`` exposes Cap and Floor only - Collar is deliberately absent. + +Every arm builds its own ``CapFloor``. An ``Instrument`` caches its NPV, so +reusing one cap across two engines would let arm B pass on a stale number. One +shared ``Settings`` drives everything: the instrument and the engine must agree +on the evaluation date or the NPV is silently wrong. +""" + +import math + +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 ( + ConstantOptionletVolatility, + FlatForward, + VolatilityType, +) +from itofin.time import BusinessDayConvention, Calendar, DayCounter, Date, Period + +EVAL = Date(15, 1, 2026) + +CURVE_RATE = 0.05 +CAP_STRIKE = 0.04 +FLOOR_STRIKE = 0.06 +OUT_OF_THE_MONEY_STRIKE = 0.06 + +VOL = 0.20 +TENOR = Period(5, "Years") +SPOT = Period(0, "Days") + +SETTINGS = Settings() +SETTINGS.set_evaluation_date(EVAL) + + +def _curve(): + return FlatForward(EVAL, CURVE_RATE, DayCounter.actual365_fixed()) + + +def _surface(displacement=0.0): + """The fixed-reference twin of the moving surface ``with_flat_vol`` builds.""" + return ConstantOptionletVolatility( + EVAL, + Calendar.null_calendar(), + BusinessDayConvention.Following, + VOL, + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + displacement, + ) + + +def _cap_floor(cap_floor_type, strike): + """A fresh 5Y instrument on the one shared ``Settings``.""" + curve = _curve() + index = Euribor.six_months(curve, SETTINGS) + return curve, CapFloor(cap_floor_type, TENOR, index, strike, SPOT, SETTINGS) + + +def test_a_cap_builds_and_carries_its_strike(): + _, cap = _cap_floor(CapFloorType.Cap, CAP_STRIKE) + assert cap.coupon_count() > 0 + assert cap.cap_rates() == [CAP_STRIKE] * cap.coupon_count() + assert cap.floor_rates() == [] + + +def test_a_floor_carries_its_strike_as_a_floor_rate(): + _, floor = _cap_floor(CapFloorType.Floor, FLOOR_STRIKE) + assert floor.floor_rates() == [FLOOR_STRIKE] * floor.coupon_count() + assert floor.cap_rates() == [] + + +def test_pricing_without_an_engine_raises(): + _, cap = _cap_floor(CapFloorType.Cap, CAP_STRIKE) + with pytest.raises(ItofinError): + cap.npv() + + +def _npv_via_surface(cap_floor_type, strike): + """Priced through the surface-handle engine constructor.""" + curve, instrument = _cap_floor(cap_floor_type, strike) + instrument.set_black_engine(BlackCapFloorEngine(_surface(), curve, None)) + return instrument.npv() + + +def _npv_via_flat_vol(cap_floor_type, strike): + """Priced through the flat-quote engine constructor, which builds the same + constant surface internally.""" + curve, instrument = _cap_floor(cap_floor_type, strike) + instrument.set_black_engine( + BlackCapFloorEngine.with_flat_vol( + curve, SimpleQuote(VOL), DayCounter.actual365_fixed(), 0.0, SETTINGS + ) + ) + return instrument.npv() + + +def test_a_cap_prices_finite_and_positive(): + npv = _npv_via_surface(CapFloorType.Cap, CAP_STRIKE) + print(f"\ncap@{CAP_STRIKE} npv = {npv!r}") + assert math.isfinite(npv) + assert npv > 0.0 + + +def test_a_floor_prices_finite_and_positive(): + npv = _npv_via_surface(CapFloorType.Floor, FLOOR_STRIKE) + print(f"\nfloor@{FLOOR_STRIKE} npv = {npv!r}") + assert math.isfinite(npv) + assert npv > 0.0 + + +def test_the_strike_reaches_the_engine(): + in_the_money = _npv_via_surface(CapFloorType.Cap, CAP_STRIKE) + out_of_the_money = _npv_via_surface(CapFloorType.Cap, OUT_OF_THE_MONEY_STRIKE) + assert out_of_the_money < in_the_money + + +def test_a_higher_volatility_raises_the_cap_price(): + """What makes the equality below non-degenerate: the surface is read, so the + two routes agreeing is a statement about the surface they build, not about + two vol-independent intrinsic values.""" + curve, cap = _cap_floor(CapFloorType.Cap, CAP_STRIKE) + cap.set_black_engine(BlackCapFloorEngine(_surface(), curve, None)) + _, dearer = _cap_floor(CapFloorType.Cap, CAP_STRIKE) + dearer.set_black_engine( + BlackCapFloorEngine.with_flat_vol( + curve, SimpleQuote(2.5 * VOL), DayCounter.actual365_fixed(), 0.0, SETTINGS + ) + ) + assert dearer.npv() > cap.npv() + + +def test_the_two_engine_constructors_price_identically(): + npv_surface = _npv_via_surface(CapFloorType.Cap, CAP_STRIKE) + npv_flat = _npv_via_flat_vol(CapFloorType.Cap, CAP_STRIKE) + print(f"\nnpv_surface = {npv_surface!r}\nnpv_flat_vol = {npv_flat!r}") + assert npv_surface == npv_flat, f"surface={npv_surface!r} flat={npv_flat!r}" + + +def test_a_displacement_differing_from_the_surface_raises(): + curve = _curve() + assert BlackCapFloorEngine(_surface(), curve, 0.0).displacement() == 0.0 + with pytest.raises(ItofinError): + BlackCapFloorEngine(_surface(), curve, 0.01) + + +def test_no_displacement_adopts_the_surfaces_own(): + """A shifted surface makes the two branches of the displacement match + distinguishable: with a 0.0-shift surface, None and 0.0 are the same number.""" + curve = _curve() + assert BlackCapFloorEngine(_surface(0.01), curve, None).displacement() == 0.01 + assert BlackCapFloorEngine(_surface(0.01), curve, 0.01).displacement() == 0.01 + with pytest.raises(ItofinError): + BlackCapFloorEngine(_surface(0.01), curve, 0.0) + + +def test_a_normal_surface_is_rejected_by_the_black_engine(): + normal = ConstantOptionletVolatility( + EVAL, + Calendar.null_calendar(), + BusinessDayConvention.Following, + VOL, + DayCounter.actual365_fixed(), + VolatilityType.Normal, + 0.0, + ) + with pytest.raises(ItofinError): + BlackCapFloorEngine(normal, _curve(), None) + + +def test_constant_surface_returns_the_constructed_volatility(): + surface = _surface() + for tenor in [1, 2, 5]: + assert surface.volatility(Period(tenor, "Years"), CAP_STRIKE) == VOL + assert surface.volatility_date(EVAL + 365 * tenor, CAP_STRIKE) == VOL + assert surface.displacement() == 0.0 + + +def test_constant_surface_black_variance_is_vol_squared_times_time(): + surface = _surface() + one_year = Period(1, "Years") + option_time = surface.black_variance(one_year, CAP_STRIKE) / (VOL * VOL) + assert option_time == pytest.approx(1.0, abs=0.01) + + +def test_quote_backed_surface_tracks_its_quote(): + quote = SimpleQuote(VOL) + surface = ConstantOptionletVolatility.with_quote( + EVAL, + Calendar.null_calendar(), + BusinessDayConvention.Following, + quote, + DayCounter.actual365_fixed(), + VolatilityType.ShiftedLognormal, + 0.0, + ) + one_year = (Period(1, "Years"), CAP_STRIKE) + assert surface.volatility(*one_year) == VOL + quote.set_value(0.25) + assert surface.volatility(*one_year) == 0.25 + + +def test_cap_floor_type_exposes_cap_and_floor_only(): + assert hasattr(CapFloorType, "Cap") + assert hasattr(CapFloorType, "Floor") + assert not hasattr(CapFloorType, "Collar")