|
21 | 21 | #include "utilities.hpp" |
22 | 22 | #include <ql/exercise.hpp> |
23 | 23 | #include <ql/instruments/vanillaoption.hpp> |
| 24 | +#include <ql/math/array.hpp> |
24 | 25 | #include <ql/math/distributions/gammadistribution.hpp> |
25 | 26 | #include <ql/math/ode/fractionaladams.hpp> |
26 | 27 | #include <ql/math/ode/fractionalkernelapproximation.hpp> |
|
38 | 39 | #include <ql/time/daycounters/actual365fixed.hpp> |
39 | 40 | #include <cmath> |
40 | 41 | #include <complex> |
| 42 | +#include <iomanip> |
41 | 43 | #include <vector> |
42 | 44 |
|
43 | 45 | using namespace QuantLib; |
@@ -188,6 +190,144 @@ BOOST_AUTO_TEST_CASE(testRiemannLiouvilleIntegral) { |
188 | 190 | } |
189 | 191 | } |
190 | 192 |
|
| 193 | +BOOST_AUTO_TEST_CASE(testRiemannLiouvilleWeightReuse) { |
| 194 | + BOOST_TEST_MESSAGE("Testing reusable Riemann-Liouville quadrature weights..."); |
| 195 | + |
| 196 | + // testRiemannLiouvilleIntegral above already validates the arithmetic |
| 197 | + // against the closed form, and now reaches it through this class, so it is |
| 198 | + // not repeated here. What is new is that one object is integrated many |
| 199 | + // times: it has to stay stateless, so that a cached set of weights gives |
| 200 | + // exactly what a set built for that one call would have given. |
| 201 | + // |
| 202 | + // steps = 1 leaves the interior loop empty, steps = 2 runs it once and |
| 203 | + // reaches index 0 of the power table, steps = 256 is the engine default. |
| 204 | + for (const Size steps : {Size(1), Size(2), Size(256)}) { |
| 205 | + std::vector<Real> real(steps + 1); |
| 206 | + std::vector<std::complex<Real>> complex(steps + 1); |
| 207 | + |
| 208 | + for (Size i{0}; i <= steps; ++i) { |
| 209 | + const Real x{Real(i) / steps}; |
| 210 | + real[i] = std::exp(-0.7 * x) * (1.0 + x * x); |
| 211 | + complex[i] = std::complex<Real>(real[i], std::sin(37.0 * x)); |
| 212 | + } |
| 213 | + |
| 214 | + // 0 and 1 are the degenerate orders; 0.25 is the fractional one the |
| 215 | + // engine asks for at H = 0.25 |
| 216 | + for (const Real alpha : {0.0, 0.25, 1.0}) { |
| 217 | + const RiemannLiouvilleWeights reused(alpha, steps); |
| 218 | + |
| 219 | + // the spacing varies with the maturity while the weights do not, |
| 220 | + // so a reused object has to survive a change of dt |
| 221 | + for (const Real dt : {1e-3, 0.5}) { |
| 222 | + const Real calculated{reused.integrate(real, dt)}; |
| 223 | + const Real expected{riemannLiouvilleIntegral(real, alpha, dt)}; |
| 224 | + |
| 225 | + if (value(calculated) != value(expected)) |
| 226 | + BOOST_ERROR("reused weights do not reproduce freshly built ones" |
| 227 | + << "\n alpha: " << alpha << "\n steps: " << steps |
| 228 | + << "\n dt: " << dt |
| 229 | + << "\n calculated: " << std::setprecision(17) << calculated |
| 230 | + << "\n expected: " << std::setprecision(17) << expected); |
| 231 | + |
| 232 | + // production only ever integrates complex vectors |
| 233 | + const std::complex<Real> calculatedC{reused.integrate(complex, dt)}; |
| 234 | + const std::complex<Real> expectedC{ |
| 235 | + riemannLiouvilleIntegral(complex, alpha, dt)}; |
| 236 | + |
| 237 | + if (value(calculatedC.real()) != value(expectedC.real()) |
| 238 | + || value(calculatedC.imag()) != value(expectedC.imag())) |
| 239 | + BOOST_ERROR("reused weights do not reproduce freshly built ones " |
| 240 | + "on the complex path" |
| 241 | + << "\n alpha: " << alpha << "\n steps: " << steps |
| 242 | + << "\n dt: " << dt |
| 243 | + << "\n calculated: " << std::setprecision(17) << calculatedC |
| 244 | + << "\n expected: " << std::setprecision(17) << expectedC); |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | + |
| 249 | + const Size steps{64}; |
| 250 | + const Real dt{0.01}; |
| 251 | + |
| 252 | + std::vector<Real> y(steps + 1); |
| 253 | + for (Size i{0}; i <= steps; ++i) |
| 254 | + y[i] = 1.0 + std::sin(Real(i)); |
| 255 | + |
| 256 | + // An oracle for the weights themselves rather than for the integral: at |
| 257 | + // alpha = 1 they have to be exactly the trapezoidal rule, which pins down |
| 258 | + // the formula more tightly than agreement with I^a t^p to 5e-5 does. |
| 259 | + Real trapezoid{0.5 * (y[0] + y[steps])}; |
| 260 | + for (Size i{1}; i < steps; ++i) |
| 261 | + trapezoid += y[i]; |
| 262 | + trapezoid *= dt; |
| 263 | + |
| 264 | + QL_CHECK_CLOSE(RiemannLiouvilleWeights(1.0, steps).integrate(y, dt), trapezoid, 1e-10); |
| 265 | + |
| 266 | + // steps = 0 would underflow (steps - 1) into an out-of-bounds index, and a |
| 267 | + // grid that does not match the weights is the mistake a caller reusing a |
| 268 | + // cached object will make |
| 269 | + BOOST_CHECK_THROW(RiemannLiouvilleWeights(0.6, 0), Error); |
| 270 | + BOOST_CHECK_THROW(RiemannLiouvilleWeights(-1e-8, steps), Error); |
| 271 | + BOOST_CHECK_THROW(RiemannLiouvilleWeights(0.6, steps - 1).integrate(y, dt), Error); |
| 272 | + BOOST_CHECK_THROW(RiemannLiouvilleWeights(0.6, steps).integrate(y, 0.0), Error); |
| 273 | +} |
| 274 | + |
| 275 | +BOOST_AUTO_TEST_CASE(testWeightCacheInvalidation) { |
| 276 | + BOOST_TEST_MESSAGE("Testing that the rough Heston engine drops cached quadrature weights " |
| 277 | + "when the Hurst exponent moves..."); |
| 278 | + |
| 279 | + const Date today(2, July, 2026); |
| 280 | + Settings::instance().evaluationDate() = today; |
| 281 | + const DayCounter dc{Actual365Fixed()}; |
| 282 | + |
| 283 | + const Handle<YieldTermStructure> rTS(ext::make_shared<FlatForward>(today, 0.03, dc)); |
| 284 | + const Handle<YieldTermStructure> qTS(ext::make_shared<FlatForward>(today, 0.01, dc)); |
| 285 | + const Handle<Quote> s0(ext::make_shared<SimpleQuote>(100.0)); |
| 286 | + |
| 287 | + const Real v0{0.04}, kappa{1.5}, theta{0.04}, sigma{0.3}, rho{-0.7}; |
| 288 | + |
| 289 | + const auto makeModel{[&](Real hurst) { |
| 290 | + return ext::make_shared<RoughHestonModel>( |
| 291 | + ext::make_shared<HestonProcess>(rTS, qTS, s0, v0, kappa, theta, sigma, rho), hurst); |
| 292 | + }}; |
| 293 | + |
| 294 | + const auto payoff{ext::make_shared<PlainVanillaPayoff>(Option::Call, 100.0)}; |
| 295 | + const Time t{1.0}; |
| 296 | + |
| 297 | + for (const auto approximation : |
| 298 | + {AnalyticRoughHestonEngine::Approximation::AdamsPredictorCorrector, |
| 299 | + AnalyticRoughHestonEngine::Approximation::Pade}) { |
| 300 | + |
| 301 | + const auto model{makeModel(0.1)}; |
| 302 | + const auto engine{ |
| 303 | + ext::make_shared<AnalyticRoughHestonEngine>(model, 128, 64, approximation)}; |
| 304 | + |
| 305 | + const Real firstPrice{engine->priceVanillaPayoff(payoff, t)}; |
| 306 | + |
| 307 | + // The fractional weights are keyed on 1 - (H + 1/2); moving H has to |
| 308 | + // rebuild them, or the engine keeps pricing the old model. |
| 309 | + Array params{model->params()}; |
| 310 | + params[5] = 0.3; |
| 311 | + model->setParams(params); |
| 312 | + |
| 313 | + const Real movedPrice{engine->priceVanillaPayoff(payoff, t)}; |
| 314 | + |
| 315 | + const auto reference{ |
| 316 | + ext::make_shared<AnalyticRoughHestonEngine>(makeModel(0.3), 128, 64, approximation)}; |
| 317 | + const Real referencePrice{reference->priceVanillaPayoff(payoff, t)}; |
| 318 | + |
| 319 | + if (std::fabs(value(movedPrice) - value(referencePrice)) > 1e-12) |
| 320 | + BOOST_ERROR("stale quadrature weights after a change of the Hurst exponent" |
| 321 | + << "\n reused engine: " << movedPrice |
| 322 | + << "\n fresh engine: " << referencePrice); |
| 323 | + |
| 324 | + if (std::fabs(value(movedPrice) - value(firstPrice)) < 1e-6) |
| 325 | + BOOST_ERROR("the Hurst exponent did not move the price, so the test " |
| 326 | + "cannot detect a stale cache" |
| 327 | + << "\n H = 0.1: " << firstPrice << "\n H = 0.3: " << movedPrice); |
| 328 | + } |
| 329 | +} |
| 330 | + |
191 | 331 | BOOST_AUTO_TEST_CASE(testEquivalenceWithHestonModel) { |
192 | 332 | BOOST_TEST_MESSAGE( |
193 | 333 | "Testing rough Heston engine against the classical Heston engine for H = 0.5..."); |
|
0 commit comments