Skip to content

yipjunkai/pyvolr

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

84 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

pyvolr

PyPI Python versions Wheel OpenSSF Scorecard CI License

Modern Black-Scholes-Merton pricing, Greeks, and implied volatility for Python. Rust core. Vectorized. Drop-in replacement for the abandoned py_vollib.

from pyvolr import bs

bs.price("c", S=100, K=105, T=0.5, r=0.05, sigma=0.2) # 4.581680167540007

⚡ Performance

Time per call: pyvolr vs the active BSM-pricing ecosystem, log-log by array size Throughput: pyvolr vs the active BSM-pricing ecosystem, log-log by array size

Six libraries on the chart: pyvolr, vollib (resurrected upstream of py_vollib, pure Python), py_vollib_vectorized (numba), blackscholes (pure Python, object-per-call), QuantLib (C++ core, looped scalar), and quantforge (Rust + SIMD).

pyvolr leads the non-SIMD field at every batch size — ~2.4× faster than py_vollib_vectorized (numba), ~4× faster than QuantLib's looped scalar, 10×+ faster than the pure-Python libraries. quantforge (Rust + SIMD) is faster from ~1k strikes up, on the explicit-vectorisation axis fast-vollib takes with Triton kernels — a trade pyvolr deliberately skips. pyvolr is the "Rust-cored CPU option": no unsafe SIMD intrinsics, no GPU dependency, an abi3 wheel in one file, and ~1-ULP accuracy into the deep-OTM tail where quantforge and blackscholes underflow to zero (see Numerical agreement below). For raw batch throughput prefer quantforge; for a correct, dependency-light CPU pricer, pyvolr.

Scenario pyvolr py_vollib speedup
bs.price, scalar 4.2 µs 2.2 µs 0.5×
bs.price, 1k strikes 43.3 µs 2.32 ms 54×
bs.price, 10k strikes 350 µs 23.32 ms 67×
bs.price, 100k strikes 3.48 ms 234.91 ms 68×
bs.price, 1M strikes 34.10 ms 2,350 ms 69×
bs.greeks (all 5), 10k 273 µs 89.95 ms 330×
bs.implied_vol, scalar 4.4 µs 15.0 µs 3.4×
bs.implied_vol, 10k strikes 465 µs 128 ms ¹ 275×
black76.price, scalar 3.7 µs 2.2 µs 0.6×
black76.price, 10k strikes 346 µs 23.19 ms 67×
black76.implied_vol, scalar 3.9 µs 14.7 µs 3.8×

¹ py_vollib's implied_volatility is scalar-only; the 10k figure is N × scalar measured via compare_py_vollib.py. pyvolr's vectorised path parallelises automatically above N=1024 via rayon — set RAYON_NUM_THREADS=1 to force serial.

Pricing throughput note: the bs.price / black76.price rows use the normalised-Black engine — ~1-ULP accurate into the deep-OTM tail, ~2.3× slower on vectorised pricing than the prior textbook S·Φ(d1) − K·Φ(d2) form. A deliberate accuracy-for-speed trade; Greeks and IV are unaffected.

