Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions crates/itofin-py/python/itofin/instruments.pyi
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
...
43 changes: 41 additions & 2 deletions crates/itofin-py/python/itofin/pricingengines.pyi
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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: ...
54 changes: 53 additions & 1 deletion crates/itofin-py/python/itofin/termstructures.pyi
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
...
132 changes: 132 additions & 0 deletions crates/itofin-py/src/capfloor.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<f64> {
self.inner.cap_rates().to_vec()
}

/// The floor strikes, one per coupon; empty for a cap.
fn floor_rates(&self) -> Vec<f64> {
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<f64> {
Ok(self.inner.npv().map_err(PyQlError::from)?)
}
}
Loading
Loading