C++ Design of PDE Models
C++ Design of PDE Models
See the Terms and Conditions (https://onlinelibrary.wiley.com/terms-and-conditions) on Wiley Online Library for rules of use; OA articles are governed by the applicable Creative Commons License
A PDE Software Framework in C++11 for a
Class of Path-Dependent Options
Daniel J. Duffy
Datasim Finance, e-mail: [email protected]
Abstract threads and tasks as well as tasks in the Microsoft Parallel Patterns Library (PPL). We
In this article we apply the Alternating Direction Explicit (ADE) finite difference shall discuss these topics elsewhere.
method to a class of partial differential equations (PDEs) occurring in computation- We see this paper as being useful for model validators and front-office developers
al finance. We take the results of Wilmott, Lewis, and Duffy (2014) and we design a who wish to create pluggable applications in which components can be replaced by
software framework based on it using system decomposition methods in combina- other components having similar functionality. An important implicit assumption
tion with the multiparadigm language features in C++11. underlying our approach is that we design software systems with change in mind.
can improve speedup by testing the resulting sequential code against multithreading
∂U ∂ 2U
and multitasking codes in C++ and related libraries. Specifically, we can use C++11 ∂t
+ LsU + LvU + ρσ vS ∂ S∂v
=0 (1)
48 WILMOTT magazine
WILMOTT magazine 49
+ (γ ( S − A)(1 − y )2 / b ) ∂V
∂y
− rV
// Scale factors (hotspots)
( ).
double scale1;
ax by
0 < x < 1, 0 < y < 1 S = 1− x
, A= 1− y
(7) double scale2;
We thus see that equation (7) is a PDE defined on the unit square (0, 1) × (0, 1). // Parameters of PDE
double r;
In order to describe the resulting initial boundary value problem, we need to impose double lambda;
boundary conditions. For x = 0 and x = 1 we impose the well-known boundary con- double K;
ditions that apply to plain call or put options. For y = 0 and y = 1 no boundary condi- // Domain information
tions are needed, as mentioned in WLD2014. This fact can be verified and motivated double T;
double xMax, yMax;
by application of the Fichera theory (see Duffy, 2009; Duffy and Germani, 2013) or
by the method of characteristics. std::function<double(double S1, double S2)> payoff;
double a11(double x, double y, double t)
{
4 The anchoring PDE double tx = 1.0-x; double ty = 1.0-y;
The financial background to the current problem is given in WLD2014. We define a double S = scale1*x/tx;
double I = scale2*y/ty;
volatility function ( ), where = S/A and the model is:
// Straight computation, i.e. function call
dS = Sdt + ( ) SdW. (8) double s1 = SIGMOID2(S / I);
We model the problem as one with memory. To this end, we define a variable A that
return 0.5*s1*s1*x*x*tx*tx;
represents an average or an anchoring value. One of the simplest and most tractable }
models is given by the following sigmoidal function:
A = λ ∫∞ e− λ (t−τ ) S (τ )dτ
t
(9)
double b1(double x, double y, double t)
where is a measure of the extent of memory. {
double tx = 1.0-x; double ty = 1.0 - y;
The form of this function has been determined from time series (see WLD2014)
and it will be used as the volatility function in the PDE that is represented in equa- double S = scale1*x/tx;
tions (6) and (7). double I = scale2*y/ty;
double s1 = SIGMOID2(S / I);
Moving to C++ design of the anchoring PDE, we first note that it plays the role of return r*x*tx - s1*s1*x*x*tx;
the implementer of TwoFactorAsianPde in the Bridge pattern. It is an instance of }
TwoFactorAsianPde that is created by a Factory Method. In this case we choose double b2(double x, double y, double t)
the following alternatives: {
double tx = 1.0-x; double ty = 1.0 - y;
• A1: Defining the PDE components in a namespace.
double S = scale1*x/tx;
• A2: Creating a void function that initializes an already created reference to an double I = scale2*y/ty;
instance of TwoFactorAsianPde.
return lambda*(S - I)*ty*ty/scale2;
• A3: A Factory Method (as discussed in GOF, 1995) that creates an instance of }
std::shared_ptr<TwoFactorAsianPde<double>>.
We discuss each of these options as possible ways to create the objects that we double d(double x, double y, double t)
need. Each choice has its advantages and disadvantages. The ultimate choice depends {
return -r;
on the context. The traditional object-oriented programming style would tend to }
employ subtype polymorphism or the CRTP, both of which necessitate the creation
double F(double x, double y, double t)
of class hierarchies. The choices A1, A2, and A3 avoid this step. {
First, choice A1 involves placing all relevant data and functions in a namespace: return 0;
}
namespace AnchorPde
{ // Similar to an Asian PDE.
double IC(double x, double y)
double SIGMOID2(double t) { // Initial condition
{ // t = S/A
// Workaround/fudge to avoid spike
// Example of Table 2 if (x == 1.0)
const double a=0.6; // (vol for low S/I) x -= 0.0001;
const double b=0.15; // (vol for high S/I)
const double c=10.0; if (y == 1.0)
const double d=-0.3; // (when S=I vol is in the middle) y -= 0.0001;
const double ecd = std::exp(c*d);
50 WILMOTT magazine
pde.b2 = b2;
return payoff(scale1*x/(1.0-x), scale2*y/(1.0-y));
}
pde.d = d;
pde.F = F;
// Approach: }
double BCLower(double x, double t)
{ Finally, choice A3 is an example of the Factory Method design pattern that returns a
TwoFactorAsianPde pointer. All low-level construction code is hidden behind
// V1 rough and ready
return 0; the interface. Notice that we use lambda functions to create the coefficients of the PDE:
}
std::shared_ptr<TwoFactorAsianPde<double>> CreateAsianPde()
{ // Factory method to create an Asian pde for input to FDM
double BCUpper(double x, double t) // Steps
{ // 1. Get input data
// Tricky one for variable I // 2. Create the components of the PDE
return 0; // Force the solution to be BS classic. // 3. Return the instance of AsianPde
}
double r = 0.049;
double T;
double BCLeft(double y, double t) cout << "T: "; cin >> T;
{ // y is S2 // Values where price is calculated S/I == 1
double S = 100; double I = 100;
// return 0; // C
return K* exp(-r*t); // P double K;
cout << "K: "; cin >> K;
}
double lambda = 0.5;
double BCRight(double y, double t)
{ // Define payoff as a lambda function
return 0; // P
double xMax = 1.0; // x far field
double S = scale1*xMax / (1 - xMax+0.001);
double yMax = 1.0; // y FF
return S -K*std::exp(-r*t); // C
} double xHotSpot = 0.5; double yHotSpot = 0.5;
} double scale1 = S*(1.0 - xHotSpot) / xHotSpot;
double scale2 = I*(1.0 - yHotSpot) / yHotSpot;
// Domain transformation data; we use the transform z = x/(x + scale1), auto a11 = [=] (double x, double y, double t)
// w = y/(y + scale2). The hotspot (S1, S2) in (x,y) space gets mapped to {
// return 0;
// (1/2, 1/2) (usually) in (z,w) space.
double tx = 1.0 - x; double ty = 1.0 - y;
xMax = 1.0; // x far field
yMax = 1.0; // y FF double S = scale1*x / tx;
double I = scale2*y / ty;
double xHotSpot = 0.5; double yHotSpot = 0.5;
scale1 = S*(1.0 - xHotSpot)/xHotSpot; scale2 = I*(1.0 - yHotSpot)/yHotSpot; // Straight computation, i.e. function call
double s1 =SIGMOID2(S / I);
Second, choice A2 is a variation on A1 in the sense that it uses the above variables return 0.5*s1*s1*x*x*tx*tx;
};
and it encapsulates the creation process in a single function:
WILMOTT magazine 51
Finally, we show the code (using design choice A1) that creates the domain in which // Other variables
double tprev, tnow, T;
the anchoring PDE is defined: std::size_t NX, NY, NT;
public:
void CreateAnchorPdeDomain(TwoFactorPdeDomain<double>& pdeDomain, std::vector<double> xmesh;
double xMax, double yMax, double T, std::vector<double> ymesh;
const std::function<double(double, double)>& IC) std::vector<double> tmesh;
{ public:
using namespace AnchorPde;
TwoFactorAsianADESolver(const TwoFactorPdeDomain<double>& domain,
pdeDomain.rx = Range<double>(0.0, xMax); const std::vector<double>& xarr,
const std::vector<double>& yarr,
pdeDomain.ry = Range<double>(0.0, yMax); const std::vector<double>& tarr)
pdeDomain.rt = Range<double>(0.0, T); : xmesh(xarr), ymesh(yarr), tmesh(tarr)
{
pdeDomain.LeftBC = BCLeft; cout << "Two-factor ADE Asian classic version\n";
pdeDomain.RightBC = BCRight; pdeDomain = domain;
pdeDomain.UpperBC = BCUpper; hx=pdeDomain.rx.spread() / static_cast<double>(xmesh.size() - 1);
pdeDomain.LowerBC = BCLower; hy=pdeDomain.ry.spread() / static_cast<double>(ymesh.size() - 1);
delta_k = pdeDomain.rt.spread()
pdeDomain.IC = IC; / static_cast<double>(tmesh.size() - 1);
} T = pdeDomain.rt.high();
We focus on the Barakat–Clark ADE variant with design choice A1. The cor- }
initIC();
}
• Constructor: initialize mesh arrays and matrices holding option prices.
///////////////////////////////////////////////////////////////////
• calculate(): compute the left-to-right and right-to-left sweeps and their void initIC()
{ // Utility function to initialise payoff function and BCs at t = 0
average at each time level.
• calculateBC(): calculate the boundary conditions on each of the four tprev = tnow = tmesh[0];
boundaries of the domain of integration at each time level. // Now initialise values in interior of interval using
// the initial function 'IC' from the PDE
• initIC(): initialize the solution at t = 0 by assigning it to the discretized val- for (std::size_t i = 0; i < xmesh.size(); ++i)
{
ues at the mesh points of the payoff function. for (std::size_t j = 0; j < ymesh.size(); ++j)
• result(): this is the implementation of what is essentially the state machine {
MatNew(i,j) = U(i, j) = V(i, j) = AnchorPde::IC(xmesh[i], ymesh[j]);
that marches the solution from payoff up to expiration. In a later version of the }
}
software it will be a member function of a mediator class, as already discussed }
in our work.
52 WILMOTT magazine
tprev = tnow;
// Left and Right BC }
std::size_t left = 0;
std::size_t right = xmesh.size() -1; }
for (std::size_t j = 0; j < ymesh.size(); ++j)
{
MatNew(left, j) = U(left, j) = V(left, j) = AnchorPde::BCLeft(ymesh[j], tnow); An example of use is:
MatNew(right, j) = U(right, j) = V(right, j) = AnchorPde::BCRight(ymesh[j], tnow);
}
} // Domain
TwoFactorPdeDomain<double> pdeDomain;
CreateAnchorPdeDomain(pdeDomain, xMax, yMax, T, payoff);
void calculate()
{ // Tells how to calculate sol. at n+1, Explicit ADE schemes
// Meshes. Create the mesh
long NX = 100; long NY = 100; long NT = 100;
double mx, my;
int sgn; cout << "Give NX \n"; cin >> NX;
cout << "Give NY \n"; cin >> NY;
cout << "Give NT \n"; cin >> NT;
for (std::size_t i = 1; i <= xmesh.size()-2; ++i)
{
mx = xmesh[i]; std::vector<double> xmesh = pdeDomain.rx.mesh(NX);
for (std::size_t j = 1; j <= ymesh.size()-2; ++j) std::vector<double> ymesh = pdeDomain.ry.mesh(NY);
{
std::vector<double>tmesh = pdeDomain.rt.mesh(NT);
// Create coefficients
// hU_t = aU_xx + bU_yy + cU_xy + dU_x + eU_y + fU + G; // f = f(x,y,t) cout << "Now creating solver\n";
my = ymesh[j];
Since we have different ways to create a PDE, we encapsulate each choice in a fac-
void result()
{ // The result of the calculation tory method whose sole task is to create a PDE instance based on input from a given
^
WILMOTT magazine 53
pde.b1 = b1; This is efficient code, because the mesh arrays support random-access iterators. For
pde.b2 = b2; cases in which the mesh arrays do not support random-access iterators, we can use
pde.d = d;
pde.F = F; std::find_if but it is slower:
return std::shared_ptr<TwoFactorAsianPde<double>> // Find position of 1st element in vector that satisfies the predicate d >= x.
(new TwoFactorAsianPde<double>(pde)); // Linear complexity.
} auto posA = std::find_if(xarr.begin(), xarr.end(), [&](double d)
{ return d >= x; });
auto posB = std::find_if(yarr.begin(), yarr.end(), [&](double d)
This design can be applied to a wide range of problems in which PDEs are approxi- { return d >= y; });
mated by finite difference methods. It is a combination of a structural pattern and a In general, it is recommended to investigate the possibility of using STL algorithms
creational pattern, namely Adapter and Factory Method, respectively (as discussed as building blocks when creating higher-level functionality. This avoids reinvention
in GOF, 1995). A summary of the design steps is: of the software wheel.
54 WILMOTT magazine
7 Accuracy and performance We automate the process of checking the computed values from Table 2 of
We know that the Saul’yev scheme is unconditionally stable and that it is first-order WLD2014. To this end, we first create two arrays representing expirations and
accurate (Saul’yev, 1964). This is in contrast to the FTCS (Forward in Time Centered strikes:
in Space) scheme that is also first-order accurate but only conditionally stable, which
// Input data to the option pricer
demands that we choose a large number of time steps in order to avoid oscillations. std::vector<double> expiries = { 0.25, 1.0, 3.0, 10.0 };
Furthermore, we use upwinding or downwinding in the y variable in equation (7), std::vector<double> strikes = { 60.0, 80.0, 100.0, 120.0, 140.0 };
depending on the sign of the convection coefficient. This method can be applied to
We also need to define some extra variables:
the model problem:
// Values where price is calculated S/I == 1
∂u ∂u double S = 100; double I = 100;
+ a = 0, −∞ < x < ∞. t > 0. (11)
∂t ∂x double xMax = 1.0; // x far field
double yMax = 1.0; // y FF
The simplest upwind scheme is the first-order upwind scheme, defined by: double xHotSpot = 0.5; double yHotSpot = 0.5;
double scale1 = S*(1.0 - xHotSpot) / xHotSpot;
double scale2 = I*(1.0 - yHotSpot) / yHotSpot;
u nj +1−u nj u nj −u nj−1
∆t
+a h
= 0 for a > 0 (12) long NX = 500; long NY = 500; long NT = 2000;
std::cout << "NX, NY, NT (put Saul’yev): "
<< NX << "," << NY << "," << NT << '\n';
∆t
+a h
= 0 for a < 0. (13) std::vector<double> xmesh = pdeDomain.rx.mesh(NX);
std::vector<double> ymesh = pdeDomain.ry.mesh(NY);
std::vector<double>tmesh = pdeDomain.rt.mesh(NT);
It can be shown using von Neumann stability analysis (see Duffy, 2006) that this
scheme is stable if the famous Courant–Friedrichs–Lewy (CFL) condition is satisfied: The following double loop computes the option price for each expiration
and strike:
a ∆t
c =| |≤ 1 (14)
h for (std::size_t k = 0; k < strikes.size(); ++k)
{
// Define payoff as a lambda function
Schemes (12) and (13) are first-order accurate in space and time and they can intro- int cp = -1;
if (cp > 0)
duce severe numerical diffusion and dissipation in the solution due to the presence payoff = [=](double S, double A) -> double
{ return std::max(S - strikes[k], 0.0); }; // call
of large gradients. In our work here we take a modified version of schemes (12) and else
payoff = [=](double S, double I) -> double
(13), namely: { return std::max(strikes[k] - S, 0.0); }; // put
WILMOTT magazine 55
56 WILMOTT magazine