Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
--cov-report=term-missing \
--cov-report=xml \
--cov-branch \
-q --runslow
-q -n auto --runslow

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
Expand Down
44 changes: 25 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Python versions](https://img.shields.io/pypi/pyversions/derivatives_pricing)](https://pypi.org/project/derivatives-pricing/)

A Python package for options pricing and Greeks computation, with a unified API
across analytical, binomial tree, PDE, and Monte Carlo methods.
across analytical, binomial tree, finite difference and Monte Carlo methods.

Built for teaching, research, and production-adjacent workflows.

Expand All @@ -16,38 +16,42 @@ Built for teaching, research, and production-adjacent workflows.

### Pricing Coverage

| | Vanilla | | Asian | | Custom | |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| **Method** | **European** | **American** | **European** | **American** | **European** | **American** |
| BSM | ✅ | — | ✅ | — | — | — |
| Binomial | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| PDE | ✅ | ✅ | — | — | ✅ | ✅ |
| Monte Carlo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| | Vanilla | Asian | Barrier | Custom |
|---|:---:|:---:|:---:|:---:|
| BSM | E | E | E | — |
| Binomial | E/A | E/A | E/A | E/A |
| PDE_FD | E/A | — | E/A | E/A |
| Monte Carlo | E/A | E/A | E/A | E/A |

E = European, A = American

**Method details:** BSM uses closed-form Black-Scholes-Merton; Binomial uses Cox-Ross-Rubinstein trees;
PDE supports implicit, explicit, and Crank-Nicolson finite difference schemes;
PDE_FD uses implicit, explicit, and Crank-Nicolson finite difference schemes;
Monte Carlo uses Longstaff-Schwartz for American-style exercise.
Asian analytical pricing uses Turnbull-Wakeman (arithmetic) and Kemna-Vorst (geometric),
with Hull averaging on binomial trees.
with Hull averaging on binomial trees. Barrier pricing supports continuous and discrete monitoring,
knock-in and knock-out structures, and rebates.

---

### Additional Capabilities

- **Greeks** — analytical, tree, grid, pathwise, likelihood-ratio, and numerical bump-and-revalue (delta, gamma, vega, theta, rho)
- **Implied volatility** — Newton-Raphson, Bisection, and Brent solvers with arbitrage-bounds checking
- **Implied volatility** — Newton-Raphson, bisection, and Brent solvers with arbitrage-bounds checking
- **Stochastic processes** — Geometric Brownian Motion, Jump Diffusion (Merton), Square-Root Diffusion (CIR)
- **Discount curves** — log-linear interpolation on arbitrary term structures; deterministic time-varying forward rate and dividend curves
- **Discrete dividends** — supported across all pricing methods
- **Barrier options** — continuous and discrete monitoring, knock-in/knock-out, rebates (at-hit and at-expiry)
- **Control variates** — European analytical control variates for American pricing variance reduction
- **Custom payoffs** — user-defined payoff functions via `PayoffSpec`

---

## Why derivatives-pricing?

- Consistent API across analytical, tree, PDE, and Monte Carlo methods
- Consistent API across analytical, tree, finite difference, and Monte Carlo methods
- Designed for transparency and clarity of implementation
- Includes vanilla, Asian, barrier, and custom-payoff workflows behind the same facade
- Suitable for teaching, experimentation, research, and production-adjacent workflows
- Extensible architecture for new models and payoffs

Expand All @@ -59,7 +63,7 @@ Install from PyPI:

```bash
pip install derivatives-pricing
# or pip install derivatives-pricing[numba] for optional PDE solver acceleration
# or pip install derivatives-pricing[numba] for optional PDE_FD solver acceleration
```

For development:
Expand Down Expand Up @@ -103,15 +107,16 @@ print(f"{'Delta:':<8} {val.delta():>10.4f}")
The repo includes two companion directories:

- **`examples/`** — concise notebooks showing how to call the public API for each feature
(European options, Americans, PDE, Asian, Greeks, jump diffusion, discount curves).
(European options, Americans, barriers, PDE_FD, Asian, Greeks, jump diffusion, discount curves).
- **`tutorials/`** — deeper walkthroughs that teach the theory behind each pricing method
(BSM, binomial trees, finite differences, Monte Carlo, Asian averaging).
(BSM, binomial trees, finite differences, Monte Carlo, Asian averaging, barrier pricing).
Tutorials may access private/internal classes for demonstration purposes.

## Tests

```bash
pytest -q
# or pytest -q --runslow (to include slow tests)
```

## Project Structure
Expand All @@ -126,22 +131,23 @@ src/derivatives_pricing/
├── utils.py # Day-count, forward price, put-call parity
├── valuation/
│ ├── asian_analytical.py # Turnbull-Wakeman, Kemna-Vorst
│ ├── barrier_analytical.py # Analytical barrier pricing
│ ├── binomial.py # Cox-Ross-Rubinstein tree
│ ├── bsm.py # Closed-form Black-Scholes-Merton
│ ├── contracts.py # VanillaSpec, PayoffSpec, AsianSpec
│ ├── contracts.py # VanillaSpec, BarrierSpec, PayoffSpec, AsianSpec
│ ├── core.py # OptionValuation facade, UnderlyingData
│ ├── implied_volatility.py # IV solver
│ ├── monte_carlo.py # Monte Carlo with Longstaff-Schwartz
│ ├── monte_carlo.py # Monte Carlo with Longstaff-Schwartz and barrier pricing
│ ├── params.py # MonteCarloParams, BinomialParams, PDEParams
│ └── pde.py # Finite difference (implicit, explicit, Crank-Nicolson)
│ └── pde.py # Finite difference (implicit, explicit, Crank-Nicolson, barriers)
tests/ # Test suite
examples/ # API usage notebooks
tutorials/ # Theory deep-dive notebooks
```

## Roadmap

Planned: barrier options, stochastic volatility models.
Planned: stochastic volatility models.

Found a bug or have a feature request? [Open an issue](https://github.com/jsidhu06/derivatives-pricing/issues).

Expand Down
21 changes: 6 additions & 15 deletions src/derivatives_pricing/valuation/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
ExerciseType,
PricingMethod,
GreekCalculationMethod,
PDESpaceGrid,
)
from .monte_carlo import (
_MCEuropeanValuation,
Expand Down Expand Up @@ -823,16 +822,9 @@ def _resolve_params(
if pricing_method is PricingMethod.MONTE_CARLO:
return MonteCarloParams()
if pricing_method is PricingMethod.BINOMIAL:
# Barriers need finer grids for accurate barrier placement
return BinomialParams(num_steps=1000) if is_barrier else BinomialParams()
return BinomialParams.for_barriers() if is_barrier else BinomialParams()
if pricing_method is PricingMethod.PDE_FD:
if is_barrier:
return PDEParams(
spot_steps=2400,
time_steps=800,
space_grid=PDESpaceGrid.LOG_SPOT,
)
return PDEParams()
return PDEParams.for_barriers() if is_barrier else PDEParams()
return None

if pricing_method is PricingMethod.MONTE_CARLO:
Expand Down Expand Up @@ -929,14 +921,13 @@ def _apply_control_variate(self, base_pv: float) -> float:
"control_variate_european is only supported for BINOMIAL, PDE_FD, "
"and MONTE_CARLO pricing."
)
if not isinstance(self._spec, VanillaSpec):
if isinstance(self._spec, PayoffSpec):
raise UnsupportedFeatureError(
"Vanilla control_variate_european requires spec to be of type VanillaSpec. "
"PayoffSpec is not supported."
"control_variate_european is not supported for PayoffSpec."
)
if self._option_type not in (OptionType.CALL, OptionType.PUT):
raise UnsupportedFeatureError(
"Vanilla control_variate_european requires a CALL or PUT option type."
"control_variate_european requires a CALL or PUT option type."
)

euro_spec = dc_replace(self._spec, exercise_type=ExerciseType.EUROPEAN)
Expand Down Expand Up @@ -1145,7 +1136,7 @@ def _resolve_greek_method(
if rule is not None:
required_method, cap_flag, supported_greeks = rule
if self._pricing_method is not required_method:
raise UnsupportedFeatureError(
raise ValidationError(
f"{greek_calc_method.value.capitalize()} greeks are only available for "
f"{required_method.name} pricing method."
)
Expand Down
49 changes: 48 additions & 1 deletion src/derivatives_pricing/valuation/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
that explicitly documents the configuration options available for that method.
"""

from dataclasses import dataclass
from __future__ import annotations

from dataclasses import dataclass, replace as dc_replace
from typing import Any
import warnings

from ..enums import PDEEarlyExercise, PDEMethod, PDESpaceGrid
Expand Down Expand Up @@ -62,6 +65,11 @@ class MonteCarloParams:
barrier_aware_basis: bool = True

def __post_init__(self) -> None:
for name in ("deg", "min_itm"):
if type(getattr(self, name)) is not int:
raise ValidationError(
f"{name} must be an int, got {type(getattr(self, name)).__name__}"
)
if self.deg < 1:
raise ValidationError(f"deg must be >= 1, got {self.deg}")
if self.ridge_lambda < 0:
Expand Down Expand Up @@ -123,13 +131,35 @@ class BinomialParams:
control_variate_european: bool = False
log_timings: bool = False

@classmethod
def for_barriers(cls, **overrides: Any) -> BinomialParams:
"""Create params that mirror the library's internal barrier defaults.

Returns a ``BinomialParams`` instance with higher step count suitable
for barrier pricing. Any keyword argument accepted by the constructor
can be passed to override individual fields.
"""
defaults = cls(num_steps=1000)
return dc_replace(defaults, **overrides) if overrides else defaults

def __post_init__(self) -> None:
for name in ("num_steps",):
if type(getattr(self, name)) is not int:
raise ValidationError(
f"{name} must be an int, got {type(getattr(self, name)).__name__}"
)
if self.num_steps < 1:
raise ValidationError(f"num_steps must be >= 1, got {self.num_steps}")
if self.mc_paths is not None and self.asian_tree_averages is not None:
raise ValidationError(
"Only one of mc_paths and asian_tree_averages can be set, got both"
)
if self.mc_paths is not None and type(self.mc_paths) is not int:
raise ValidationError(f"mc_paths must be an int, got {type(self.mc_paths).__name__}")
if self.asian_tree_averages is not None and type(self.asian_tree_averages) is not int:
raise ValidationError(
f"asian_tree_averages must be an int, got {type(self.asian_tree_averages).__name__}"
)
if self.mc_paths is not None and self.mc_paths < 1:
raise ValidationError(f"mc_paths must be >= 1, got {self.mc_paths}")
if self.asian_tree_averages is not None and self.asian_tree_averages < 1:
Expand Down Expand Up @@ -217,7 +247,24 @@ class PDEParams:
control_variate_european: bool = False
log_timings: bool = False

@classmethod
def for_barriers(cls, **overrides: Any) -> PDEParams:
"""Create params that mirror the library's internal barrier defaults.

Returns a ``PDEParams`` instance with a finer grid and log-spot
spatial discretization suitable for barrier pricing. Any keyword
argument accepted by the constructor can be passed to override
individual fields.
"""
defaults = cls(spot_steps=2400, time_steps=800, space_grid=PDESpaceGrid.LOG_SPOT)
return dc_replace(defaults, **overrides) if overrides else defaults

def __post_init__(self) -> None:
for name in ("spot_steps", "time_steps", "max_iter", "rannacher_steps"):
if type(getattr(self, name)) is not int:
raise ValidationError(
f"{name} must be an int, got {type(getattr(self, name)).__name__}"
)
if self.smax_mult <= 0:
raise ValidationError(f"smax_mult must be positive, got {self.smax_mult}")
if self.spot_steps < 3:
Expand Down
21 changes: 14 additions & 7 deletions src/derivatives_pricing/valuation/pde.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,12 @@ def _build_log_grid(
lies exactly on an interior node *and* the resulting domain is a
(possibly slight) superset of ``[zmin_target, zmax_target]``. For
CN/IMPLICIT this is achieved by recomputing ``dz`` from the binding
half (left or right of the anchor); for explicit schemes ``dz`` is
fixed by Hull's stability heuristic, so the grid is shifted in place
while keeping strict cover of the target domain.
half (left or right of the anchor), i.e. the side that requires the
larger uniform ``dz`` to keep the anchor on-node while still covering
the target domain. The other side can then have up to roughly one cell
of slack. For explicit schemes ``dz`` is fixed by Hull's stability
heuristic, so the grid is shifted in place while keeping strict cover
of the target domain.
"""
smax = float(smax_mult * max(spot, strike))
smin = float(max(max(spot, strike) / smax_mult, 1.0e-8))
Expand Down Expand Up @@ -442,7 +445,9 @@ def _build_log_grid(
# ``grid_width = spot_steps * dz`` strictly larger than the
# target span (when not capped). dz is fixed by stability, so
# we shift the grid in place while keeping strict cover of
# ``[zmin_target, zmax_target]``.
# ``[zmin_target, zmax_target]``. If the target span is already
# capped exactly by ``spot_steps * dz``, exact anchoring is only
# possible when the anchor happens to lie on that fixed grid.
j_min = max(0, int(math.ceil((z_anchor - zmin_target) / dz - 1.0e-12)))
j_max = min(
spot_steps,
Expand All @@ -459,8 +464,10 @@ def _build_log_grid(
# grid (which forces an unsatisfiable strict-cover constraint
# when dz exactly tiles the target span), we *grow* dz on the
# binding half. Pick the integer node closest to where the
# anchor naturally falls, then size dz from whichever side is
# tighter. The result is a uniform grid that:
# anchor naturally falls, then compute the dz required to cover
# the left and right halves separately; whichever side requires
# the larger dz is the binding side, and the other side absorbs
# the slack. The result is a uniform grid that:
# - places the anchor exactly on an interior node,
# - is strictly tight to the target on the binding side,
# - has up to one cell of slack outside the target on the
Expand Down Expand Up @@ -2454,7 +2461,7 @@ def __init__(self, valuation_ctx: OptionValuation) -> None:
self.underlying = valuation_ctx.underlying # type: ignore[assignment]
self._spec: BarrierSpec = valuation_ctx.spec # type: ignore[assignment]
assert isinstance(valuation_ctx.params, PDEParams)
self.pde_params: PDEParams = valuation_ctx.params
self.pde_params = valuation_ctx.params

def _is_triggered_at_inception(self) -> bool:
spot = float(self.underlying.initial_value)
Expand Down
Loading
Loading