Skip to content

Commit 1b31e24

Browse files
authored
Cache the Riemann-Liouville quadrature weights in the rough Heston engine (#2758)
2 parents f9dbcda + 817967c commit 1b31e24

4 files changed

Lines changed: 255 additions & 23 deletions

File tree

ql/math/ode/fractionaladams.hpp

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,72 @@ namespace QuantLib {
119119
};
120120

121121

122+
//! quadrature weights of the product-trapezoidal Riemann-Liouville integral
123+
/*!
124+
The weights depend only on the integration order \f$ \alpha \f$ and on
125+
the number of grid steps. Callers that integrate repeatedly on the same grid,
126+
as the rough Heston characteristic function does once per quadrature node, can
127+
build once and reuse.
128+
*/
129+
class RiemannLiouvilleWeights {
130+
public:
131+
RiemannLiouvilleWeights(Real alpha, Size steps);
132+
133+
Real alpha() const { return alpha_; }
134+
135+
//! \f$ I^{\alpha} y(t_N) \f$ for grid values y of spacing dt
136+
template <class T>
137+
T integrate(const std::vector<T>& y, Real dt) const {
138+
QL_REQUIRE(y.size() == steps_ + 1, "grid size (" << y.size()
139+
<< ") does not match the weights ("
140+
<< steps_ + 1 << " values expected)");
141+
QL_REQUIRE(dt > 0.0, "grid spacing dt (" << dt << ") must be positive");
142+
143+
// y[steps_] carries weight exactly 1 and is not stored, so w_ is
144+
// one shorter than y
145+
T sum{0};
146+
147+
for (Size j{0}; j < steps_; ++j) {
148+
sum += w_[j] * y[j];
149+
}
150+
sum += y[steps_];
151+
152+
return std::pow(dt, alpha_) / gamma_ * sum;
153+
}
154+
155+
private:
156+
Real alpha_{0.0};
157+
Size steps_{0};
158+
Real gamma_{0.0};
159+
std::vector<Real> w_;
160+
};
161+
162+
inline RiemannLiouvilleWeights::RiemannLiouvilleWeights(Real alpha, Size steps) {
163+
QL_REQUIRE(alpha >= 0.0, "integration order alpha (" << alpha << ") must be non-negative");
164+
QL_REQUIRE(steps > 0, "at least one grid step required");
165+
166+
alpha_ = alpha;
167+
steps_ = steps;
168+
gamma_ = GammaFunction().value(alpha + 2.0);
169+
w_ = std::vector<Real>(steps);
170+
171+
// powers m ^ (alpha + 1) for m = 0, ..., steps + 1, reused across the
172+
// shifted (k - 1, k, k + 1) terms in the weights below
173+
std::vector<Real> powAlphaPlus1(steps + 2);
174+
for (Size m{0}; m <= steps + 1; ++m) {
175+
powAlphaPlus1[m] = std::pow(static_cast<Real>(m), alpha + 1.0);
176+
}
177+
178+
// weight of y_0: (n - 1) ^ (alpha + 1) - (n - 1 - alpha) n ^ alpha
179+
w_[0] = powAlphaPlus1[steps - 1] - (steps - 1 - alpha) * std::pow(Real(steps), alpha);
180+
181+
for (Size j{1}; j < steps; ++j) {
182+
const Size k{steps - j};
183+
w_[j] = powAlphaPlus1[k + 1] + powAlphaPlus1[k - 1] - 2.0 * powAlphaPlus1[k];
184+
}
185+
}
186+
187+
122188
//! product-trapezoidal Riemann-Liouville fractional integral
123189
/*! Approximates
124190
\f[
@@ -130,31 +196,18 @@ namespace QuantLib {
130196
piecewise linear interpolant of \f$ y \f$ against the kernel.
131197
For \f$ \alpha = 1 \f$ this is the trapezoidal rule;
132198
\f$ \alpha \to 0 \f$ recovers the identity.
199+
200+
\note The weights are rebuilt on every call. Callers integrating
201+
repeatedly on the same \f$ (\alpha, N) \f$ grid should hold a
202+
RiemannLiouvilleWeights instead.
133203
*/
134204
template <class T = Real>
135205
T riemannLiouvilleIntegral(const std::vector<T>& y, Real alpha, Real dt) {
136206
QL_REQUIRE(alpha >= 0.0, "integration order alpha (" << alpha << ") must be non-negative");
137207
QL_REQUIRE(y.size() >= 2, "at least two grid values required");
138208
QL_REQUIRE(dt > 0.0, "grid spacing dt (" << dt << ") must be positive");
139209

140-
const Size n{y.size() - 1};
141-
142-
// powers m ^ (alpha + 1) for m = 0, ..., n + 1, reused across the shifted
143-
// (k - 1, k, k + 1) terms in the quadrature weights below
144-
std::vector<Real> powAlphaPlus1(n + 2);
145-
for (Size m{0}; m <= n + 1; ++m)
146-
powAlphaPlus1[m] = std::pow(static_cast<Real>(m), alpha + 1.0);
147-
148-
// weight of y_0: (n - 1) ^ (alpha + 1) - (n - 1 - alpha) n ^ alpha
149-
T sum{(powAlphaPlus1[n - 1] - (n - 1 - alpha) * std::pow(Real(n), alpha)) * y[0]};
150-
151-
for (Size j{1}; j < n; ++j) {
152-
const Size k{n - j};
153-
sum += (powAlphaPlus1[k + 1] + powAlphaPlus1[k - 1] - 2.0 * powAlphaPlus1[k]) * y[j];
154-
}
155-
sum += y[n];
156-
157-
return std::pow(dt, alpha) / GammaFunction().value(alpha + 2.0) * sum;
210+
return RiemannLiouvilleWeights(alpha, y.size() - 1).integrate(y, dt);
158211
}
159212
}
160213

