edgekit.options
Black-Scholes — European option pricing, the full first-order Greeks, and an implied-volatility solver, with a continuous dividend / carry yield q throughout. Enough to price, hedge, and back out vol without pulling in a derivatives library.
What's inside. The two normal-distribution primitives (norm_cdf, norm_pdf) the formulas are built on, the price itself (bs_price), the Greek bundle (bs_greeks), and a Newton/bisection implied-vol root-finder (implied_vol). Calls and puts are selected with kind="call" / "put"; the dividend yield q also serves as the foreign rate for FX or the convenience-adjusted carry for a future.
The Greeks are reported in raw Black-Scholes units, not the rescaled forms desks often quote:
- delta — change in option value per
$1move in spot. - gamma — change in delta per
$1move in spot (i.e. per$1²). - vega — per
1.00(100 vol-points) change in sigma; divide by 100 for the per-1%-vol figure traders usually quote. - theta — per year; divide by 365 for per-calendar-day decay.
- rho — per
1.00(100 bp × 100) change in the rate.
Distribution primitives#
norm_cdf#
Standard-normal cumulative distribution function — the N(·) in the Black-Scholes price. Vectorised over array input.
norm_cdf(x) -> float | np.ndarrayx— a scalar or array of standardised values.
Returns: the cumulative probability P(Z ≤ x), same shape as the input.
norm_pdf#
Standard-normal probability density function — the n(·) that appears in gamma and vega.
norm_pdf(x) -> float | np.ndarrayx— a scalar or array of standardised values.
Returns: the density at x, same shape as the input.
Pricing#
bs_price#
The Black-Scholes-Merton price of a European call or put with continuous yield q. The core valuation everything else in the module supports.
bs_price(S, K, t, r, sigma, kind="call", q=0.0) -> float| Param | Type | Default | Meaning |
|---|---|---|---|
S | float | — | Spot price of the underlying. |
K | float | — | Strike price. |
t | float | — | Time to expiry in years. |
r | float | — | Continuously-compounded risk-free rate. |
sigma | float | — | Volatility (annualised, e.g. 0.20 for 20%). |
kind | str | "call" | "call" or "put". |
q | float | 0.0 | Continuous dividend / carry yield. |
Returns: a float option premium in the underlying's currency.
import edgekit as ek
ek.options.bs_price(S=100, K=105, t=0.5, r=0.03, sigma=0.20, kind="call")Greeks & implied vol#
bs_greeks#
All five first-order Greeks in one call — delta, gamma, vega, theta, rho — for the same option as bs_price. Read the units callout above before you size a hedge off these.
bs_greeks(S, K, t, r, sigma, kind="call", q=0.0) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
S | float | — | Spot price. |
K | float | — | Strike. |
t | float | — | Time to expiry (years). |
r | float | — | Risk-free rate. |
sigma | float | — | Volatility. |
kind | str | "call" | "call" or "put". |
q | float | 0.0 | Dividend / carry yield. |
Returns: a dict with keys "delta" (per $1 spot), "gamma" (per $1² spot), "vega" (per 1.00 vol — ÷100 for per-1%), "theta" (per year — ÷365 for per-day), and "rho" (per 1.00 rate).
g = ek.options.bs_greeks(S=100, K=100, t=0.25, r=0.03, sigma=0.20)
g["delta"]
g["vega"] / 100 # per 1% vol move
g["theta"] / 365 # per calendar dayimplied_vol#
Back out the volatility that reprices a European option to an observed price — a Newton root-find with a bisection fallback. The inverse of bs_price: feed it a market premium, get the market's vol view.
implied_vol(price, S, K, t, r, kind="call", q=0.0, tol=1e-6, max_iter=100) -> float| Param | Type | Default | Meaning |
|---|---|---|---|
price | float | — | Observed option premium to match. |
S | float | — | Spot price. |
K | float | — | Strike. |
t | float | — | Time to expiry (years). |
r | float | — | Risk-free rate. |
kind | str | "call" | "call" or "put". |
q | float | 0.0 | Dividend / carry yield. |
tol | float | 1e-6 | Price convergence tolerance. |
max_iter | int | 100 | Maximum solver iterations. |
Returns: a float implied volatility. Returns nan when price is below intrinsic value (no real vol reproduces it).
iv = ek.options.implied_vol(price=6.20, S=100, K=100, t=0.25, r=0.03, kind="call")
# nan if price < max(S - K, 0) discounted (below intrinsic)Trees, jumps & the surface#
Beyond flat-vol Black-Scholes: binomial_tree prices American exercise, merton_price adds jumps, svi_fit / svi_iv parameterise a smile, delta_hedge_sim shows what hedging at the wrong vol actually costs, and variance_swap_strike turns a strip of quotes into the fair variance level.
binomial_tree#
A Cox-Ross-Rubinstein binomial tree — the workhorse for American options, where early exercise makes the closed form unavailable. With american=False it converges to bs_price as n grows, which is also the standard sanity check on the tree itself.
binomial_tree(s, k, t, r, sigma, n=500, kind="call", american=True) -> float| Param | Type | Default | Meaning |
|---|---|---|---|
s | float | — | Spot price. |
k | float | — | Strike. |
t | float | — | Time to expiry (years). |
r | float | — | Risk-free rate. |
sigma | float | — | Volatility. |
n | int | 500 | Tree steps (error shrinks ~1/n). |
kind | str | "call" | "call" or "put". |
american | bool | True | Allow early exercise at every node. |
Returns: a float option premium.
import edgekit as ek
am = ek.options.binomial_tree(s=100, k=105, t=0.5, r=0.03, sigma=0.20, kind="put")
eu = ek.options.binomial_tree(s=100, k=105, t=0.5, r=0.03, sigma=0.20, kind="put", american=False)
am - eu # the early-exercise premiummerton_price#
Merton's jump-diffusion price — a Poisson-weighted series of Black-Scholes prices, each conditioned on a number of jumps. Jumps are what a diffusion cannot fake: they produce the short-dated smile and price the crash risk flat-vol BS ignores. Truncated at n_terms terms (the Poisson weights die fast).
merton_price(s, k, t, r, sigma, lam, jump_mu, jump_sigma, kind="call", n_terms=40) -> float| Param | Type | Default | Meaning |
|---|---|---|---|
s | float | — | Spot price. |
k | float | — | Strike. |
t | float | — | Time to expiry (years). |
r | float | — | Risk-free rate. |
sigma | float | — | Diffusive volatility (between jumps). |
lam | float | — | Jump intensity (expected jumps per year). |
jump_mu | float | — | Mean log-jump size (negative = crash-shaped). |
jump_sigma | float | — | Log-jump size volatility. |
kind | str | "call" | "call" or "put". |
n_terms | int | 40 | Poisson series truncation. |
Returns: a float option premium (→ bs_price as lam → 0).
ek.options.merton_price(s=100, k=90, t=0.25, r=0.03, sigma=0.15,
lam=0.5, jump_mu=-0.10, jump_sigma=0.15, kind="put")
# downside puts price well above flat-vol BS — that's the jump premiumsvi_fit#
Fit the raw SVI (stochastic volatility inspired) parameterisation to an implied-vol smile — total variance w(k) = a + b·(rho·(k−m) + sqrt((k−m)² + sig²)) — via a built-in Nelder-Mead (pure numpy, no scipy). Five parameters give you a smooth, arbitrage-resistant curve through noisy strike quotes.
svi_fit(log_moneyness, iv, t) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
log_moneyness | array | — | log(K/F) per quote. |
iv | array | — | Implied vol per quote (e.g. from implied_vol). |
t | float | — | Time to expiry of the slice (years). |
Returns: a dict of the raw-SVI parameters "a", "b", "rho", "m", "sig" — feed it straight to svi_iv.
params = ek.options.svi_fit(k_log, ivs, t=0.25)svi_iv#
Evaluate a fitted SVI slice at any log-moneyness — the interpolator/extrapolator you quote off once svi_fit has parameterised the smile.
svi_iv(params, log_moneyness, t) -> np.ndarrayparams— the dict returned bysvi_fit.log_moneyness— the log(K/F) grid to evaluate at.t— the slice expiry (years).
Returns: a numpy array of implied vols on the grid.
import numpy as np
grid = np.linspace(-0.3, 0.3, 61)
smile = ek.options.svi_iv(params, grid, t=0.25) # the smooth fitted smiledelta_hedge_sim#
Simulate the P&L of a short option delta-hedged at sigma_hedge while the world actually moves at sigma_real — the cleanest demonstration that an option position is a volatilitybet: hedge at the true vol and the mean P&L is ~0 with residual discrete-hedging noise; sell at an implied above realized and the gap is your expected profit. Deterministic given a seeded rng.
delta_hedge_sim(s0, k, t, r, sigma_real, sigma_hedge, steps=52, n_paths=2000, kind="call", rng=None) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
s0 | float | — | Initial spot. |
k | float | — | Strike. |
t | float | — | Expiry (years). |
r | float | — | Risk-free rate. |
sigma_real | float | — | The vol the paths are generated with (realized). |
sigma_hedge | float | — | The vol the BS delta is computed at (implied). |
steps | int | 52 | Rehedge count over the option's life. |
n_paths | int | 2000 | Monte-Carlo paths. |
kind | str | "call" | "call" or "put". |
rng | Generator | None | None | numpy Generator; defaults to bootstrap_rng(). |
Returns: a dict with keys "pnl"(per-path P&L array), "mean", and "std".
sim = ek.options.delta_hedge_sim(s0=100, k=100, t=0.25, r=0.03,
sigma_real=0.18, sigma_hedge=0.22)
sim["mean"] # > 0: sold at 22 vol, world delivered 18 — the vol premium capturedvariance_swap_strike#
The fair strike of a variance swap by discrete static replication — a weighted strip of OTM options across strikes replicates the log contract, whose price is the risk-neutral expected variance. This is the machinery behind a VIX-style index, in one call.
variance_swap_strike(strikes, ivs, s, t, r=0.0) -> float| Param | Type | Default | Meaning |
|---|---|---|---|
strikes | array | — | The strike grid of the quote strip. |
ivs | array | — | Implied vol at each strike. |
s | float | — | Spot price. |
t | float | — | Expiry (years). |
r | float | 0.0 | Risk-free rate. |
Returns: a float — the fair variance strike K_var in vol² units (take sqrt for the vol-swap-style number).
kvar = ek.options.variance_swap_strike(strikes, ivs, s=100, t=30/365)
kvar ** 0.5 # the VIX-style vol level implied by the stripSee also#
- edgekit.timeseries — realized / EWMA / GARCH vol to compare against implied.
- edgekit.risk — tail-risk metrics for an options-overlaid book.