|
2 | 2 |
|
3 | 3 | /* |
4 | 4 | Copyright (C) 2025 Paolo D'Elia |
| 5 | + Copyright (C) 2026 Yassine Idyiahia |
5 | 6 |
|
6 | 7 | This file is part of QuantLib, a free-software/open-source library |
7 | 8 | for financial quantitative analysts and developers - http://quantlib.org/ |
|
19 | 20 | #include "toplevelfixture.hpp" |
20 | 21 | #include "utilities.hpp" |
21 | 22 | #include <ql/termstructures/volatility/interpolatedsmilesection.hpp> |
| 23 | +#include <ql/math/distributions/normaldistribution.hpp> |
22 | 24 | #include <ql/math/interpolations/linearinterpolation.hpp> |
| 25 | +#include <ql/pricingengines/blackformula.hpp> |
23 | 26 | #include <ql/quotes/simplequote.hpp> |
24 | 27 | #include <ql/time/daycounters/actual365fixed.hpp> |
25 | 28 |
|
@@ -210,6 +213,126 @@ BOOST_AUTO_TEST_CASE(testErrorThrowingWhenNonSortedStrikes) { |
210 | 213 | ); |
211 | 214 | } |
212 | 215 |
|
| 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 | + |
213 | 336 | BOOST_AUTO_TEST_SUITE_END() |
214 | 337 |
|
215 | 338 | BOOST_AUTO_TEST_SUITE_END() |
0 commit comments