# `ZenQuant.Options.Pricing.FiniteDifference`
[🔗](https://github.com/ZenHive/zen_quant/blob/v0.8.1/lib/zen_quant/options/pricing/finite_difference.ex#L1)

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 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 budget** — `400` 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
| 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` |

# `error_reason`

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

Solver failure reason.

# `exercise`

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

Exercise style.

# `inputs`

```elixir
@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`

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

European option side.

# `result`

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

# `price`

```elixir
@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

```elixir
{:ok,
 %{
   price: 6.09,
   option_type: :put,
   exercise: :american,
   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`

```elixir
# descripex:contract
%{
  opts: %{
    theta: %{
      default: 0.5,
      type: :float,
      description: "θ-scheme weight in [0, 1]; 0.5 is Crank-Nicolson, 1.0 fully implicit"
    },
    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"
    },
    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,
   %{
     price: 6.09,
     option_type: :put,
     exercise: :american,
     spatial_steps: 400,
     time_steps: 400,
     s_max: 400.0
   }},
  composes_with: [:price]
}
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
