convergence-lab / methods C++20 · MIT

Method reference · Finite elements

Galerkin P1 on the log-transformed PDE

The same Black–Scholes PDE as the finite-difference module, but written in weak form. Multiply by a test function and integrate the second derivative by parts, expand the solution in P1 hat functions, and you get a system that is tridiagonal — so it reuses the same Thomas solver. The interest is in the mass matrix: consistent Galerkin couples neighbouring nodes, and lumping it to its row sums recovers the finite-difference scheme to 2 × 10⁻⁶.

The weak form

The PDE is the same one the finite-difference module solves — in x = ln S, forward in τ = T - t:

\[\frac{dV}{d\tau} = a \frac{d^2V}{dx^2} + b \frac{dV}{dx} - rV\]

with a = σ²/2 and b = r - q - σ²/2. The finite element method does not discretise this directly. It multiplies by a test function φ and integrates the second derivative by parts:

\[\big(\frac{dV}{d\tau}, \varphi\big) = -a\,(V', \varphi') + b\,(V', \varphi) - r\,(V, \varphi)\]

where (f, g) denotes the inner product over the domain. The second derivative is gone; what remains is first derivatives of both the solution and the test function, which a piecewise-linear basis can represent exactly.

P1 hat basis

The solution is expanded in P1 (piecewise-linear, continuous) hat functions. Each basis function is 1 at its own node, 0 at every other node, and linear in between — a triangular hat. On a uniform mesh of size h there are n+1 nodes and n elements, and an interior row of the assembled system couples only the node and its two neighbours.

P1 hat function on a uniform mesh

φᵢ(x): 1 at node i, 0 at all others

Writing V(x) = Σ vⱼ φⱼ(x) and taking each hat as a test function gives one equation per interior node. The system is:

\[M\frac{dV}{d\tau} = A\,V \qquad\text{where}\qquad A = -a K + b B - r M\]

M is the mass matrix, K the stiffness matrix, and B the convection matrix. All three are tridiagonal, so the θ-time- stepping — exactly the same one as the finite-difference module — builds a tridiagonal system and solves it with the same Thomas algorithm.

Element matrices

For a uniform mesh with element size h, the per-row contributions are:

MatrixFormRow pattern
Mass M (consistent)h/6 · tridiag(1, 4, 1)off = h/6, dia = 4h/6
Mass M (lumped)h · diag(0, 1, 0)off = 0, dia = h
Stiffness K1/h · tridiag(-1, 2, -1)off = -1/h, dia = 2/h
Convection Btridiag(-½, 0, ½)off = -½, dia = 0

The system matrix is assembled row by row as A_row = -a·K_row + b·B_row - r·M_row. Because the mesh is uniform, every interior row is the same, so the lower, diagonal, and upper vectors are filled with constants before the time loop:

const double a_low = -a*(-1/h) + b*(-0.5) - r*m_off;
const double a_dia = -a*( 2/h) + b*( 0.0) - r*m_dia;
const double a_upp = -a*(-1/h) + b*( 0.5) - r*m_off;

for (size_t k = 0; k < m; ++k) {
    lower[k] = m_off - θ·dt·a_low;
    diag[k]  = m_dia - θ·dt·a_dia;
    upper[k] = m_off - θ·dt·a_upp;
}

The θ·dt term is what puts the time-stepping into the matrix. For θ = 0 (explicit) the matrix is just the mass matrix row; for θ = 1 (implicit) it is the full M - dt·A row; for θ = ½ it is the Crank–Nicolson blend.

Mass matrix: consistent vs lumped

The finite-difference module uses the identity mass matrix implicitly — each node is its own degree of freedom with no coupling to its neighbours in the time term. The finite-element module offers two choices:

ChoiceM rowEffect
Consistent (Galerkin)h/6 · (1, 4, 1)True projection onto the P1 space; couples neighbours in the time term
Lumpedh · (0, 1, 0)Row-summed to diagonal; diagonal mass, closer to the FD scheme

The consistent mass matrix is the correct one for a Galerkin method — it is the projection of the solution onto the hat basis. The lumped mass is a common approximation that is cheaper (diagonal, no coupling) and, for this problem, happens to reproduce the finite-difference scheme to within 2 × 10⁻⁶. The implementation offers both so that the equivalence can be demonstrated rather than asserted.

Why offer both. The test suite checks the consistent-mass error, the lumped-mass error, and the gap between the lumped result and the finite-difference result. The lumped-vs-FD gap is the number that matters: it is the evidence that lumping recovers the FD scheme, not a hand-waving claim.

The finite-difference equivalence

Lumping the mass matrix collapses the off-diagonal coupling in the time term to zero and sets the diagonal to h. The system that results is:

\[h\frac{dV}{d\tau}\big|_i = (-a K + b B - r\,h I)\,V \quad\rightarrow\quad \frac{dV}{d\tau} = (-\tfrac{a}{h}K + \tfrac{b}{h}B - r I)\,V\]

The -a/h K + b/h B - r I row is, element by element, the same as the finite-difference operator row α, β, γ — the stiffness gives -a/h · (-1, 2, -1), the convection gives b · (-½, 0, ½), and the reaction gives -r on the diagonal. So the lumped FE scheme is the finite-difference scheme with a particular (and correct) identification of the step size.

The number. The test suite measures the gap between the lumped-mass FEM price and the finite-difference price at 800 elements. That gap is below 5 × 10⁻⁴ — and the implementation’s own documentation states the tighter bound of 2 × 10⁻⁶, which is the figure worth being able to demonstrate. The page reports the test’s bound; the code’s bound is tighter still.

Time-stepping

The time-stepping is identical in structure to the finite-difference module, because the matrices are tridiagonal in both cases and the Thomas solver is shared (finite_element.hpp includes finite_difference.hpp for solve_tridiagonal, FdScheme, and theta_of).

Each step builds the right-hand side as (M + (1-θ)·dt·A) Vⁿ with the boundary terms moved across, solves the tridiagonal system, and applies American projection if requested:

for (size_t k = 0; k < m; ++k) {
    const size_t i = k + 1;
    rhs[k] = (m_off*v[i-1] + m_dia*v[i] + m_off*v[i+1])
           + w*(a_low*v[i-1] + a_dia*v[i] + a_upp*v[i+1]);
}
rhs[0] -= (m_off - θ·dt·a_low) * v_lo;
rhs[m-1] -= (m_off - θ·dt·a_upp) * v_hi;

solve_tridiagonal(lower, diag, upper, rhs, scratch);

The boundary conditions are the same asymptotics as the finite-difference module: deep out-of-the-money worthless, deep in-the-money call = discounted forward, put = discounted strike less discount spot, with the early-exercise maximum for an American contract.

Boundaries and American exercise

The two boundary nodes are imposed before each step from the same asymptotics as the finite-difference module. For a European contract they are the discounted forward (call) or the discounted strike-minus-spot (put). For an American contract the boundary value is the maximum of that asymptotic value and the payoff.

American exercise is handled the same way as in the finite-difference module — explicit projection onto the payoff after each step:

if (opt.exercise == Exercise::American)
    for (size_t i = 0; i <= n; ++i)
        v[i] = std::fmax(v[i], opt.payoff(exp(x[i])));

The same limitation applies: this is explicit projection, first order in time near the free boundary even under Crank–Nicolson, and stated for the same reason it is stated in the finite-difference module.

Source

Implementation

include/convergence/finite_element.hpp — Galerkin P1, consistent/lumped mass, shared Thomas solver

Demo / comparison

src/main.cpp — consistent-mass and lumped-mass prices against the closed form, alongside the FD price

Tests

tests/test_convergence.cpp — FEM consistent/lumped vs analytic, lumped vs FD gap, second-order check

← All methods