ql/pricingengines/vanilla/analyticroughhestonengine.cpp

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,36 @@ namespace QuantLib {
178178

179179
void AnalyticRoughHestonEngine::update() {
180180
chFCache_.clear();
181-
// The kernel nodes depend on the Hurst exponent
181+
// The kernel nodes depend on the Hurst exponent.
182182
liftedGridCache_.clear();
183+
// As does the fractional order of the Riemann-Liouville integral
184+
rlWeightsOne_.reset();
185+
rlWeightsFractional_.reset();
186+
183187
GenericModelEngine<RoughHestonModel,
184188
VanillaOption::arguments,
185189
VanillaOption::results>::update();
186190
}
187191

192+
const RiemannLiouvilleWeights& AnalyticRoughHestonEngine::unitWeights() const {
193+
if (!rlWeightsOne_) {
194+
rlWeightsOne_.emplace(1.0, timeSteps_);
195+
}
196+
197+
return *rlWeightsOne_;
198+
}
199+
200+
const RiemannLiouvilleWeights& AnalyticRoughHestonEngine::fractionalWeights(Real alpha) const {
201+
// Identity check on a cache key, not a numerical comparison: alpha is
202+
// rebuilt from the same expression every call, so a difference means
203+
// the Hurst exponent moved.
204+
if (!rlWeightsFractional_ || rlWeightsFractional_->alpha() != alpha) {
205+
rlWeightsFractional_.emplace(alpha, timeSteps_);
206+
}
207+
208+
return *rlWeightsFractional_;
209+
}
210+
188211
std::complex<Real> AnalyticRoughHestonEngine::lnChF(
189212
const std::complex<Real>& z, Time t) const {
190213

@@ -244,8 +267,8 @@ namespace QuantLib {
244267

245268
const Real dt{t / timeSteps_};
246269

247-
return kappa * theta * riemannLiouvilleIntegral(h, 1.0, dt)
248-
+ v0 * riemannLiouvilleIntegral(h, 1.0 - a, dt);
270+
return kappa * theta * unitWeights().integrate(h, dt) +
271+
v0 * fractionalWeights(1.0 - a).integrate(h, dt);
249272
}
250273

251274
std::complex<Real> AnalyticRoughHestonEngine::lnChFPade(
@@ -266,8 +289,8 @@ namespace QuantLib {
266289
h[j] = evaluatePade(c, sigma * std::pow(Real(j) * dt, a));
267290
}
268291

269-
return kappa * theta * riemannLiouvilleIntegral(h, 1.0, dt)
270-
+ v0 * riemannLiouvilleIntegral(h, 1.0 - a, dt);
292+
return kappa * theta * unitWeights().integrate(h, dt) +
293+
v0 * fractionalWeights(1.0 - a).integrate(h, dt);
271294
}
272295

273296
// (3, 3) global rational approximation of Gatheral-Radoicic

ql/pricingengines/vanilla/analyticroughhestonengine.hpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
#include <ql/instruments/vanillaoption.hpp>
2828
#include <ql/math/array.hpp>
2929
#include <ql/math/integrals/fourierintegration.hpp>
30+
#include <ql/math/ode/fractionaladams.hpp>
3031
#include <ql/models/equity/roughhestonmodel.hpp>
3132
#include <ql/pricingengines/genericmodelengine.hpp>
3233
#include <complex>
3334
#include <map>
35+
#include <optional>
3436
#include <tuple>
3537
#include <vector>
3638

@@ -204,6 +206,12 @@ namespace QuantLib {
204206
const ext::shared_ptr<PlainVanillaPayoff>& payoff,
205207
Time maturity, Real fwd) const;
206208

209+
//! Weights of the `alpha = 1` integral, built on first use
210+
const RiemannLiouvilleWeights& unitWeights() const;
211+
212+
//! Weights of the `alpha = 1 - a` integral, rebuilt when `a` moves
213+
const RiemannLiouvilleWeights& fractionalWeights(Real alpha) const;
214+
207215
const Size timeSteps_;
208216
const Integration integration_;
209217
const Real andersenPiterbargEpsilon_, alpha_;
@@ -214,6 +222,14 @@ namespace QuantLib {
214222
mutable std::map<std::tuple<Real, Real, Time>, std::complex<Real>>
215223
chFCache_;
216224
mutable std::map<Time, LiftedGrid> liftedGridCache_;
225+
226+
/*
227+
Quadrature weights of the two Riemann-Liouville integrals the chF is
228+
built from. Both depend only on (alpha, timeSteps_); the fractional
229+
one moves with the Hurst exponent, so both are dropped in `update()`.
230+
*/
231+
mutable std::optional<RiemannLiouvilleWeights> rlWeightsOne_;
232+
mutable std::optional<RiemannLiouvilleWeights> rlWeightsFractional_;
217233
};
218234
}
219235

test-suite/roughhestonmodel.cpp

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "utilities.hpp"
2222
#include <ql/exercise.hpp>
2323
#include <ql/instruments/vanillaoption.hpp>
24+
#include <ql/math/array.hpp>
2425
#include <ql/math/distributions/gammadistribution.hpp>
2526
#include <ql/math/ode/fractionaladams.hpp>
2627
#include <ql/math/ode/fractionalkernelapproximation.hpp>
@@ -38,6 +39,7 @@
3839
#include <ql/time/daycounters/actual365fixed.hpp>
3940
#include <cmath>
4041
#include <complex>
42+
#include <iomanip>
4143
#include <vector>
4244

4345
using namespace QuantLib;
@@ -188,6 +190,144 @@ BOOST_AUTO_TEST_CASE(testRiemannLiouvilleIntegral) {
188190
}
189191
}
190192

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+
191331
BOOST_AUTO_TEST_CASE(testEquivalenceWithHestonModel) {
192332
BOOST_TEST_MESSAGE(
193333
"Testing rough Heston engine against the classical Heston engine for H = 0.5...");

0 commit comments

Comments
 (0)