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}