Skip to main content

libitofin/termstructures/volatility/
smilesection.rs

1//! Smile-section base.
2//!
3//! Port of `ql/termstructures/volatility/smilesection.{hpp,cpp}`.
4//! [`SmileSection`] is the volatility-smile abstraction the vol layer has
5//! deferred since B1: a single option expiry, holding the volatility as a
6//! function of strike plus enough state to turn a variance into an option
7//! price through the Black (or Bachelier) formula.
8//!
9//! C++'s `SmileSection` keeps its common state (reference/exercise date, day
10//! counter, exercise time, volatility type, shift) on the base class. The trait
11//! cannot hold fields, so this port mirrors the [`TermStructure`] /
12//! [`TermStructureBase`](crate::termstructures::TermStructureBase) precedent:
13//! [`SmileSectionBase`] owns the shared state and every implementor exposes it
14//! through the required [`base`](SmileSection::base) accessor, with the provided
15//! methods delegating to it exactly as the C++ base class does.
16//!
17//! ## Divergences from QuantLib
18//!
19//! - `atmLevel()` returns `Null<Real>()` when a section has no at-the-money
20//!   level; here the required hook is [`atm_level`](SmileSection::atm_level)
21//!   returning [`Option<Rate>`], and [`option_price`](SmileSection::option_price)
22//!   turns the missing level into an `Err` (C++'s `QL_REQUIRE`).
23//! - `referenceDate()` throws when unavailable; the port returns `Err` per D4.
24//!
25//! ## Deferred (visible)
26//!
27//! Tracked under #586:
28//! - The floating construction (C++'s `Date` constructor with a defaulted
29//!   reference date) tracks `Settings`' evaluation date through the
30//!   Observable/Observer graph and recomputes the exercise time on every
31//!   change. That path and the observer plumbing are a single coupled unit; a
32//!   null reference date is therefore rejected here rather than silently
33//!   floated. Consumers in B3a ([`FlatSmileSection`](super::FlatSmileSection),
34//!   `SabrSmileSection`) are stateless after construction and do not need it.
35//! - `digitalOptionPrice`, `vega`, `density`, the volatility-type-converting
36//!   `volatility(strike, type, shift)`, `InterpolatedSmileSection`, and
37//!   `SmileSectionUtils` are not ported.
38
39use crate::errors::QlResult;
40use crate::option::OptionType;
41use crate::pricingengines::blackformula::{bachelier_black_formula, black_formula};
42use crate::termstructures::volatility::VolatilityType;
43use crate::time::date::Date;
44use crate::time::daycounter::DayCounter;
45use crate::types::{Rate, Real, Time, Volatility};
46use crate::{fail, require};
47
48/// Shared state every [`SmileSection`] carries, mirroring the fields C++ keeps
49/// on the `SmileSection` base class.
50#[derive(Clone, Debug)]
51pub struct SmileSectionBase {
52    reference_date: Option<Date>,
53    day_counter: DayCounter,
54    exercise_time: Time,
55    volatility_type: VolatilityType,
56    shift: Rate,
57}
58
59impl SmileSectionBase {
60    /// Base pinned to a fixed reference date, computing the exercise time as the
61    /// day counter's year fraction from `reference_date` to `exercise_date`
62    /// (C++'s `Date` constructor with an explicit reference date).
63    ///
64    /// # Errors
65    ///
66    /// Returns `Err` when `reference_date` is null (the floating path deferred to
67    /// #586) or when `exercise_date` precedes `reference_date`.
68    pub fn with_reference_date(
69        exercise_date: Date,
70        day_counter: DayCounter,
71        reference_date: Date,
72        volatility_type: VolatilityType,
73        shift: Rate,
74    ) -> QlResult<SmileSectionBase> {
75        require!(
76            reference_date != Date::null(),
77            "a null reference date selects QuantLib's floating smile section, which tracks the \
78             evaluation date through the observer graph; that path is deferred to #586"
79        );
80        require!(
81            exercise_date >= reference_date,
82            "exercise date ({exercise_date}) must not precede the reference date \
83             ({reference_date})"
84        );
85        let exercise_time = day_counter.year_fraction(reference_date, exercise_date);
86        Ok(SmileSectionBase {
87            reference_date: Some(reference_date),
88            day_counter,
89            exercise_time,
90            volatility_type,
91            shift,
92        })
93    }
94
95    /// Base pinned to an exercise time directly, with no reference date
96    /// (C++'s `Time` constructor).
97    ///
98    /// # Errors
99    ///
100    /// Returns `Err` when `exercise_time` is negative.
101    pub fn with_exercise_time(
102        exercise_time: Time,
103        day_counter: DayCounter,
104        volatility_type: VolatilityType,
105        shift: Rate,
106    ) -> QlResult<SmileSectionBase> {
107        if exercise_time < 0.0 {
108            fail!("exercise time ({exercise_time}) must be non-negative");
109        }
110        Ok(SmileSectionBase {
111            reference_date: None,
112            day_counter,
113            exercise_time,
114            volatility_type,
115            shift,
116        })
117    }
118}
119
120/// Interest-rate volatility smile section.
121///
122/// A single option expiry viewed as volatility against strike. Implementors
123/// supply the state holder through [`base`](Self::base) and the smile shape
124/// through [`volatility_impl`](Self::volatility_impl), [`min_strike`](Self::min_strike),
125/// [`max_strike`](Self::max_strike) and [`atm_level`](Self::atm_level); the
126/// provided methods derive variance, volatility and the option price from them.
127pub trait SmileSection {
128    /// The shared state holder.
129    fn base(&self) -> &SmileSectionBase;
130
131    /// Volatility at `strike`; the caller owns any range checking.
132    ///
133    /// # Errors
134    ///
135    /// Propagates an implementor's failure to evaluate the smile.
136    fn volatility_impl(&self, strike: Rate) -> QlResult<Volatility>;
137
138    /// The lowest strike the section can quote.
139    fn min_strike(&self) -> Rate;
140
141    /// The highest strike the section can quote.
142    fn max_strike(&self) -> Rate;
143
144    /// The at-the-money level, or `None` when the section provides none.
145    fn atm_level(&self) -> Option<Rate>;
146
147    /// Volatility at `strike`.
148    ///
149    /// # Errors
150    ///
151    /// Propagates [`volatility_impl`](Self::volatility_impl).
152    fn volatility(&self, strike: Rate) -> QlResult<Volatility> {
153        self.volatility_impl(strike)
154    }
155
156    /// Black variance at `strike`: `volatility^2 * exercise_time` (C++'s
157    /// `varianceImpl`).
158    ///
159    /// # Errors
160    ///
161    /// Propagates [`volatility_impl`](Self::volatility_impl).
162    fn variance(&self, strike: Rate) -> QlResult<Real> {
163        let vol = self.volatility_impl(strike)?;
164        Ok(vol * vol * self.exercise_time())
165    }
166
167    /// The exercise time this section was built for.
168    fn exercise_time(&self) -> Time {
169        self.base().exercise_time
170    }
171
172    /// The day counter used to turn dates into times.
173    fn day_counter(&self) -> DayCounter {
174        self.base().day_counter.clone()
175    }
176
177    /// The volatility model this section is quoted against.
178    fn volatility_type(&self) -> VolatilityType {
179        self.base().volatility_type
180    }
181
182    /// The lognormal shift applied to strike and forward.
183    fn shift(&self) -> Rate {
184        self.base().shift
185    }
186
187    /// The reference date the section is anchored to.
188    ///
189    /// # Errors
190    ///
191    /// Returns `Err` for a section built from an exercise time, which carries no
192    /// reference date (C++'s `referenceDate()` throw).
193    fn reference_date(&self) -> QlResult<Date> {
194        match self.base().reference_date {
195            Some(date) => Ok(date),
196            None => fail!("reference date not available for this instance"),
197        }
198    }
199
200    /// Undiscounted-times-`discount` price of a European option struck at
201    /// `strike`, priced through the section's volatility model.
202    ///
203    /// # Errors
204    ///
205    /// Returns `Err` when the section provides no at-the-money level, or when
206    /// the underlying Black/Bachelier formula rejects its inputs.
207    fn option_price(
208        &self,
209        strike: Rate,
210        option_type: OptionType,
211        discount: Real,
212    ) -> QlResult<Real> {
213        let Some(atm) = self.atm_level() else {
214            fail!("smile section must provide an atm level to compute an option price");
215        };
216        match self.volatility_type() {
217            VolatilityType::ShiftedLognormal => {
218                let shift = self.shift();
219                let std_dev = if (strike + shift).abs() < Real::EPSILON {
220                    0.2
221                } else {
222                    self.variance(strike)?.sqrt()
223                };
224                black_formula(option_type, strike, atm, std_dev, discount, shift)
225            }
226            VolatilityType::Normal => {
227                let std_dev = self.variance(strike)?.sqrt();
228                bachelier_black_formula(option_type, strike, atm, std_dev, discount)
229            }
230        }
231    }
232}