ZenQuant.Options.Pricing.FiniteDifference (zen_quant v0.7.0)

Copy Markdown View Source

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:callmax(S − K, 0), :putmax(K − S, 0).
  • Exercise:american (default, the reason the solver exists) or :european.
  • Boundary conditions — Dirichlet. At S = 0: an American put is worth K (immediate exercise), a European put K·e^{-rτ}, a call 0. At S = S_max: a put 0, a call S_max·e^{-qτ} − K·e^{-rτ} (an American call additionally floored at intrinsic). S_max defaults to 4 × max(spot, strike).
  • Tolerance — the PSOR sweep converges to an absolute 1.0e-8 change; the consumer's price-accuracy target is 0.01 in currency units, met by the default grid for at-the-money maturities up to a few years.
  • Grid / runtime budget400 spatial × 400 time 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

FunctionArityDescriptionParam Kinds
price3Price 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

error_reason()

@type error_reason() ::
  {:invalid_option_type, term()}
  | {:missing_input, atom()}
  | {:invalid_input, atom()}
  | :zero_volatility_unsupported
  | {:invalid_solver_option, atom()}

Solver failure reason.

exercise()

@type exercise() :: :american | :european

Exercise style.

inputs()

@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.

option_type()

@type option_type() :: :call | :put

European option side.

result()

@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

price(option_type, inputs, opts \\ [])

@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 - :call or :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]
}