Skip to content

Commit ee21581

Browse files
authored
Scale the differencing gap in SmileSection::density and digitalOptionPrice with the strike (#2768)
2 parents 02205d9 + 35e274b commit ee21581

2 files changed

Lines changed: 142 additions & 7 deletions

File tree

ql/termstructures/volatility/smilesection.cpp

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/*
44
Copyright (C) 2006 Mario Pucci
55
Copyright (C) 2013, 2015 Peter Caspers
6+
Copyright (C) 2026 Yassine Idyiahia
67
78
This file is part of QuantLib, a free-software/open-source library
89
for financial quantitative analysts and developers - http://quantlib.org/
@@ -22,6 +23,8 @@
2223
#include <ql/pricingengines/blackformula.hpp>
2324
#include <ql/settings.hpp>
2425
#include <ql/termstructures/volatility/smilesection.hpp>
26+
#include <algorithm>
27+
#include <cmath>
2528
#include <utility>
2629

2730
using std::sqrt;
@@ -90,18 +93,27 @@ namespace QuantLib {
9093
Real discount,
9194
Real gap) const {
9295
Real m = volatilityType() == ShiftedLognormal ? Real(-shift()) : -QL_MAX_REAL;
93-
Real kl = std::max(strike-gap/2.0,m);
94-
Real kr = kl+gap;
96+
// A fixed gap loses meaning once it drops below the strike's own
97+
// precision: the interval quantizes, and collapses entirely when kl and
98+
// kr coincide. The floor sits far below any usual gap.
99+
Real g = std::max(gap, std::abs(strike)*1.0e-10);
100+
Real kl = std::max(strike-g/2.0,m);
101+
Real kr = kl+g;
95102
return (type==Option::Call ? 1.0 : -1.0) *
96-
(optionPrice(kl,type,discount)-optionPrice(kr,type,discount)) / gap;
103+
(optionPrice(kl,type,discount)-optionPrice(kr,type,discount)) / g;
97104
}
98105

99106
Real SmileSection::density(Rate strike, Real discount, Real gap) const {
100107
Real m = volatilityType() == ShiftedLognormal ? Real(-shift()) : -QL_MAX_REAL;
101-
Real kl = std::max(strike-gap/2.0,m);
102-
Real kr = kl+gap;
103-
return (digitalOptionPrice(kl,Option::Call,discount,gap) -
104-
digitalOptionPrice(kr,Option::Call,discount,gap)) / gap;
108+
// A second difference, so roundoff scales as |strike|*eps/g^2 rather
109+
// than the digital's |strike|*eps/g. The floor has to be
110+
// correspondingly larger. The same g must reach the digitals and the
111+
// divisor, or their own floor leaves the three inconsistent.
112+
Real g = std::max(gap, std::abs(strike)*1.0e-6);
113+
Real kl = std::max(strike-g/2.0,m);
114+
Real kr = kl+g;
115+
return (digitalOptionPrice(kl,Option::Call,discount,g) -
116+
digitalOptionPrice(kr,Option::Call,discount,g)) / g;
105117
}
106118

107119
Real SmileSection::vega(Rate strike, Real discount) const {

test-suite/interpolatedsmilesection.cpp

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
/*
44
Copyright (C) 2025 Paolo D'Elia
5+
Copyright (C) 2026 Yassine Idyiahia
56
67
This file is part of QuantLib, a free-software/open-source library
78
for financial quantitative analysts and developers - http://quantlib.org/
@@ -19,7 +20,9 @@
1920
#include "toplevelfixture.hpp"
2021
#include "utilities.hpp"
2122
#include <ql/termstructures/volatility/interpolatedsmilesection.hpp>
23+
#include <ql/math/distributions/normaldistribution.hpp>
2224
#include <ql/math/interpolations/linearinterpolation.hpp>
25+
#include <ql/pricingengines/blackformula.hpp>
2326
#include <ql/quotes/simplequote.hpp>
2427
#include <ql/time/daycounters/actual365fixed.hpp>
2528

@@ -210,6 +213,126 @@ BOOST_AUTO_TEST_CASE(testErrorThrowingWhenNonSortedStrikes) {
210213
);
211214
}
212215

216+
BOOST_AUTO_TEST_CASE(testDigitalOptionPriceAtLargeStrikes) {
217+
BOOST_TEST_MESSAGE("Testing digital option price at strikes large enough "
218+
"to absorb the differencing gap...");
219+
220+
// The digital differences the option price across a gap. Once the gap falls
221+
// below the strike's own precision the interval quantizes to whole ulps, and
222+
// the probability that comes back is wrong without anything reporting it.
223+
// The low levels also pin the unaffected range, where the floor never binds.
224+
Time expiry = 1.0;
225+
Real sqrtT = std::sqrt(expiry);
226+
Volatility vol = 0.20;
227+
Real tol = 1e-4;
228+
229+
// At the money with a flat smile, P(S > F) = N(-sigma sqrt(T) / 2),
230+
// independent of the level.
231+
Real expected = CumulativeNormalDistribution()(-0.5 * vol * sqrtT);
232+
233+
for (Real atmLevel : { 1.0e2, 1.0e3, 1.0e6, 1.0e11 }) {
234+
std::vector<Rate> strikes{0.9 * atmLevel, atmLevel, 1.1 * atmLevel};
235+
std::vector<Real> stdDevs{vol * sqrtT, vol * sqrtT, vol * sqrtT};
236+
237+
auto section = ext::make_shared<InterpolatedSmileSection<Linear> >(
238+
expiry, strikes, stdDevs, atmLevel);
239+
240+
Real calculated = section->digitalOptionPrice(atmLevel, Option::Call, 1.0);
241+
242+
if (std::fabs(calculated - expected) > tol) {
243+
BOOST_FAIL("failed to reproduce the digital option price at a large strike"
244+
<< "\n strike: " << atmLevel
245+
<< "\n calculated: " << calculated
246+
<< "\n expected: " << expected
247+
<< "\n diff: " << calculated - expected
248+
<< "\n tolerance: " << tol);
249+
}
250+
}
251+
}
252+
253+
BOOST_AUTO_TEST_CASE(testDensityAtLargeStrikes) {
254+
BOOST_TEST_MESSAGE("Testing smile-section density at equity index level "
255+
"strikes, which absorb the differencing gap...");
256+
257+
// density differences the digital across a gap, and the digital itself
258+
// differences the option price, so the roundoff is |strike|*eps/gap^2
259+
// against a density falling like 1/strike. The relative error therefore
260+
// grows like strike^2 and reaches 100% around 3e4 -- Nikkei or Hang Seng
261+
// levels -- so a fixed gap fails far earlier here than for the digital.
262+
Time expiry = 1.0;
263+
Real sqrtT = std::sqrt(expiry);
264+
Volatility vol = 0.20;
265+
Real tol = 1e-3;
266+
267+
// At 1e2 the floor coincides with the default gap and nothing changes. The
268+
// middle three are KOSPI, S&P 500 and Nikkei 225 levels, where the unfixed
269+
// density is respectively 0.2%, 8% and 600% out. Past 1e6 the second
270+
// difference is pure roundoff and can come back negative.
271+
for (Real atmLevel : { 1.0e2, 2.5e3, 5.0e3, 3.8e4, 1.0e6, 1.0e11 }) {
272+
std::vector<Rate> strikes{0.9 * atmLevel, atmLevel, 1.1 * atmLevel};
273+
std::vector<Real> stdDevs{vol * sqrtT, vol * sqrtT, vol * sqrtT};
274+
275+
auto section = ext::make_shared<InterpolatedSmileSection<Linear> >(
276+
expiry, strikes, stdDevs, atmLevel);
277+
278+
Real calculated = section->density(atmLevel, 1.0);
279+
280+
// At the money with a flat smile the Black density is
281+
// phi(d2) / (K sigma sqrt(T)) with d2 = -sigma sqrt(T) / 2.
282+
Real d2 = -0.5 * vol * sqrtT;
283+
Real expected = NormalDistribution()(d2) / (atmLevel * vol * sqrtT);
284+
285+
// NaN would make the relative error below compare
286+
// false against the tolerance and slip through.
287+
BOOST_REQUIRE(std::isfinite(value(calculated)));
288+
Real relError = std::fabs(calculated - expected) / expected;
289+
if (relError > tol) {
290+
BOOST_FAIL("failed to reproduce the density at a large strike"
291+
<< "\n strike: " << atmLevel
292+
<< "\n calculated: " << calculated
293+
<< "\n expected: " << expected
294+
<< "\n rel error: " << relError
295+
<< "\n tolerance: " << tol);
296+
}
297+
}
298+
}
299+
300+
BOOST_AUTO_TEST_CASE(testDensityHonoursExplicitGap) {
301+
BOOST_TEST_MESSAGE("Testing that smile-section density honours an explicit gap...");
302+
303+
// A gap above the floor must reach both digitals and the divisor, leaving
304+
// density the central second difference of the call price.
305+
Time expiry = 1.0;
306+
Real sqrtT = std::sqrt(expiry);
307+
Volatility vol = 0.20;
308+
Real atmLevel = 1.0e11;
309+
Real gap = 1.0e10;
310+
311+
std::vector<Rate> strikes{0.9 * atmLevel, atmLevel, 1.1 * atmLevel};
312+
std::vector<Real> stdDevs{vol * sqrtT, vol * sqrtT, vol * sqrtT};
313+
314+
auto section = ext::make_shared<InterpolatedSmileSection<Linear> >(
315+
expiry, strikes, stdDevs, atmLevel);
316+
317+
Real calculated = section->density(atmLevel, 1.0, gap);
318+
319+
Real stdDev = vol * sqrtT;
320+
Real expected = (blackFormula(Option::Call, atmLevel - gap, atmLevel, stdDev) -
321+
2.0 * blackFormula(Option::Call, atmLevel, atmLevel, stdDev) +
322+
blackFormula(Option::Call, atmLevel + gap, atmLevel, stdDev)) / (gap * gap);
323+
324+
Real tol = 1e-8;
325+
Real relError = std::fabs(calculated - expected) / expected;
326+
if (relError > tol) {
327+
BOOST_FAIL("failed to honour the explicit gap"
328+
<< "\n gap: " << gap
329+
<< "\n calculated: " << calculated
330+
<< "\n expected: " << expected
331+
<< "\n rel error: " << relError
332+
<< "\n tolerance: " << tol);
333+
}
334+
}
335+
213336
BOOST_AUTO_TEST_SUITE_END()
214337

215338
BOOST_AUTO_TEST_SUITE_END()

0 commit comments

Comments
 (0)