Skip to main content

libitofin/termstructures/volatility/
localvolcurve.rs

1//! Local volatility curve derived from a Black curve.
2//!
3//! Port of `ql/termstructures/volatility/equityfx/localvolcurve.hpp`: from
4//! the relation `integral of sigma_L^2(t) dt over [0, T] = sigma_B^2(T) T`,
5//! the local volatility is `sqrt(d(sigma_B^2(t) t)/dt)`, differentiated here
6//! with C++'s one-day forward step on the Black variance.
7//!
8//! ## Divergences from QuantLib
9//!
10//! - Inspectors on an empty underlying handle return `None`/`Err` (and
11//!   [`max_date`](crate::termstructures::TermStructure::max_date) the null
12//!   date) where C++ dereferences a null pointer.
13//! - C++ captures the underlying's business-day convention at construction;
14//!   here it is delegated on each call (falling back to `Following`, the only
15//!   value a `BlackVarianceCurve` ever reports, when the handle is empty).
16//! - As in C++, a variance decreasing in time (possible only when the curve
17//!   was built with `force_monotone_variance` disabled) is not guarded here:
18//!   the square root then yields a NaN volatility.
19//! - `accept(AcyclicVisitor&)` is not ported, following the crate convention.
20
21use crate::errors::QlResult;
22use crate::handle::Handle;
23use crate::math::interpolations::Interpolator;
24use crate::math::interpolations::linear::Linear;
25use crate::patterns::observable::{AsObservable, Observable};
26use crate::termstructures::volatility::{
27    BlackVarianceCurve, BlackVolTermStructure, VolatilityTermStructure,
28};
29use crate::termstructures::{TermStructure, TermStructureBase};
30use crate::time::businessdayconvention::BusinessDayConvention;
31use crate::time::calendar::Calendar;
32use crate::time::date::Date;
33use crate::time::daycounter::DayCounter;
34use crate::types::{Rate, Real, Time, Volatility};
35
36use super::LocalVolTermStructure;
37
38/// Local volatility curve derived from a Black variance curve.
39pub struct LocalVolCurve<I: Interpolator + 'static = Linear> {
40    base: TermStructureBase,
41    curve: Handle<BlackVarianceCurve<I>>,
42}
43
44impl<I: Interpolator + 'static> LocalVolCurve<I> {
45    /// Wraps the Black variance curve handle, registering with it so relinks
46    /// and underlying changes reach this structure's observers.
47    pub fn new(curve: Handle<BlackVarianceCurve<I>>) -> LocalVolCurve<I> {
48        let base = TermStructureBase::new(None);
49        curve.register_observer(&base.updater());
50        LocalVolCurve { base, curve }
51    }
52}
53
54impl<I: Interpolator + 'static> AsObservable for LocalVolCurve<I> {
55    fn observable(&self) -> &Observable {
56        self.base.observable()
57    }
58}
59
60impl<I: Interpolator + 'static> TermStructure for LocalVolCurve<I> {
61    fn base(&self) -> &TermStructureBase {
62        &self.base
63    }
64
65    fn reference_date(&self) -> QlResult<Date> {
66        self.curve.current_link()?.reference_date()
67    }
68
69    fn calendar(&self) -> Option<Calendar> {
70        self.curve.current_link().ok().and_then(|c| c.calendar())
71    }
72
73    fn day_counter(&self) -> Option<DayCounter> {
74        self.curve.current_link().ok().and_then(|c| c.day_counter())
75    }
76
77    fn max_date(&self) -> Date {
78        self.curve
79            .current_link()
80            .map(|c| c.max_date())
81            .unwrap_or_else(|_| Date::null())
82    }
83}
84
85impl<I: Interpolator + 'static> VolatilityTermStructure for LocalVolCurve<I> {
86    fn business_day_convention(&self) -> BusinessDayConvention {
87        self.curve
88            .current_link()
89            .map(|c| c.business_day_convention())
90            .unwrap_or(BusinessDayConvention::Following)
91    }
92
93    fn min_strike(&self) -> Rate {
94        Rate::MIN
95    }
96
97    fn max_strike(&self) -> Rate {
98        Rate::MAX
99    }
100}
101
102impl<I: Interpolator + 'static> LocalVolTermStructure for LocalVolCurve<I> {
103    fn local_vol_impl(&self, t: Time, strike: Real) -> QlResult<Volatility> {
104        let curve = self.curve.current_link()?;
105        let dt = 1.0 / 365.0;
106        let var1 = curve.black_variance(t, strike, true)?;
107        let var2 = curve.black_variance(t + dt, strike, true)?;
108        let derivative = (var2 - var1) / dt;
109        Ok(derivative.sqrt())
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::handle::RelinkableHandle;
117    use crate::shared::shared;
118    use crate::test_support::{Flag, as_observer};
119    use crate::time::date::Month;
120    use crate::time::daycounters::actual365fixed::Actual365Fixed;
121
122    fn variance_curve() -> BlackVarianceCurve {
123        let reference = Date::new(15, Month::June, 2026);
124        BlackVarianceCurve::new(
125            reference,
126            &[reference + 365, reference + 730],
127            &[0.2, 0.25],
128            Actual365Fixed::new(),
129            true,
130        )
131        .unwrap()
132    }
133
134    fn local_curve() -> LocalVolCurve {
135        LocalVolCurve::new(Handle::new(shared(variance_curve())))
136    }
137
138    #[test]
139    fn local_vol_is_the_square_root_of_the_variance_slope() {
140        let local = local_curve();
141        // Variance nodes: 0 at t=0, 0.04 at t=1, 0.125 at t=2; linear in
142        // between, so the slope is 0.04 on [0,1] and 0.085 on [1,2].
143        assert!((local.local_vol(0.5, 100.0, false).unwrap() - 0.2).abs() < 1.0e-12);
144        let slope = 0.085_f64;
145        assert!((local.local_vol(1.5, 100.0, false).unwrap() - slope.sqrt()).abs() < 1.0e-12);
146        // At a node the one-day step reads the next segment forward.
147        assert!((local.local_vol(1.0, 100.0, false).unwrap() - slope.sqrt()).abs() < 1.0e-12);
148    }
149
150    #[test]
151    fn beyond_the_last_node_flat_vol_extrapolation_gives_the_last_vol() {
152        let local = local_curve();
153        // Flat-volatility extension: var(t) = 0.25^2 t, so the slope is
154        // 0.25^2 and the local vol equals the last Black vol.
155        assert!((local.local_vol(2.0, 100.0, false).unwrap() - 0.25).abs() < 1.0e-12);
156    }
157
158    #[test]
159    fn local_vol_matches_the_one_day_forward_vol_of_the_underlying() {
160        let local = local_curve();
161        let underlying = variance_curve();
162        // The forward vol divides by the rounded difference (t + dt) - t, the
163        // local vol by dt itself, so agreement is to float precision only.
164        for t in [0.0, 0.3, 1.0, 1.7] {
165            let expected = underlying
166                .black_forward_vol(t, t + 1.0 / 365.0, 100.0, true)
167                .unwrap();
168            assert!((local.local_vol(t, 100.0, false).unwrap() - expected).abs() < 1.0e-10);
169        }
170    }
171
172    #[test]
173    fn inspectors_delegate_to_the_underlying_curve() {
174        let local = local_curve();
175        let underlying = variance_curve();
176        assert_eq!(
177            local.reference_date().unwrap(),
178            underlying.reference_date().unwrap()
179        );
180        assert_eq!(local.max_date(), underlying.max_date());
181        assert_eq!(
182            local.day_counter().unwrap().name(),
183            underlying.day_counter().unwrap().name()
184        );
185        assert_eq!(
186            local.business_day_convention(),
187            BusinessDayConvention::Following
188        );
189        assert_eq!(local.min_strike(), Rate::MIN);
190        assert_eq!(local.max_strike(), Rate::MAX);
191    }
192
193    #[test]
194    fn empty_handle_errors_instead_of_dereferencing_null() {
195        let local: LocalVolCurve = LocalVolCurve::new(Handle::empty());
196        assert!(local.reference_date().is_err());
197        assert!(local.day_counter().is_none());
198        assert_eq!(local.max_date(), Date::null());
199        assert!(local.local_vol(1.0, 100.0, true).is_err());
200    }
201
202    #[test]
203    fn relinking_the_underlying_notifies_observers() {
204        let relinkable = RelinkableHandle::new(shared(variance_curve()));
205        let local = LocalVolCurve::new(relinkable.handle());
206        let flag = Flag::new();
207        local.observable().register_observer(&as_observer(&flag));
208
209        let reference = Date::new(15, Month::June, 2026);
210        let steeper = BlackVarianceCurve::new(
211            reference,
212            &[reference + 365, reference + 730],
213            &[0.3, 0.35],
214            Actual365Fixed::new(),
215            true,
216        )
217        .unwrap();
218        relinkable.link_to(shared(steeper));
219
220        assert!(Flag::is_up(&flag));
221        assert!((local.local_vol(0.5, 100.0, false).unwrap() - 0.3).abs() < 1.0e-12);
222    }
223}