diff --git a/python/rateslib/enums/parameters.py b/python/rateslib/enums/parameters.py index e0e437c9f..cbc17e7f1 100644 --- a/python/rateslib/enums/parameters.py +++ b/python/rateslib/enums/parameters.py @@ -13,18 +13,14 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Never -from rateslib.rs import FloatFixingMethod, LegIndexBase +from rateslib.rs import FloatFixingMethod, IROptionMetric, LegIndexBase if TYPE_CHECKING: pass -class PicklingContainer: - pass - - class OptionType(float, Enum): """ Enumerable type to define option directions. @@ -43,21 +39,6 @@ class FXOptionMetric(Enum): Percent = 1 -class IROptionMetric(Enum): - """ - Enumerable type for IROption metrics. - """ - - NormalVol = 0 - LogNormalVol = 3 - PercentNotional = 4 - Cash = 5 - BlackVol = 1 - BlackVolShift100 = 6 - BlackVolShift200 = 7 - BlackVolShift300 = 8 - - class SwaptionSettlementMethod(Enum): """ Enumerable type for swaption settlement methods. @@ -303,22 +284,17 @@ def _get_fx_option_metric(method: str | FXOptionMetric) -> FXOptionMetric: ) -_IR_METRIC_MAP = { +_IR_METRIC_MAP: dict[str, type[IROptionMetric]] = { "normal_vol": IROptionMetric.NormalVol, - "normalvol": IROptionMetric.NormalVol, "log_normal_vol": IROptionMetric.LogNormalVol, - "lognormalvol": IROptionMetric.LogNormalVol, "cash": IROptionMetric.Cash, - "black_vol": IROptionMetric.BlackVol, - "blackvol": IROptionMetric.BlackVol, - "black_shift_100": IROptionMetric.BlackVolShift100, - "black_shift_200": IROptionMetric.BlackVolShift200, - "black_shift_300": IROptionMetric.BlackVolShift300, - "blackvolshift100": IROptionMetric.BlackVolShift100, - "blackvolshift200": IROptionMetric.BlackVolShift200, - "blackvolshift300": IROptionMetric.BlackVolShift300, "percent_notional": IROptionMetric.PercentNotional, + "black_vol_shift": IROptionMetric.BlackVolShift, + # aliases + "normalvol": IROptionMetric.NormalVol, + "lognormalvol": IROptionMetric.LogNormalVol, "percentnotional": IROptionMetric.PercentNotional, + "blackvolshift": IROptionMetric.BlackVolShift, } @@ -326,12 +302,26 @@ def _get_ir_option_metric(method: str | IROptionMetric) -> IROptionMetric: if isinstance(method, IROptionMetric): return method else: + method = method.lower() + if "shift" in method: + idx = method.rfind("_") + if idx < 0: + raise ValueError( + "The 'BlackVolShift' metric must have an underscore and shift, e.g. " + "'black_vol_shift_100" + ) + else: + args: tuple[Never, ...] | tuple[int]= (int(method[idx + 1 :]),) + method = method[:idx] + else: + args = tuple() + try: - return _IR_METRIC_MAP[method.lower()] + return _IR_METRIC_MAP[method](*args) except KeyError: raise ValueError( f"IROption `metric` as string: '{method}' is not a valid option. Please consult " - f"docs." + f"documentation." ) diff --git a/python/rateslib/instruments/ir_options/call_put.py b/python/rateslib/instruments/ir_options/call_put.py index 4c45d5a38..9ab149f17 100644 --- a/python/rateslib/instruments/ir_options/call_put.py +++ b/python/rateslib/instruments/ir_options/call_put.py @@ -406,7 +406,7 @@ def rate( metric=metric_, ) if ( - metric_ in [IROptionMetric.Cash, IROptionMetric.PercentNotional] + metric_ in [IROptionMetric.Cash(), IROptionMetric.PercentNotional()] and self.leg2.settlement_params.payment != self.leg1.settlement_params.payment ): disc_curve_ = _validate_obj_not_no_input(disc_curve, name="disc_curve") diff --git a/python/rateslib/periods/ir_volatility.py b/python/rateslib/periods/ir_volatility.py index 9d508d02f..b777ba964 100644 --- a/python/rateslib/periods/ir_volatility.py +++ b/python/rateslib/periods/ir_volatility.py @@ -390,9 +390,9 @@ def rate( ir_vol=ir_vol, ) - if metric_ == IROptionMetric.Cash: + if metric_ == IROptionMetric.Cash(): return cash - elif metric_ == IROptionMetric.PercentNotional: + elif metric_ == IROptionMetric.PercentNotional(): return cash / self.settlement_params.notional * 100.0 disc_curve_ = _disc_required_maybe_from_curve(curve=rate_curve, disc_curve=disc_curve) @@ -412,7 +412,7 @@ def rate( pricing_ = pricing del pricing - if metric_ == IROptionMetric.NormalVol: + if metric_ == IROptionMetric.NormalVol(): # use a root finder to reverse engineer the _Bachelier model. if anal_delta is None: anal_delta_: DualTypes = self.ir_option_params.option_fixing.irs.analytic_delta( # type: ignore[assignment] @@ -442,19 +442,11 @@ def s(g: DualTypes) -> DualTypes: ) g: DualTypes = result["g"] return g * 100.0 - - else: # metric_ in [BlackVol types] + else: + # metric_ in [BlackVol types] # might need to resolve a volatility value depending upon the required shift # and the expected shift - - expected_shift = { - IROptionMetric.LogNormalVol: 0, - IROptionMetric.BlackVol: 0, - IROptionMetric.BlackVolShift100: 100, - IROptionMetric.BlackVolShift200: 200, - IROptionMetric.BlackVolShift300: 300, - } - required_shift = expected_shift[metric_] + required_shift = metric_.shift() provided_shift = int(_dual_float(pricing_.shift)) if required_shift == provided_shift: return pricing_.vol diff --git a/python/rateslib/rs.pyi b/python/rateslib/rs.pyi index a3712e32b..d0eea5fa9 100644 --- a/python/rateslib/rs.pyi +++ b/python/rateslib/rs.pyi @@ -250,6 +250,21 @@ class FloatFixingMethod(_MethodParam): def to_json(self) -> str: ... +class _Shift: + def shift(self) -> int: ... + +class IROptionMetric(_Shift): + class NormalVol(IROptionMetric): ... + class LogNormalVol(IROptionMetric): ... + class PercentNotional(IROptionMetric): ... + class Cash(IROptionMetric): ... + + class BlackVolShift(IROptionMetric): + param: int + def __init__(self, param: int) -> None: ... + + def to_json(self) -> str: ... + class CalendarManager: def add(self, name: str, calendar: Cal) -> None: ... def pop(self, name: str) -> Cal | UnionCal: ... diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index c82424b36..cd160763c 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -9025,12 +9025,12 @@ def test_default_payment_date(self): ("metric", "expected"), [ ("LogNormalVol", 25.16), - ("BlackVol", 25.16), + ("BlackVolShift_0", 25.16), ("Cash", 149725.796514), ("NormalVol", 75.792872), - ("Black_shift_100", 18.880156), - ("Black_shift_200", 15.111396), - ("Black_shift_300", 12.597702), + ("Black_vol_shift_100", 18.880156), + ("Black_vol_shift_200", 15.111396), + ("Black_vol_shift_300", 12.597702), ("PercentNotional", 0.149725), ], ) diff --git a/python/tests/periods/test_periods_legacy.py b/python/tests/periods/test_periods_legacy.py index fb1bb315d..d92188500 100644 --- a/python/tests/periods/test_periods_legacy.py +++ b/python/tests/periods/test_periods_legacy.py @@ -5870,14 +5870,15 @@ def test_option_npv_different_csa(self): @pytest.mark.parametrize( ("metric", "expected"), [ + ("NormalVol", 75.792872), ("LogNormalVol", 25.16), - ("BlackVol", 25.16), ("Cash", 149725.796514), ("PercentNotional", 0.149725), - ("NormalVol", 75.792872), - ("Black_shift_100", 18.880156), - ("Black_shift_200", 15.111396), - ("Black_shift_300", 12.597702), + ("black_vol_shift_0", 25.16), + ("Black_vol_shift_100", 18.880156), + ("Black_vol_shift_200", 15.111396), + ("Black_vol_shift_300", 12.597702), + ("Black_vol_shift_117", 18.112063), ], ) def test_option_rate(self, metric, expected): diff --git a/python/tests/serialization/test_json.py b/python/tests/serialization/test_json.py index 7268b0c68..6e28ea02a 100644 --- a/python/tests/serialization/test_json.py +++ b/python/tests/serialization/test_json.py @@ -11,7 +11,7 @@ import pytest from rateslib import Curve, Dual, Dual2, FXForwards, FXRates, dt, from_json -from rateslib.enums import FloatFixingMethod, LegIndexBase +from rateslib.enums import FloatFixingMethod, IROptionMetric, LegIndexBase from rateslib.rs import Schedule as ScheduleRs from rateslib.scheduling import ( Adjuster, @@ -82,6 +82,11 @@ FloatFixingMethod.RFRLockout(4), FloatFixingMethod.RFRLockoutAverage(4), FloatFixingMethod.IBOR(2), + IROptionMetric.Cash(), + IROptionMetric.PercentNotional(), + IROptionMetric.NormalVol(), + IROptionMetric.LogNormalVol(), + IROptionMetric.BlackVolShift(25), LegIndexBase.Initial, LegIndexBase.PeriodOnPeriod, ], diff --git a/python/tests/serialization/test_pickle.py b/python/tests/serialization/test_pickle.py index 7bc7f2e13..a4669e5c8 100644 --- a/python/tests/serialization/test_pickle.py +++ b/python/tests/serialization/test_pickle.py @@ -31,7 +31,7 @@ MultiCsaCurve, ProxyCurve, ) -from rateslib.enums import FloatFixingMethod, LegIndexBase +from rateslib.enums import FloatFixingMethod, IROptionMetric, LegIndexBase from rateslib.rs import Schedule as ScheduleRs from rateslib.scheduling import ( Adjuster, @@ -169,6 +169,12 @@ def test_pickle_round_trip_obj_via_equality(obj): (Convention.ActActICMA, Convention.ActActICMA, Convention.ActActISDA), (FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR(2), FloatFixingMethod.RFRLookback(2)), (FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR(5)), + (IROptionMetric.Cash(), IROptionMetric.Cash(), IROptionMetric.BlackVolShift(200)), + ( + IROptionMetric.BlackVolShift(200), + IROptionMetric.BlackVolShift(200), + IROptionMetric.BlackVolShift(100), + ), (LegIndexBase.Initial, LegIndexBase.Initial, LegIndexBase.PeriodOnPeriod), ], ) @@ -181,6 +187,7 @@ def test_enum_equality(a1, a2, b1): ("enum", "klass"), [ (FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR), + (IROptionMetric.BlackVolShift(2), IROptionMetric.BlackVolShift), ], ) def test_complex_enum_isinstance(enum, klass): diff --git a/rust/enums/mod.rs b/rust/enums/mod.rs index 571e4e207..f05498d22 100644 --- a/rust/enums/mod.rs +++ b/rust/enums/mod.rs @@ -13,7 +13,8 @@ // pub mod docs; mod parameters; -pub use crate::enums::parameters::{FloatFixingMethod, LegIndexBase}; +pub use crate::enums::parameters::{FloatFixingMethod, IROptionMetric, LegIndexBase}; pub(crate) mod py; pub(crate) use crate::enums::py::PyFloatFixingMethod; +pub(crate) use crate::enums::py::PyIROptionMetric; diff --git a/rust/enums/parameters.rs b/rust/enums/parameters.rs index e67a7ffd7..a925a8c3c 100644 --- a/rust/enums/parameters.rs +++ b/rust/enums/parameters.rs @@ -53,6 +53,21 @@ impl FloatFixingMethod { } } +/// Specifier for the rate metric on IR Option types. +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +pub enum IROptionMetric { + /// Volatility expressed in normalized basis points, i.e. used in the Bachelier pricing model. + NormalVol {}, + /// Alias for BlackVolShift(0) + LogNormalVol {}, + /// Cash option premium expressed as a percentage of the notional. + PercentNotional {}, + /// Option premium expressed as a cash quantity. + Cash {}, + /// Log-normal Black volatility applying a basis-points shift to the forward and strike. + BlackVolShift(i32), +} + /// Enumerable type for index base determination on each Period in a Leg. #[pyclass(module = "rateslib.rs", eq, eq_int, hash, frozen, from_py_object)] #[derive(Debug, Hash, Copy, Clone, Serialize, Deserialize, PartialEq)] diff --git a/rust/enums/py/ir_option_metric.rs b/rust/enums/py/ir_option_metric.rs new file mode 100644 index 000000000..4f650966c --- /dev/null +++ b/rust/enums/py/ir_option_metric.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: LicenseRef-Rateslib-Dual +// +// Copyright (c) 2026 Siffrorna Technology Limited +// This code cannot be used or copied externally +// +// Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +// Source-available, not open source. +// +// See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +// and/or contact info (at) rateslib (dot) com +//////////////////////////////////////////////////////////////////////////////////////////////////// + +//! Wrapper module to export to Python using pyo3 bindings. + +use crate::enums::IROptionMetric; +use crate::json::{DeserializedObj, JSON}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyTuple; +use serde::{Deserialize, Serialize}; + +/// Enumerable type for IR Option rate metrics. +/// +/// .. rubric:: Variants +/// +/// .. ipython:: python +/// :suppress: +/// +/// from rateslib.rs import IROptionMetric +/// variants = [item for item in IROptionMetric.__dict__ if \ +/// "__" != item[:2] and \ +/// item not in ['to_json', 'method_param'] \ +/// ] +/// +/// .. ipython:: python +/// +/// variants +/// +#[pyclass(module = "rateslib.rs", name = "IROptionMetric", eq, from_py_object)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) enum PyIROptionMetric { + #[pyo3(constructor = (_u8=0))] + NormalVol { _u8: u8 }, + #[pyo3(constructor = (_u8=1))] + LogNormalVol { _u8: u8 }, + #[pyo3(constructor = (_u8=2))] + PercentNotional { _u8: u8 }, + #[pyo3(constructor = (_u8=3))] + Cash { _u8: u8 }, + #[pyo3(constructor = (param, _u8=4))] + BlackVolShift { param: i32, _u8: u8 }, +} + +/// Used for providing pickle support for PyIROptionMetric +enum PyIROptionMetricNewArgs { + NoArgs(u8), + I32(i32, u8), +} + +impl<'py> IntoPyObject<'py> for PyIROptionMetricNewArgs { + type Target = PyTuple; + type Output = Bound<'py, Self::Target>; + type Error = std::convert::Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + match self { + PyIROptionMetricNewArgs::NoArgs(x) => Ok((x,).into_pyobject(py).unwrap()), + PyIROptionMetricNewArgs::I32(x, y) => Ok((x, y).into_pyobject(py).unwrap()), + } + } +} + +impl<'py> FromPyObject<'py, 'py> for PyIROptionMetricNewArgs { + type Error = PyErr; + + fn extract(obj: Borrowed<'_, 'py, PyAny>) -> Result { + let ext: PyResult<(u8,)> = obj.extract(); + if ext.is_ok() { + let (x,) = ext.unwrap(); + return Ok(PyIROptionMetricNewArgs::NoArgs(x)); + } + let ext: PyResult<(i32, u8)> = obj.extract(); + if ext.is_ok() { + let (x, y) = ext.unwrap(); + return Ok(PyIROptionMetricNewArgs::I32(x, y)); + } + Err(PyValueError::new_err("Undefined behaviour")) + } +} + +impl From for PyIROptionMetric { + fn from(value: IROptionMetric) -> Self { + match value { + IROptionMetric::NormalVol {} => PyIROptionMetric::NormalVol { _u8: 0 }, + IROptionMetric::LogNormalVol {} => PyIROptionMetric::LogNormalVol { _u8: 1 }, + IROptionMetric::PercentNotional {} => PyIROptionMetric::PercentNotional { _u8: 2 }, + IROptionMetric::Cash {} => PyIROptionMetric::Cash { _u8: 3 }, + IROptionMetric::BlackVolShift(n) => { + PyIROptionMetric::BlackVolShift { param: n, _u8: 4 } + } + } + } +} + +impl From for IROptionMetric { + fn from(value: PyIROptionMetric) -> Self { + match value { + PyIROptionMetric::NormalVol { _u8: _ } => IROptionMetric::NormalVol {}, + PyIROptionMetric::LogNormalVol { _u8: _ } => IROptionMetric::LogNormalVol {}, + PyIROptionMetric::PercentNotional { _u8: _ } => IROptionMetric::PercentNotional {}, + PyIROptionMetric::Cash { _u8: _ } => IROptionMetric::Cash {}, + PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => { + IROptionMetric::BlackVolShift(n) + } + } + } +} + +#[pymethods] +impl PyIROptionMetric { + /// Return the shift associated with the Black Vol metric. + /// + /// Returns + /// ------- + /// int + #[pyo3(name = "shift")] + fn shift_py(&self) -> i32 { + match self { + PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => *n, + _ => 0_i32, + } + } + + fn __str__(&self) -> String { + match self { + PyIROptionMetric::NormalVol { _u8: _ } => "normal_vol".to_string(), + PyIROptionMetric::LogNormalVol { _u8: _ } => "log_normal_vol".to_string(), + PyIROptionMetric::PercentNotional { _u8: _ } => "percent_notional".to_string(), + PyIROptionMetric::Cash { _u8: _ } => "cash".to_string(), + PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => { + format!("black_vol_shift_{}", n) + } + } + } + + fn __getnewargs__(&self) -> PyIROptionMetricNewArgs { + match self { + PyIROptionMetric::NormalVol { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::LogNormalVol { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::PercentNotional { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::Cash { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::BlackVolShift { param: n, _u8: u } => { + PyIROptionMetricNewArgs::I32(*n, *u) + } + } + } + + #[new] + fn new_py(args: PyIROptionMetricNewArgs) -> PyIROptionMetric { + match args { + PyIROptionMetricNewArgs::NoArgs(0) => PyIROptionMetric::NormalVol { _u8: 0 }, + PyIROptionMetricNewArgs::NoArgs(1) => PyIROptionMetric::LogNormalVol { _u8: 1 }, + PyIROptionMetricNewArgs::NoArgs(2) => PyIROptionMetric::PercentNotional { _u8: 2 }, + PyIROptionMetricNewArgs::NoArgs(3) => PyIROptionMetric::Cash { _u8: 3 }, + PyIROptionMetricNewArgs::I32(n, 4) => { + PyIROptionMetric::BlackVolShift { param: n, _u8: 4 } + } + _ => panic!("Undefined behaviour."), + } + } + + fn __repr__(&self) -> String { + let metric: IROptionMetric = (*self).into(); + format!("", metric, self) + } + + /// Return a JSON representation of the object. + /// + /// Returns + /// ------- + /// str + #[pyo3(name = "to_json")] + fn to_json_py(&self) -> PyResult { + match DeserializedObj::PyIROptionMetric(self.clone()).to_json() { + Ok(v) => Ok(v), + Err(_) => Err(PyValueError::new_err( + "Failed to serialize `IROptionMetric` to JSON.", + )), + } + } +} diff --git a/rust/enums/py/mod.rs b/rust/enums/py/mod.rs index 825b89be3..703a545e3 100644 --- a/rust/enums/py/mod.rs +++ b/rust/enums/py/mod.rs @@ -11,6 +11,8 @@ //////////////////////////////////////////////////////////////////////////////////////////////////// pub(crate) mod float_fixing_method; +pub(crate) mod ir_option_metric; pub(crate) mod leg_index_base; pub(crate) use crate::enums::py::float_fixing_method::PyFloatFixingMethod; +pub(crate) use crate::enums::py::ir_option_metric::PyIROptionMetric; diff --git a/rust/json/json_py.rs b/rust/json/json_py.rs index f51662cdd..814ec7bce 100644 --- a/rust/json/json_py.rs +++ b/rust/json/json_py.rs @@ -19,7 +19,7 @@ use crate::curves::curve_py::Curve; use crate::dual::{Dual, Dual2}; -use crate::enums::{LegIndexBase, PyFloatFixingMethod}; +use crate::enums::{LegIndexBase, PyFloatFixingMethod, PyIROptionMetric}; use crate::fx::rates::FXRates; use crate::json::JSON; use crate::scheduling::{ @@ -56,6 +56,7 @@ pub(crate) enum DeserializedObj { Convention(Convention), PyFloatFixingMethod(PyFloatFixingMethod), LegIndexBase(LegIndexBase), + PyIROptionMetric(PyIROptionMetric), } impl JSON for DeserializedObj {} diff --git a/rust/lib.rs b/rust/lib.rs index ec1810a0c..ba5b0573f 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -60,7 +60,7 @@ use scheduling::{ }; pub mod enums; -use enums::{LegIndexBase, PyFloatFixingMethod}; +use enums::{LegIndexBase, PyFloatFixingMethod, PyIROptionMetric}; #[pymodule] fn rs(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -122,6 +122,7 @@ fn rs(m: &Bound<'_, PyModule>) -> PyResult<()> { // Rates and Indexes m.add_class::()?; m.add_class::()?; + m.add_class::()?; Ok(()) }