The table above is the headline-vs-the-abandoned-upstream comparison (py_vollib's last release is broken on Python 3.12+, see docs/why.md). For the workload most people actually run — a smile, an option chain, an IV snapshot — pyvolr is tens of times faster than the abandoned py_vollib upstream and installs cleanly on every modern Python.

bs.greeks returning all five Greeks at once uses a single-pass Rust kernel that shares d1/d2, discount factors, cdf, and pdf across the five outputs — ~3× faster than the equivalent five separate calls. For batches ≥4096 rows, the work also dispatches across CPU cores in parallel.

Numerical agreement: pyvolr matches every library above to f64 precision (~1e-13 relative) on every well-posed input across price + 5 Greeks + IV. At deep-OTM short-expiry corners pyvolr is more precise than the rest — blackscholes and quantforge underflow to zero where pyvolr's erfcx-based cdf retains the ~1e-50 price; QuantLib and the alternatives lose 1-2 digits. Run python bench/sanity_check_competitors.py in each venv to re-validate.

Reproduce the table with python bench/compare_py_vollib.py; reproduce the chart with python bench/compare_competitors.py bench then python bench/compare_competitors.py chart (across the Python 3.11 + 3.12 venvs documented in the script's docstring). Library versions: Apple M4 Pro / Python 3.10.20 / numpy 2.2.6 / pyvolr 0.1.4 / py_vollib 1.0.1 (table) / vollib 1.0.7 / py_vollib_vectorized 0.1.1 / blackscholes 0.2.0 / QuantLib 1.42.1 / quantforge 0.1.1 (chart).

📦 Install

pip install pyvolr

Or via uv:

uv pip install pyvolr

Pre-built wheels are published for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows (x86_64) across Python 3.10–3.14, plus a free-threaded build for 3.14t. (3.13t wheels were last published at pyvolr 0.1.3 — cibuildwheel 4 dropped Python 3.13 free-threading, which never left experimental status.)

Tested on

3.10 3.11 3.12 3.13 3.14
Linux
macOS
Windows

Every push and PR runs the full pytest + cargo test suites across the matrix above. Windows × {3.10, 3.11} are skipped intentionally to keep CI minutes reasonable — the wheels themselves still build for those combinations and are published. The free-threaded wheel (3.14t) is built and exercised through cibuildwheel's in-wheel test pass on every release across Linux/macOS/Windows, and on packaging-touching PRs via the wheel-smoke check.

From source (requires Rust):

git clone https://github.com/yipjunkai/pyvolr
cd pyvolr
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -e ".[dev,test]"
maturin develop --release

🚀 Quick start

import numpy as np
from pyvolr import bs

# Scalar
bs.price("c", S=100, K=105, T=0.5, r=0.05, sigma=0.2)

# Vectorized — broadcast over any combination of inputs
strikes = np.linspace(80, 120, 41)
prices = bs.price("c", S=100, K=strikes, T=0.5, r=0.05, sigma=0.2)

# All five Greeks in one call
greeks = bs.greeks("c", S=100, K=strikes, T=0.5, r=0.05, sigma=0.2)
# {"delta": [...], "gamma": [...], "theta": [...], "vega": [...], "rho": [...]}

# Implied volatility from a market price
bs.implied_vol(price=5.20, flag="c", S=100, K=100, T=0.25, r=0.05)

# Broadcasting works in any dimension
strike_grid = np.linspace(80, 120, 5).reshape(-1, 1)
vol_grid = np.linspace(0.10, 0.40, 4).reshape(1, -1)
surface = bs.price("c", S=100, K=strike_grid, T=0.5, r=0.05, sigma=vol_grid)
# shape (5, 4)

# Black-76 for options on futures / forwards — same API, F replaces S, no q.
from pyvolr import black76
black76.price("c", F=100, K=105, T=0.5, r=0.05, sigma=0.2)

✨ Features

  • Black-Scholes-Merton pricing — calls and puts with continuous dividend yield
  • Black-76 pricing — European options on futures/forwards (pyvolr.black76), same vectorized API as bs
  • Analytical Greeks — delta, gamma, theta, vega, rho (with documented sign and unit conventions)
  • Robust implied volatility — Jäckel "Let's Be Rational" algorithm: rational-cubic initial guess plus Householder order-4 iteration converges to ~1e-13 precision in ≤2 iterations across the full no-arbitrage range
  • Automatic parallelism on large batchesimplied_vol (above N≈1,000 rows) and the bundled greeks kernel (above N≈4,000) release the GIL and dispatch per-row work to rayon's global thread pool; set RAYON_NUM_THREADS=1 to opt out
  • Full numpy broadcasting — any combination of inputs in any shape, scalar-in scalar-out
  • py_vollib drop-in shimpyvolr.compat.py_vollib mirrors the upstream module tree (including py_vollib.black) for one-import-line migration
  • Rust core, no compiler needed — abi3 wheels for Python 3.10–3.14 × {Linux, macOS, Windows}
  • Free-threaded Python ready — a dedicated 3.14t wheel: with no GIL, every entry point scales across threads. On standard (GIL) builds, the large-batch implied_vol (≥1k rows) and bundled greeks (≥4k rows) kernels release the GIL while rayon works; price and single-Greek calls hold it
  • Typed end-to-end — pyright-strict library code, full type stubs for the Rust extension

🗺️ Coming soon

  • Drop-in compat shim for py_vollib_vectorized (vectorized_* API + price_dataframe/get_all_greeks, pandas as soft dep)
  • Bachelier (normal model, for negative rates)
  • Higher-order Greeks (vanna, vomma, charm, speed, zomma, color)
  • SIMD batch evaluation
  • American options (CRR binomial → finite difference)
  • Volatility surface fitting (SVI, SSVI)

🔄 Migrating from py_vollib

Replace your imports — the signatures and 'c'/'p' flag convention are preserved exactly:

# Before
from py_vollib.black_scholes import black_scholes
from py_vollib.black_scholes.greeks.analytical import delta
from py_vollib.black_scholes.implied_volatility import implied_volatility
from py_vollib.black import black  # futures options

# After
from pyvolr.compat.py_vollib.black_scholes import black_scholes
from pyvolr.compat.py_vollib.black_scholes.greeks.analytical import delta
from pyvolr.compat.py_vollib.black_scholes.implied_volatility import implied_volatility
from pyvolr.compat.py_vollib.black import black  # futures options

The compat shim also preserves py_vollib's unit conventions: vega is per-1% vol, theta is per-day, rho is per-1% rate, and implied_volatility takes flag as its last argument. For new code, prefer the modern pyvolr.bs API — it accepts numpy arrays, broadcasts naturally, uses per-unit conventions consistently, and returns all Greeks in a single call.

🤔 Why pyvolr exists

py_vollib has been broken on Python 3.12+ since the release — a transitive dependency imports DBL_MIN / DBL_MAX from CPython's internal _testcapi test module, which isn't shipped with modern Python distributions. The fix is two lines (sys.float_info.{min,max} are the correct sources), but py_lets_be_rational hasn't released since 2017, py_vollib since 2020, and the maintainers are gone.

Full backstory: docs/why.md.

📁 Project structure

pyvolr/
├── crates/core/             # Rust numerical core
│   ├── src/
│   │   ├── lib.rs           # PyO3 bindings (flat-array entry points)
│   │   ├── bsm.rs           # BSM pricing, d1/d2, forward price
│   │   ├── black76.rs       # Black-76 (futures options) — delegates to BSM with q=r
│   │   ├── greeks.rs        # Delta, gamma, theta, vega, rho
│   │   ├── iv.rs            # Jäckel "Let's Be Rational" IV solver (Householder-4, ≤2 iters)
│   │   └── normal.rs        # Φ / φ, erfcx (Lentz CF), inverse CDF (Wichura AS241)
│   └── benches/             # criterion: perf-gate contracts (pricing) + experiment harness (experiments)
├── bench/                   # Python-level speed/precision scripts (dev-only, not in CI)
│   ├── compare_py_vollib.py            # reproduces the perf table
│   ├── compare_competitors.py          # reproduces the perf chart (6 libraries)
│   └── sanity_check_competitors.py     # cross-validates numerical agreement
├── python/pyvolr/
│   ├── bs.py                # BSM public API (numpy-broadcast wrappers)
│   ├── black76.py           # Black-76 public API
│   ├── _wrappers.py         # Shared FFI helpers (broadcast, flag normalize)
│   ├── _core.pyi            # Type stubs for the Rust extension
│   └── compat/py_vollib/    # Drop-in shim mirroring py_vollib's tree
├── tests/                   # pytest + hypothesis property tests
├── .github/workflows/       # ci, release, release-please, differential, fuzz, perf, security, scorecard, stale
├── .github/scripts/         # CI helper scripts (perf-gate comparator)
├── Cargo.toml               # Rust workspace
└── pyproject.toml           # maturin build backend + project config

📚 API reference

Function Returns Vectorized over
bs.price(flag, S, K, T, r, sigma, q=0) option price all numeric inputs
bs.delta(flag, S, K, T, r, sigma, q=0) ∂Price/∂S all numeric inputs
bs.gamma(S, K, T, r, sigma, q=0) ∂²Price/∂S² all numeric inputs
bs.vega(S, K, T, r, sigma, q=0) ∂Price/∂σ (per unit vol) all numeric inputs
bs.theta(flag, S, K, T, r, sigma, q=0) −∂Price/∂T (per year) all numeric inputs
bs.rho(flag, S, K, T, r, sigma, q=0) ∂Price/∂r (per unit r) all numeric inputs
bs.greeks(flag, S, K, T, r, sigma, q=0) dict of all five Greeks all numeric inputs
bs.implied_vol(price, flag, S, K, T, r, q=0) σ (NaN on bound violation) price + numeric inputs
black76.price(flag, F, K, T, r, sigma) option price on a forward all numeric inputs
black76.{delta,gamma,vega,theta,rho}(...) Greeks for Black-76 all numeric inputs
black76.greeks(flag, F, K, T, r, sigma) dict of all five Greeks all numeric inputs
black76.implied_vol(price, flag, F, K, T, r) σ (NaN on bound violation) price + numeric inputs
pyvolr.compat.py_vollib.… py_vollib-shaped scalars n/a (scalar API)

flag accepts 'c'/'C' (call), 'p'/'P' (put), or an array thereof.

🛡️ Sustainability

py_vollib died because nobody was paid to maintain it. pyvolr is engineered to outlive its maintainer:

  • One-click releases via release-please + PyPI Trusted Publishing — PyPI publication needs no stored credentials (OIDC), and release-please authenticates as a repo-scoped GitHub App rather than a user PAT, so the credential survives a maintainer handoff
  • Release-gated differential tests against py_vollib (Python 3.10 sidecar) — every release is blocked unless pyvolr still matches the reference
  • Wide CI matrix (Python 3.10–3.14 × Linux/macOS/Windows) — the specific failure mode that killed the predecessor
  • All GitHub Actions pinned with weekly Dependabot bumps, hardening against supply-chain attacks
  • Hand-off plan documented in GOVERNANCE.md

Commercial sponsorship channels will be added if demand warrants. For now the best support is real-world use, good bug reports, and PRs.

🤝 Contributing

See CONTRIBUTING.md. Particularly welcome: new pricing models (Bachelier, American), higher-order Greeks, SIMD/vectorization work, and property tests for edge cases.

📄 License

Dual-licensed under MIT or Apache 2.0, at your option.

Algorithms are reimplemented from published references (Hull, Merton, Jäckel); no third-party source code is incorporated.

About

Modern Black-Scholes-Merton pricing, Greeks, and implied volatility for Python. Rust core. Drop-in replacement for the abandoned py_vollib.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors