Finite-difference PDE pricing for European and American vanilla options.
The Black-Scholes operator is discretized on a uniform price grid and marched backward from expiry with a θ-scheme (Crank-Nicolson by default, θ = 0.5) using two fully-implicit Rannacher startup steps to damp the payoff-kink oscillations Crank-Nicolson would otherwise produce. European values are solved directly with the Thomas tridiagonal algorithm; the American early exercise free boundary is resolved as a linear complementarity problem with Projected Successive Over-Relaxation (PSOR).
Consumer-defined specification
The unblocking consumer (the operator) defines the numerical contract; the defaults below encode it and every knob is caller-overridable:
- Payoff —
:call→max(S − K, 0),:put→max(K − S, 0). - Exercise —
:american(default, the reason the solver exists) or:european. - Boundary conditions — Dirichlet. At
S = 0: an American put is worthK(immediate exercise), a European putK·e^{-rτ}, a call0. AtS = S_max: a put0, a callS_max·e^{-qτ} − K·e^{-rτ}(an American call additionally floored at intrinsic).S_maxdefaults to4 × max(spot, strike). - Tolerance — the PSOR sweep converges to an absolute
1.0e-8change; the consumer's price-accuracy target is0.01in currency units, met by the default grid for at-the-money maturities up to a few years. - Grid / runtime budget —
400spatial ×400time steps by default, chosen to price a single option well inside the operator's 250 ms budget. Coarser grids trade accuracy for speed; both dimensions are caller-set.
Input contract
price/3 accepts :call or :put plus the same map as
ZenQuant.Options.Pricing:
:spot,:strike— positive currency prices:time_to_expiry_years— non-negative caller-computed year fraction:risk_free_rate,:dividend_yield— annual continuously compounded decimals:volatility— positive annual decimal standard deviation
At expiry (:time_to_expiry_years == 0.0) the intrinsic payoff is returned
directly. Zero volatility is a degenerate, convection-dominated PDE and
returns {:error, :zero_volatility_unsupported}; use
ZenQuant.Options.Pricing for the deterministic zero-volatility payoff.
The θ-scheme is unconditionally stable, so refining either grid dimension moves the price monotonically toward the analytic/binomial reference rather than diverging.
API Functions
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
price | 3 | Price a European or American call/put with a finite-difference PDE solver. | option_type: value, inputs: value |
Summary
Types
Solver failure reason.
Exercise style.
Pricing input map; volatility must be strictly positive.
European option side.
Successful finite-difference valuation with grid provenance.
Functions
Price a European or American call/put with a finite-difference PDE solver.
Types
@type error_reason() :: {:invalid_option_type, term()} | {:missing_input, atom()} | {:invalid_input, atom()} | :zero_volatility_unsupported | {:invalid_solver_option, atom()}
Solver failure reason.
@type exercise() :: :american | :european
Exercise style.
@type inputs() :: %{ spot: number(), strike: number(), time_to_expiry_years: number(), risk_free_rate: number(), dividend_yield: number(), volatility: number() }
Pricing input map; volatility must be strictly positive.
@type option_type() :: :call | :put
European option side.
@type result() :: %{ price: float(), option_type: option_type(), exercise: exercise(), spatial_steps: pos_integer(), time_steps: pos_integer(), s_max: float() }
Successful finite-difference valuation with grid provenance.
Functions
@spec price(option_type() | term(), inputs() | term(), keyword() | term()) :: {:ok, result()} | {:error, error_reason()}
Price a European or American call/put with a finite-difference PDE solver.
Parameters
option_type-:callor:put(value)inputs- Map with positive :spot/:strike, non-negative :time_to_expiry_years, continuous annual :risk_free_rate/:dividend_yield, and positive annual :volatility (value)
Options
exercise-:american(PSOR free boundary) or:european(Thomas solve) (default::american)spatial_steps- Number of price grid intervals; must be at least 4 (default:400)time_steps- Number of backward time steps; must be positive (default:400)s_max_multiple- Upper grid bound as a multiple of max(spot, strike); must exceed 1 (default:4.0)theta- θ-scheme weight in [0, 1]; 0.5 is Crank-Nicolson, 1.0 fully implicit (default:0.5)rannacher_steps- Leading fully-implicit steps that damp the payoff-kink oscillation (default:2)psor_omega- PSOR over-relaxation factor in (0, 2) (default:1.2)psor_tolerance- Positive absolute PSOR convergence tolerance (default:1.0e-8)psor_max_iterations- Positive PSOR sweep limit per time step (default:10000)
Returns
{:ok, %{price, option_type, exercise, spatial_steps, time_steps, s_max}} or {:error, reason} (result_tuple)
Example
{:ok,
%{
option_type: :put,
exercise: :american,
price: 6.09,
spatial_steps: 400,
time_steps: 400,
s_max: 400.0
}}Errors
:invalid_option_type- Option type is not :call or :put:missing_input- A required map field is absent:invalid_input- A field has an unsupported type or domain:zero_volatility_unsupported- The PDE is convection-dominated at zero volatility:invalid_solver_option- A grid, θ, or PSOR option is invalid
Composes With
price
# descripex:contract
%{
opts: %{
exercise: %{
default: :american,
type: :atom,
description: "`:american` (PSOR free boundary) or `:european` (Thomas solve)"
},
spatial_steps: %{
default: 400,
type: :integer,
description: "Number of price grid intervals; must be at least 4"
},
time_steps: %{
default: 400,
type: :integer,
description: "Number of backward time steps; must be positive"
},
s_max_multiple: %{
default: 4.0,
type: :float,
description: "Upper grid bound as a multiple of max(spot, strike); must exceed 1"
},
theta: %{
default: 0.5,
type: :float,
description: "θ-scheme weight in [0, 1]; 0.5 is Crank-Nicolson, 1.0 fully implicit"
},
rannacher_steps: %{
default: 2,
type: :integer,
description: "Leading fully-implicit steps that damp the payoff-kink oscillation"
},
psor_omega: %{
default: 1.2,
type: :float,
description: "PSOR over-relaxation factor in (0, 2)"
},
psor_tolerance: %{
default: 1.0e-8,
type: :float,
description: "Positive absolute PSOR convergence tolerance"
},
psor_max_iterations: %{
default: 10000,
type: :integer,
description: "Positive PSOR sweep limit per time step"
}
},
params: %{
option_type: %{description: "`:call` or `:put`", kind: :value},
inputs: %{
description: "Map with positive :spot/:strike, non-negative :time_to_expiry_years, continuous annual :risk_free_rate/:dividend_yield, and positive annual :volatility",
kind: :value
}
},
errors: [
invalid_option_type: "Option type is not :call or :put",
missing_input: "A required map field is absent",
invalid_input: "A field has an unsupported type or domain",
zero_volatility_unsupported: "The PDE is convection-dominated at zero volatility",
invalid_solver_option: "A grid, θ, or PSOR option is invalid"
],
returns: %{
type: :result_tuple,
description: "`{:ok, %{price, option_type, exercise, spatial_steps, time_steps, s_max}}` or `{:error, reason}`"
},
returns_example: {:ok,
%{
option_type: :put,
exercise: :american,
price: 6.09,
spatial_steps: 400,
time_steps: 400,
s_max: 400.0
}},
composes_with: [:price]
}