Skip to content
python 3.11+ powered by JAX license MIT

Options

See the Options guide for a narrative walkthrough.

Pricing

pricing

Option pricing in pure JAX.

The Black-Scholes-Merton price is a plain differentiable function, so the Greeks module gets every sensitivity from jax.grad without duplicating formulas. A jitted Newton solver recovers implied volatility, and a binomial (Cox-Ross- Rubinstein) lattice prices American-style options.

Convention: +1 for calls, -1 for puts (is_call boolean helpers are provided for readability at call sites).

black_scholes_price

black_scholes_price(
    spot: Array,
    strike: Array,
    ttm: Array,
    vol: Array,
    rate: Array = 0.0,
    div: Array = 0.0,
    is_call: bool = True,
) -> Array

Black-Scholes-Merton price of a European option.

All array arguments broadcast, so this prices whole option chains at once.

Parameters:

Name Type Description Default
spot Array

Underlying price and strike.

required
strike Array

Underlying price and strike.

required
ttm Array

Time to maturity in years.

required
vol Array

Annualized volatility.

required
rate Array

Continuous risk-free rate and dividend yield.

0.0
div Array

Continuous risk-free rate and dividend yield.

0.0
is_call bool

True for a call, False for a put.

True
Source code in src/jaxfolio/options/pricing.py
def black_scholes_price(
    spot: Array,
    strike: Array,
    ttm: Array,
    vol: Array,
    rate: Array = 0.0,
    div: Array = 0.0,
    is_call: bool = True,
) -> Array:
    """Black-Scholes-Merton price of a European option.

    All array arguments broadcast, so this prices whole option chains at once.

    Parameters
    ----------
    spot, strike:
        Underlying price and strike.
    ttm:
        Time to maturity in years.
    vol:
        Annualized volatility.
    rate, div:
        Continuous risk-free rate and dividend yield.
    is_call:
        ``True`` for a call, ``False`` for a put.
    """
    d1, d2 = _d1_d2(spot, strike, ttm, vol, rate, div)
    disc = jnp.exp(-rate * ttm)
    carry = jnp.exp(-div * ttm)
    if is_call:
        return spot * carry * norm.cdf(d1) - strike * disc * norm.cdf(d2)
    return strike * disc * norm.cdf(-d2) - spot * carry * norm.cdf(-d1)

price_call_chain

price_call_chain(
    spot: float,
    strikes: Array,
    ttms: Array,
    vols: Array,
    rate: float = 0.0,
    div: float = 0.0,
) -> Array

Vectorized call prices across a chain of (strike, ttm, vol) triples.

ttms and vols may be scalars (broadcast to every strike) or arrays aligned with strikes.

Source code in src/jaxfolio/options/pricing.py
def price_call_chain(
    spot: float, strikes: Array, ttms: Array, vols: Array, rate: float = 0.0, div: float = 0.0
) -> Array:
    """Vectorized call prices across a chain of (strike, ttm, vol) triples.

    ``ttms`` and ``vols`` may be scalars (broadcast to every strike) or arrays
    aligned with ``strikes``.
    """
    strikes = jnp.asarray(strikes, dtype=float)
    ttms = jnp.broadcast_to(jnp.asarray(ttms, dtype=float), strikes.shape)
    vols = jnp.broadcast_to(jnp.asarray(vols, dtype=float), strikes.shape)
    return _price_over_chain(spot, strikes, ttms, vols, rate, div)

implied_volatility

implied_volatility(
    price: float,
    spot: float,
    strike: float,
    ttm: float,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
    *,
    initial_vol: float = 0.2,
    max_iter: int = 50,
    tol: float = 1e-08,
) -> Array

Recover implied volatility via jitted Newton iterations.

Vega (the derivative of price w.r.t. vol) comes from jax.grad on the pricer, so the Newton step needs no hand-coded derivative. Returns NaN when no volatility reproduces price to within tol (e.g. a price below intrinsic value or a deep-OTM option with vanishing vega), so callers can distinguish "no solution" from a genuinely tiny implied vol.

Source code in src/jaxfolio/options/pricing.py
def implied_volatility(
    price: float,
    spot: float,
    strike: float,
    ttm: float,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
    *,
    initial_vol: float = 0.2,
    max_iter: int = 50,
    tol: float = 1e-8,
) -> Array:
    """Recover implied volatility via jitted Newton iterations.

    Vega (the derivative of price w.r.t. vol) comes from ``jax.grad`` on the
    pricer, so the Newton step needs no hand-coded derivative. Returns ``NaN``
    when no volatility reproduces ``price`` to within ``tol`` (e.g. a price below
    intrinsic value or a deep-OTM option with vanishing vega), so callers can
    distinguish "no solution" from a genuinely tiny implied vol.
    """
    price_of_vol = lambda v: black_scholes_price(spot, strike, ttm, v, rate, div, is_call)
    vega_of_vol = jax.grad(price_of_vol)

    def cond(state):
        i, v, diff = state
        return jnp.logical_and(i < max_iter, jnp.abs(diff) > tol)

    def body(state):
        i, v, _ = state
        diff = price_of_vol(v) - price
        vega = vega_of_vol(v)
        vega = jnp.where(jnp.abs(vega) < 1e-8, 1e-8, vega)
        v_new = jnp.clip(v - diff / vega, 1e-4, 10.0)
        return (i + 1, v_new, v_new - v)

    _, vol, _ = jax.lax.while_loop(cond, body, (0, jnp.asarray(initial_vol), jnp.asarray(jnp.inf)))
    # Flag non-convergence: the recovered vol must actually reprice the option.
    residual = jnp.abs(price_of_vol(vol) - price)
    return jnp.where(residual <= jnp.sqrt(tol) + 1e-6, vol, jnp.nan)

binomial_american

binomial_american(
    spot: float,
    strike: float,
    ttm: float,
    vol: float,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
    *,
    steps: int = 256,
    dividends: list[tuple[float, float]] | None = None,
) -> Array

Cox-Ross-Rubinstein binomial price for an American-style option.

Backward induction with early-exercise checks at every node. steps controls the lattice resolution.

Parameters:

Name Type Description Default
div float

Continuous dividend yield (always supported).

0.0
dividends list[tuple[float, float]] | None

Optional schedule of discrete cash dividends as (time, amount) pairs (time in years from now, 0 < time <= ttm). Priced with the escrowed-dividend model: the lattice is built on the spot net of the present value of dividends paid before expiry, and the present value of not-yet-paid dividends is added back at each node for the early-exercise decision. Composes with the continuous div yield. When None (the default) the fast pure-JAX lattice is used and behavior is unchanged.

None
Source code in src/jaxfolio/options/pricing.py
def binomial_american(
    spot: float,
    strike: float,
    ttm: float,
    vol: float,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
    *,
    steps: int = 256,
    dividends: list[tuple[float, float]] | None = None,
) -> Array:
    """Cox-Ross-Rubinstein binomial price for an American-style option.

    Backward induction with early-exercise checks at every node. ``steps``
    controls the lattice resolution.

    Parameters
    ----------
    div:
        Continuous dividend *yield* (always supported).
    dividends:
        Optional schedule of **discrete cash dividends** as ``(time, amount)``
        pairs (time in years from now, ``0 < time <= ttm``). Priced with the
        escrowed-dividend model: the lattice is built on the spot net of the
        present value of dividends paid before expiry, and the present value of
        not-yet-paid dividends is added back at each node for the early-exercise
        decision. Composes with the continuous ``div`` yield. When ``None`` (the
        default) the fast pure-JAX lattice is used and behavior is unchanged.
    """
    if dividends:
        return _binomial_american_discrete_div(
            spot, strike, ttm, vol, rate, div, is_call, steps, dividends
        )
    dt = ttm / steps
    u = jnp.exp(vol * jnp.sqrt(dt))
    d = 1.0 / u
    disc = jnp.exp(-rate * dt)
    p = (jnp.exp((rate - div) * dt) - d) / (u - d)
    p = jnp.clip(p, 0.0, 1.0)
    sign = 1.0 if is_call else -1.0

    j = jnp.arange(steps + 1)
    # Terminal underlying prices and payoffs at maturity (fixed-length buffer).
    terminal_prices = spot * u**j * d ** (steps - j)
    values = jnp.maximum(sign * (terminal_prices - strike), 0.0)

    def body(i, values):
        # Rolling back to layer m = steps - i, which has m + 1 live nodes (0..m).
        m = steps - i
        node_prices = spot * u**j * d ** (m - j)  # valid for j <= m; tail is unused
        # cont[j] = disc * (p * values[j+1] + (1-p) * values[j]); roll gives values[j+1].
        up = jnp.roll(values, -1)
        cont = disc * (p * up + (1.0 - p) * values)
        exercise = jnp.maximum(sign * (node_prices - strike), 0.0)
        return jnp.maximum(cont, exercise)

    values = jax.lax.fori_loop(1, steps + 1, body, values)
    return values[0]

Greeks

greeks

Option Greeks via JAX automatic differentiation.

Rather than re-deriving closed-form Greek formulas (and risking them drifting out of sync with the pricer), we differentiate :func:black_scholes_price directly:

  • delta = d price / d spot
  • gamma = d^2 price / d spot^2
  • vega = d price / d vol
  • theta = -d price / d ttm (per-year; divide by 365 for per-calendar-day)
  • rho = d price / d rate

All are vmap-friendly, so :func:chain_greeks returns every Greek across a whole option chain in one call.

delta

delta(
    spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True
) -> Array

Delta: sensitivity of price to the underlying spot.

Source code in src/jaxfolio/options/greeks.py
def delta(spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True) -> Array:
    """Delta: sensitivity of price to the underlying spot."""
    f = lambda s: _price(s, strike, ttm, vol, rate, div, is_call)
    return jax.grad(f)(jnp.asarray(float(spot)))

gamma

gamma(
    spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True
) -> Array

Gamma: second derivative of price w.r.t. spot (same for calls and puts).

Source code in src/jaxfolio/options/greeks.py
def gamma(spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True) -> Array:
    """Gamma: second derivative of price w.r.t. spot (same for calls and puts)."""
    f = lambda s: _price(s, strike, ttm, vol, rate, div, is_call)
    return jax.grad(jax.grad(f))(jnp.asarray(float(spot)))

vega

vega(
    spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True
) -> Array

Vega: sensitivity of price to volatility (per 1.00 change in vol).

Source code in src/jaxfolio/options/greeks.py
def vega(spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True) -> Array:
    """Vega: sensitivity of price to volatility (per 1.00 change in vol)."""
    f = lambda v: _price(spot, strike, ttm, v, rate, div, is_call)
    return jax.grad(f)(jnp.asarray(float(vol)))

theta

theta(
    spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True
) -> Array

Theta: time decay (per year). Negative for most long options.

Source code in src/jaxfolio/options/greeks.py
def theta(spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True) -> Array:
    """Theta: time decay (per year). Negative for most long options."""
    f = lambda t: _price(spot, strike, t, vol, rate, div, is_call)
    return -jax.grad(f)(jnp.asarray(float(ttm)))

rho

rho(
    spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True
) -> Array

Rho: sensitivity of price to the risk-free rate.

Source code in src/jaxfolio/options/greeks.py
def rho(spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True) -> Array:
    """Rho: sensitivity of price to the risk-free rate."""
    f = lambda r: _price(spot, strike, ttm, vol, r, div, is_call)
    return jax.grad(f)(jnp.asarray(float(rate)))

all_greeks

all_greeks(
    spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True
) -> dict[str, float]

Return every first/second-order Greek for a single option as a dict.

Source code in src/jaxfolio/options/greeks.py
def all_greeks(spot, strike, ttm, vol, rate=0.0, div=0.0, is_call=True) -> dict[str, float]:
    """Return every first/second-order Greek for a single option as a dict."""
    return {
        "price": float(_price(spot, strike, ttm, vol, rate, div, is_call)),
        "delta": float(delta(spot, strike, ttm, vol, rate, div, is_call)),
        "gamma": float(gamma(spot, strike, ttm, vol, rate, div, is_call)),
        "vega": float(vega(spot, strike, ttm, vol, rate, div, is_call)),
        "theta": float(theta(spot, strike, ttm, vol, rate, div, is_call)),
        "rho": float(rho(spot, strike, ttm, vol, rate, div, is_call)),
    }

chain_greeks

chain_greeks(
    spot: float,
    strikes: Array,
    ttms: Array,
    vols: Array,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
) -> dict[str, Array]

Vectorized Greeks across a chain of (strike, ttm, vol) triples.

Returns a dict of arrays (one entry per Greek), each aligned with the input chain. Built from vmap over the single-option autodiff Greeks.

Source code in src/jaxfolio/options/greeks.py
def chain_greeks(
    spot: float,
    strikes: Array,
    ttms: Array,
    vols: Array,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
) -> dict[str, Array]:
    """Vectorized Greeks across a chain of (strike, ttm, vol) triples.

    Returns a dict of arrays (one entry per Greek), each aligned with the input
    chain. Built from ``vmap`` over the single-option autodiff Greeks.
    """
    strikes = jnp.asarray(strikes, dtype=float)
    ttms = jnp.broadcast_to(jnp.asarray(ttms, dtype=float), strikes.shape)
    vols = jnp.broadcast_to(jnp.asarray(vols, dtype=float), strikes.shape)

    # argnums: 0=spot, 2=ttm, 3=vol, 4=rate.
    price_fn = lambda s, k, t, v, r: _price(s, k, t, v, r, div, is_call)

    d_delta = jax.vmap(lambda k, t, v: jax.grad(price_fn, argnums=0)(spot, k, t, v, rate))
    d_gamma = jax.vmap(
        lambda k, t, v: jax.grad(jax.grad(price_fn, argnums=0), argnums=0)(spot, k, t, v, rate)
    )
    d_vega = jax.vmap(lambda k, t, v: jax.grad(price_fn, argnums=3)(spot, k, t, v, rate))
    d_theta = jax.vmap(lambda k, t, v: -jax.grad(price_fn, argnums=2)(spot, k, t, v, rate))
    d_rho = jax.vmap(lambda k, t, v: jax.grad(price_fn, argnums=4)(spot, k, t, v, rate))
    d_price = jax.vmap(lambda k, t, v: price_fn(spot, k, t, v, rate))

    return {
        "price": d_price(strikes, ttms, vols),
        "delta": d_delta(strikes, ttms, vols),
        "gamma": d_gamma(strikes, ttms, vols),
        "vega": d_vega(strikes, ttms, vols),
        "theta": d_theta(strikes, ttms, vols),
        "rho": d_rho(strikes, ttms, vols),
    }

Strategies

strategies

Multi-leg options strategies: payoffs, P&L, and Greeks.

An :class:OptionLeg describes a single position (call/put, long/short, strike, expiry, quantity). An :class:OptionStrategy is a collection of legs (optionally plus a stock leg) with helpers to evaluate payoff at expiry, mark-to-model P&L at a horizon, net Greeks, and the break-even points. A library of preset constructors builds the common structures (covered call, collar, spreads, straddle, iron condor, butterfly, calendar).

OptionLeg dataclass

OptionLeg(
    kind: str,
    strike: float,
    quantity: float = 1.0,
    expiry: float = 0.25,
    premium: float | None = None,
)

A single option position.

Attributes:

Name Type Description
kind str

"call" or "put".

strike float

Strike price.

quantity float

Signed contract count — positive = long, negative = short.

expiry float

Time to expiry in years (used for mark-to-model and Greeks).

premium float | None

Premium paid (long) or received (short) per contract. If None it is filled from the Black-Scholes price at construction time by the presets.

intrinsic
intrinsic(spot: ndarray) -> ndarray

Intrinsic value of the option at spot (per contract, unsigned).

Source code in src/jaxfolio/options/strategies.py
def intrinsic(self, spot: np.ndarray) -> np.ndarray:
    """Intrinsic value of the option at ``spot`` (per contract, unsigned)."""
    if self.is_call:
        return np.maximum(spot - self.strike, 0.0)
    return np.maximum(self.strike - spot, 0.0)
payoff_at_expiry
payoff_at_expiry(spot: ndarray) -> ndarray

Signed P&L of this leg at expiry, net of premium.

Source code in src/jaxfolio/options/strategies.py
def payoff_at_expiry(self, spot: np.ndarray) -> np.ndarray:
    """Signed P&L of this leg at expiry, net of premium."""
    prem = self.premium if self.premium is not None else 0.0
    return self.quantity * (self.intrinsic(spot) - prem)

StockLeg dataclass

StockLeg(quantity: float = 1.0, entry_price: float = 100.0)

A linear underlying position held alongside options (e.g. covered call).

OptionStrategy dataclass

OptionStrategy(
    name: str,
    legs: list[OptionLeg] = list(),
    stock: StockLeg | None = None,
)

A named collection of option (and optional stock) legs.

payoff_at_expiry
payoff_at_expiry(spot: ndarray) -> ndarray

Total strategy P&L at expiry across a grid of terminal spot prices.

Source code in src/jaxfolio/options/strategies.py
def payoff_at_expiry(self, spot: np.ndarray) -> np.ndarray:
    """Total strategy P&L at expiry across a grid of terminal spot prices."""
    spot = np.asarray(spot, dtype=float)
    total = np.zeros_like(spot)
    for leg in self.legs:
        total = total + leg.payoff_at_expiry(spot)
    if self.stock is not None:
        total = total + self.stock.payoff_at_expiry(spot)
    return total
net_premium
net_premium() -> float

Net premium: negative = net debit (paid), positive = net credit.

Source code in src/jaxfolio/options/strategies.py
def net_premium(self) -> float:
    """Net premium: negative = net debit (paid), positive = net credit."""
    return float(-sum(leg.quantity * (leg.premium or 0.0) for leg in self.legs))
value_at
value_at(
    spot: float,
    vol: float,
    rate: float = 0.0,
    div: float = 0.0,
    ttm_shift: float = 0.0,
) -> float

Mark-to-model value of all option legs at a horizon.

ttm_shift is subtracted from each leg's expiry to advance time (e.g. ttm_shift=1/12 marks the book one month forward).

Source code in src/jaxfolio/options/strategies.py
def value_at(
    self, spot: float, vol: float, rate: float = 0.0, div: float = 0.0, ttm_shift: float = 0.0
) -> float:
    """Mark-to-model value of all option legs at a horizon.

    ``ttm_shift`` is subtracted from each leg's expiry to advance time (e.g.
    ``ttm_shift=1/12`` marks the book one month forward).
    """
    total = 0.0
    for leg in self.legs:
        t = max(leg.expiry - ttm_shift, 1e-6)
        price = float(black_scholes_price(spot, leg.strike, t, vol, rate, div, leg.is_call))
        total += leg.quantity * price
    if self.stock is not None:
        total += self.stock.quantity * spot
    return total
pnl_at
pnl_at(
    spot: float,
    vol: float,
    rate: float = 0.0,
    div: float = 0.0,
    ttm_shift: float = 0.0,
) -> float

Mark-to-model P&L relative to the entry cost of the option legs.

Source code in src/jaxfolio/options/strategies.py
def pnl_at(
    self, spot: float, vol: float, rate: float = 0.0, div: float = 0.0, ttm_shift: float = 0.0
) -> float:
    """Mark-to-model P&L relative to the entry cost of the option legs."""
    entry = sum(leg.quantity * (leg.premium or 0.0) for leg in self.legs)
    if self.stock is not None:
        entry += self.stock.quantity * self.stock.entry_price
    return self.value_at(spot, vol, rate, div, ttm_shift) - entry
greeks
greeks(
    spot: float,
    vol: float,
    rate: float = 0.0,
    div: float = 0.0,
) -> dict

Net position Greeks (sum of per-leg Greeks weighted by quantity).

Source code in src/jaxfolio/options/strategies.py
def greeks(self, spot: float, vol: float, rate: float = 0.0, div: float = 0.0) -> dict:
    """Net position Greeks (sum of per-leg Greeks weighted by quantity)."""
    agg = {"delta": 0.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0}
    for leg in self.legs:
        g = all_greeks(spot, leg.strike, leg.expiry, vol, rate, div, leg.is_call)
        for k in agg:
            agg[k] += leg.quantity * g[k]
    if self.stock is not None:
        agg["delta"] += self.stock.quantity  # stock has unit delta
    return agg
break_evens
break_evens(
    spot_grid: ndarray | None = None,
) -> list[float]

Approximate break-even spot prices (payoff sign changes).

Source code in src/jaxfolio/options/strategies.py
def break_evens(self, spot_grid: np.ndarray | None = None) -> list[float]:
    """Approximate break-even spot prices (payoff sign changes)."""
    if spot_grid is None:
        strikes = [leg.strike for leg in self.legs]
        lo, hi = 0.5 * min(strikes), 1.5 * max(strikes)
        spot_grid = np.linspace(lo, hi, 2000)
    pnl = self.payoff_at_expiry(spot_grid)
    sign = np.sign(pnl)
    crossings = np.where(np.diff(sign) != 0)[0]
    # Linear interpolation at each zero crossing.
    bes = []
    for i in crossings:
        x0, x1 = spot_grid[i], spot_grid[i + 1]
        y0, y1 = pnl[i], pnl[i + 1]
        if y1 != y0:
            bes.append(float(x0 - y0 * (x1 - x0) / (y1 - y0)))
    return bes

covered_call

covered_call(
    spot: float,
    strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long stock + short call: income overlay that caps upside.

Source code in src/jaxfolio/options/strategies.py
def covered_call(
    spot: float, strike: float, *, expiry: float = 0.25, vol: float = 0.25, rate: float = 0.0
) -> OptionStrategy:
    """Long stock + short call: income overlay that caps upside."""
    leg = _priced_leg("call", strike, -1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Covered Call", [leg], StockLeg(1.0, spot))

protective_put

protective_put(
    spot: float,
    strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long stock + long put: downside insurance.

Source code in src/jaxfolio/options/strategies.py
def protective_put(
    spot: float, strike: float, *, expiry: float = 0.25, vol: float = 0.25, rate: float = 0.0
) -> OptionStrategy:
    """Long stock + long put: downside insurance."""
    leg = _priced_leg("put", strike, 1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Protective Put", [leg], StockLeg(1.0, spot))

collar

collar(
    spot: float,
    put_strike: float,
    call_strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long stock + long put + short call: bounded payoff, often near-zero cost.

Source code in src/jaxfolio/options/strategies.py
def collar(
    spot: float,
    put_strike: float,
    call_strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Long stock + long put + short call: bounded payoff, often near-zero cost."""
    put = _priced_leg("put", put_strike, 1.0, expiry, spot, vol, rate, 0.0)
    call = _priced_leg("call", call_strike, -1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Collar", [put, call], StockLeg(1.0, spot))

bull_call_spread

bull_call_spread(
    spot: float,
    lower: float,
    upper: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long lower-strike call + short upper-strike call: capped bullish debit.

Source code in src/jaxfolio/options/strategies.py
def bull_call_spread(
    spot: float,
    lower: float,
    upper: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Long lower-strike call + short upper-strike call: capped bullish debit."""
    long_c = _priced_leg("call", lower, 1.0, expiry, spot, vol, rate, 0.0)
    short_c = _priced_leg("call", upper, -1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Bull Call Spread", [long_c, short_c])

bear_put_spread

bear_put_spread(
    spot: float,
    upper: float,
    lower: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long upper-strike put + short lower-strike put: capped bearish debit.

Source code in src/jaxfolio/options/strategies.py
def bear_put_spread(
    spot: float,
    upper: float,
    lower: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Long upper-strike put + short lower-strike put: capped bearish debit."""
    long_p = _priced_leg("put", upper, 1.0, expiry, spot, vol, rate, 0.0)
    short_p = _priced_leg("put", lower, -1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Bear Put Spread", [long_p, short_p])

straddle

straddle(
    spot: float,
    strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long call + long put at the same strike: a bet on large moves either way.

Source code in src/jaxfolio/options/strategies.py
def straddle(
    spot: float, strike: float, *, expiry: float = 0.25, vol: float = 0.25, rate: float = 0.0
) -> OptionStrategy:
    """Long call + long put at the same strike: a bet on large moves either way."""
    call = _priced_leg("call", strike, 1.0, expiry, spot, vol, rate, 0.0)
    put = _priced_leg("put", strike, 1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Long Straddle", [call, put])

strangle

strangle(
    spot: float,
    put_strike: float,
    call_strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long OTM put + long OTM call: cheaper volatility bet than a straddle.

Source code in src/jaxfolio/options/strategies.py
def strangle(
    spot: float,
    put_strike: float,
    call_strike: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Long OTM put + long OTM call: cheaper volatility bet than a straddle."""
    put = _priced_leg("put", put_strike, 1.0, expiry, spot, vol, rate, 0.0)
    call = _priced_leg("call", call_strike, 1.0, expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Long Strangle", [put, call])

iron_condor

iron_condor(
    spot: float,
    put_long: float,
    put_short: float,
    call_short: float,
    call_long: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Short strangle wrapped in long protective wings: a range-bound credit trade.

Strikes must satisfy put_long < put_short < call_short < call_long.

Source code in src/jaxfolio/options/strategies.py
def iron_condor(
    spot: float,
    put_long: float,
    put_short: float,
    call_short: float,
    call_long: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Short strangle wrapped in long protective wings: a range-bound credit trade.

    Strikes must satisfy ``put_long < put_short < call_short < call_long``.
    """
    legs = [
        _priced_leg("put", put_long, 1.0, expiry, spot, vol, rate, 0.0),
        _priced_leg("put", put_short, -1.0, expiry, spot, vol, rate, 0.0),
        _priced_leg("call", call_short, -1.0, expiry, spot, vol, rate, 0.0),
        _priced_leg("call", call_long, 1.0, expiry, spot, vol, rate, 0.0),
    ]
    return OptionStrategy("Iron Condor", legs)

butterfly

butterfly(
    spot: float,
    lower: float,
    middle: float,
    upper: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Long butterfly with calls: peak payoff pinned at the middle strike.

Source code in src/jaxfolio/options/strategies.py
def butterfly(
    spot: float,
    lower: float,
    middle: float,
    upper: float,
    *,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Long butterfly with calls: peak payoff pinned at the middle strike."""
    legs = [
        _priced_leg("call", lower, 1.0, expiry, spot, vol, rate, 0.0),
        _priced_leg("call", middle, -2.0, expiry, spot, vol, rate, 0.0),
        _priced_leg("call", upper, 1.0, expiry, spot, vol, rate, 0.0),
    ]
    return OptionStrategy("Long Butterfly", legs)

calendar_spread

calendar_spread(
    spot: float,
    strike: float,
    *,
    near_expiry: float = 0.08,
    far_expiry: float = 0.33,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy

Short near-dated + long far-dated call at one strike: a theta/vega play.

Because the legs have different expiries, use :meth:OptionStrategy.pnl_at (mark-to-model) rather than expiry payoff to analyze this structure.

Source code in src/jaxfolio/options/strategies.py
def calendar_spread(
    spot: float,
    strike: float,
    *,
    near_expiry: float = 0.08,
    far_expiry: float = 0.33,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OptionStrategy:
    """Short near-dated + long far-dated call at one strike: a theta/vega play.

    Because the legs have different expiries, use :meth:`OptionStrategy.pnl_at`
    (mark-to-model) rather than expiry payoff to analyze this structure.
    """
    short_near = _priced_leg("call", strike, -1.0, near_expiry, spot, vol, rate, 0.0)
    long_far = _priced_leg("call", strike, 1.0, far_expiry, spot, vol, rate, 0.0)
    return OptionStrategy("Calendar Spread", [short_near, long_far])

Overlays

overlay

Options overlays on top of an optimized equity portfolio.

Bridges the optimizer output (:class:PortfolioResult) and the options layer: apply a covered-call or collar overlay to the largest holdings, aggregate the net Greeks of the resulting book, and produce a combined at-expiry payoff for the overlaid position. This is what makes the two halves of the library compose.

OverlayBook dataclass

OverlayBook(
    strategies: dict[str, OptionStrategy],
    weights: dict[str, float],
    spots: dict[str, float],
)

A basket of per-asset option strategies scaled by portfolio weights.

Attributes:

Name Type Description
strategies dict[str, OptionStrategy]

Mapping asset -> OptionStrategy for the overlaid holdings.

weights dict[str, float]

Portfolio weight of each overlaid asset (used to scale payoffs/Greeks).

spots dict[str, float]

Reference spot price per asset.

net_greeks
net_greeks(
    vol: float = 0.25, rate: float = 0.0
) -> dict[str, float]

Weighted net Greeks across the whole overlay book.

Source code in src/jaxfolio/options/overlay.py
def net_greeks(self, vol: float = 0.25, rate: float = 0.0) -> dict[str, float]:
    """Weighted net Greeks across the whole overlay book."""
    agg = {"delta": 0.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0}
    for asset, strat in self.strategies.items():
        w = self.weights[asset]
        g = strat.greeks(self.spots[asset], vol, rate)
        for k in agg:
            agg[k] += w * g[k]
    return agg
payoff_curve
payoff_curve(shock_grid: ndarray) -> ndarray

Weighted portfolio P&L across a grid of relative spot shocks.

shock_grid is a multiplier applied to every asset's spot (e.g. np.linspace(0.7, 1.3, 200) sweeps -30% to +30%). Returns the weight-scaled aggregate P&L of the overlaid holdings.

Source code in src/jaxfolio/options/overlay.py
def payoff_curve(self, shock_grid: np.ndarray) -> np.ndarray:
    """Weighted portfolio P&L across a grid of *relative* spot shocks.

    ``shock_grid`` is a multiplier applied to every asset's spot (e.g.
    ``np.linspace(0.7, 1.3, 200)`` sweeps -30% to +30%). Returns the
    weight-scaled aggregate P&L of the overlaid holdings.
    """
    total = np.zeros_like(shock_grid, dtype=float)
    for asset, strat in self.strategies.items():
        w = self.weights[asset]
        spots = self.spots[asset] * shock_grid
        total = total + w * strat.payoff_at_expiry(spots)
    return total

covered_call_overlay

covered_call_overlay(
    result: PortfolioResult,
    spots: dict[str, float],
    *,
    top_n: int = 5,
    moneyness: float = 1.05,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OverlayBook

Write covered calls on the top_n largest holdings.

moneyness sets the call strike relative to spot (1.05 = 5% OTM). Assets without a provided spot price are skipped.

Source code in src/jaxfolio/options/overlay.py
def covered_call_overlay(
    result: PortfolioResult,
    spots: dict[str, float],
    *,
    top_n: int = 5,
    moneyness: float = 1.05,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OverlayBook:
    """Write covered calls on the ``top_n`` largest holdings.

    ``moneyness`` sets the call strike relative to spot (``1.05`` = 5% OTM).
    Assets without a provided spot price are skipped.
    """
    holdings = result.top(top_n)
    strategies: dict[str, OptionStrategy] = {}
    weights: dict[str, float] = {}
    used_spots: dict[str, float] = {}
    for asset, w in holdings.items():
        if asset not in spots or w <= 0:
            continue
        s = spots[asset]
        strategies[asset] = covered_call(s, s * moneyness, expiry=expiry, vol=vol, rate=rate)
        weights[asset] = float(w)
        used_spots[asset] = s
    return OverlayBook(strategies, weights, used_spots)

collar_overlay

collar_overlay(
    result: PortfolioResult,
    spots: dict[str, float],
    *,
    top_n: int = 5,
    put_moneyness: float = 0.95,
    call_moneyness: float = 1.05,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OverlayBook

Wrap the top_n largest holdings in protective collars.

Bounds each holding's outcome between put_moneyness and call_moneyness times spot — capping both tail loss and upside.

Source code in src/jaxfolio/options/overlay.py
def collar_overlay(
    result: PortfolioResult,
    spots: dict[str, float],
    *,
    top_n: int = 5,
    put_moneyness: float = 0.95,
    call_moneyness: float = 1.05,
    expiry: float = 0.25,
    vol: float = 0.25,
    rate: float = 0.0,
) -> OverlayBook:
    """Wrap the ``top_n`` largest holdings in protective collars.

    Bounds each holding's outcome between ``put_moneyness`` and
    ``call_moneyness`` times spot — capping both tail loss and upside.
    """
    holdings = result.top(top_n)
    strategies: dict[str, OptionStrategy] = {}
    weights: dict[str, float] = {}
    used_spots: dict[str, float] = {}
    for asset, w in holdings.items():
        if asset not in spots or w <= 0:
            continue
        s = spots[asset]
        strategies[asset] = collar(
            s, s * put_moneyness, s * call_moneyness, expiry=expiry, vol=vol, rate=rate
        )
        weights[asset] = float(w)
        used_spots[asset] = s
    return OverlayBook(strategies, weights, used_spots)

Volatility surface

surface

Implied-volatility surfaces.

A :class:VolSurface turns a discrete grid of implied vols (strikes x expiries) into a callable iv(strike, ttm) by interpolating in total variance (w = iv^2 * ttm) across time and across strike — the representation in which a well-behaved surface is smooth and arbitrage checks are natural. Build one from market option prices (:meth:VolSurface.from_chain, which inverts prices with the existing Newton :func:~jaxfolio.options.pricing.implied_volatility) or fit a parametric raw-SVI slice per expiry (:meth:VolSurface.fit_svi).

The surface reuses the package pricer/Greeks: :meth:VolSurface.price and :meth:VolSurface.greeks read the vol off the surface and call :func:~jaxfolio.options.pricing.black_scholes_price / :func:~jaxfolio.options.greeks.all_greeks, so nothing is re-derived.

Arbitrage diagnostics (:meth:VolSurface.arbitrage_report) flag butterfly violations (call price must be convex in strike) and calendar violations (total variance must not decrease with maturity).

SVIParams dataclass

SVIParams(
    a: float, b: float, rho: float, m: float, sigma: float
)

Raw-SVI parameters for a single expiry slice (Gatheral, 2004).

Total variance as a function of log-moneyness k = log(strike / spot)::

w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2))

Attributes:

Name Type Description
a float

Vertical level of the variance smile (>= 0 region after fit).

b float

Slope / wing steepness (b >= 0).

rho float

Skew, -1 < rho < 1 (negative = downward equity skew).

m float

Horizontal shift of the smile minimum.

sigma float

Curvature at the minimum (sigma > 0).

total_variance
total_variance(k: ndarray) -> ndarray

Total implied variance w(k) at log-moneyness k.

Source code in src/jaxfolio/options/surface.py
def total_variance(self, k: np.ndarray) -> np.ndarray:
    """Total implied variance ``w(k)`` at log-moneyness ``k``."""
    k = np.asarray(k, dtype=float)
    return self.a + self.b * (
        self.rho * (k - self.m) + np.sqrt((k - self.m) ** 2 + self.sigma**2)
    )

VolSurface dataclass

VolSurface(
    strikes: ndarray,
    ttms: ndarray,
    iv_grid: ndarray,
    spot: float,
    rate: float = 0.0,
    div: float = 0.0,
    svi: list[SVIParams] | None = None,
)

An interpolated implied-volatility surface.

Attributes:

Name Type Description
strikes ndarray

Strictly increasing strike grid, shape (K,).

ttms ndarray

Strictly increasing time-to-maturity grid (years), shape (M,).

iv_grid ndarray

Implied vols, shape (M, K) — row i is the smile at ttms[i].

spot float

Reference spot used for log-moneyness.

rate, div

Continuous risk-free rate and dividend yield used when pricing off the surface.

svi list[SVIParams] | None

Optional per-expiry SVI slices (populated by :meth:fit_svi); when present, iv is evaluated from SVI across strike instead of grid interpolation.

from_iv_grid classmethod
from_iv_grid(
    strikes, ttms, iv_grid, spot, *, rate=0.0, div=0.0
) -> VolSurface

Build directly from a known implied-vol grid.

Source code in src/jaxfolio/options/surface.py
@classmethod
def from_iv_grid(cls, strikes, ttms, iv_grid, spot, *, rate=0.0, div=0.0) -> VolSurface:
    """Build directly from a known implied-vol grid."""
    return cls(strikes, ttms, iv_grid, spot, rate=rate, div=div)
from_chain classmethod
from_chain(
    spot: float,
    strikes,
    ttms,
    prices: ndarray,
    *,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
) -> VolSurface

Invert a grid of market option prices into an implied-vol surface.

prices has shape (len(ttms), len(strikes)). Each entry is inverted with the Newton solver; non-invertible quotes (below intrinsic) become NaN and are then filled by nearest-valid interpolation along the smile.

Source code in src/jaxfolio/options/surface.py
@classmethod
def from_chain(
    cls,
    spot: float,
    strikes,
    ttms,
    prices: np.ndarray,
    *,
    rate: float = 0.0,
    div: float = 0.0,
    is_call: bool = True,
) -> VolSurface:
    """Invert a grid of market option prices into an implied-vol surface.

    ``prices`` has shape ``(len(ttms), len(strikes))``. Each entry is inverted
    with the Newton solver; non-invertible quotes (below intrinsic) become
    ``NaN`` and are then filled by nearest-valid interpolation along the smile.
    """
    strikes = np.asarray(strikes, dtype=float)
    ttms = np.asarray(ttms, dtype=float)
    prices = np.asarray(prices, dtype=float)
    iv_grid = np.empty((len(ttms), len(strikes)))
    for i, t in enumerate(ttms):
        for j, k in enumerate(strikes):
            iv_grid[i, j] = float(
                implied_volatility(prices[i, j], spot, k, t, rate, div, is_call)
            )
        iv_grid[i] = _fill_nans(strikes, iv_grid[i])
    return cls(strikes, ttms, iv_grid, spot, rate=rate, div=div)
fit_svi
fit_svi() -> VolSurface

Return a copy whose smiles are replaced by fitted raw-SVI slices.

Each expiry's (log-moneyness, total-variance) points are fit with :func:calibrate_svi; the grid is re-evaluated from the fits so iv becomes smooth and extrapolates sensibly beyond the quoted strikes.

Source code in src/jaxfolio/options/surface.py
def fit_svi(self) -> VolSurface:
    """Return a copy whose smiles are replaced by fitted raw-SVI slices.

    Each expiry's ``(log-moneyness, total-variance)`` points are fit with
    :func:`calibrate_svi`; the grid is re-evaluated from the fits so ``iv``
    becomes smooth and extrapolates sensibly beyond the quoted strikes.
    """
    k = np.log(self.strikes / self.spot)
    sections: list[SVIParams] = []
    new_grid = np.empty_like(self.iv_grid)
    for i, t in enumerate(self.ttms):
        w = self.iv_grid[i] ** 2 * t
        params = calibrate_svi(k, w)
        sections.append(params)
        new_grid[i] = np.sqrt(np.clip(params.total_variance(k), 1e-12, None) / t)
    return VolSurface(
        self.strikes, self.ttms, new_grid, self.spot, rate=self.rate, div=self.div, svi=sections
    )
iv
iv(strike: float, ttm: float) -> float

Interpolated implied vol at (strike, ttm).

Interpolates in total variance across maturity (flat-forward beyond the quoted range) and along the smile at each bracketing expiry.

Source code in src/jaxfolio/options/surface.py
def iv(self, strike: float, ttm: float) -> float:
    """Interpolated implied vol at ``(strike, ttm)``.

    Interpolates in **total variance** across maturity (flat-forward beyond
    the quoted range) and along the smile at each bracketing expiry.
    """
    ttms = self.ttms
    if ttm <= ttms[0]:
        return self._slice_iv(0, strike)
    if ttm >= ttms[-1]:
        return self._slice_iv(len(ttms) - 1, strike)
    hi = int(np.searchsorted(ttms, ttm))
    lo = hi - 1
    t_lo, t_hi = ttms[lo], ttms[hi]
    w_lo = self._slice_iv(lo, strike) ** 2 * t_lo
    w_hi = self._slice_iv(hi, strike) ** 2 * t_hi
    frac = (ttm - t_lo) / (t_hi - t_lo)
    w = w_lo + frac * (w_hi - w_lo)
    return float(np.sqrt(max(w, 1e-12) / ttm))
price
price(
    strike: float, ttm: float, *, is_call: bool = True
) -> float

Black-Scholes price of an option, using the surface's implied vol.

Source code in src/jaxfolio/options/surface.py
def price(self, strike: float, ttm: float, *, is_call: bool = True) -> float:
    """Black-Scholes price of an option, using the surface's implied vol."""
    vol = self.iv(strike, ttm)
    return float(black_scholes_price(self.spot, strike, ttm, vol, self.rate, self.div, is_call))
greeks
greeks(
    strike: float, ttm: float, *, is_call: bool = True
) -> dict[str, float]

Full Greeks of an option, using the surface's implied vol.

Source code in src/jaxfolio/options/surface.py
def greeks(self, strike: float, ttm: float, *, is_call: bool = True) -> dict[str, float]:
    """Full Greeks of an option, using the surface's implied vol."""
    vol = self.iv(strike, ttm)
    return all_greeks(self.spot, strike, ttm, vol, self.rate, self.div, is_call)
arbitrage_report
arbitrage_report(tol: float = 1e-06) -> dict[str, object]

Check static no-arbitrage conditions on the surface.

Returns a dict with butterfly_ok (call price convex in strike at each expiry), calendar_ok (total variance non-decreasing in maturity at each strike), an overall arbitrage_free flag, and the lists of offending (ttm, strike) / (strike, ttm_pair) locations.

Source code in src/jaxfolio/options/surface.py
def arbitrage_report(self, tol: float = 1e-6) -> dict[str, object]:
    """Check static no-arbitrage conditions on the surface.

    Returns a dict with ``butterfly_ok`` (call price convex in strike at each
    expiry), ``calendar_ok`` (total variance non-decreasing in maturity at
    each strike), an overall ``arbitrage_free`` flag, and the lists of
    offending ``(ttm, strike)`` / ``(strike, ttm_pair)`` locations.
    """
    butterfly_violations = []
    for i, t in enumerate(self.ttms):
        calls = np.array(
            [
                black_scholes_price(
                    self.spot, k, t, self._slice_iv(i, k), self.rate, self.div, True
                )
                for k in self.strikes
            ]
        )
        # Convexity: second difference of call price in strike must be >= 0.
        second_diff = calls[:-2] - 2 * calls[1:-1] + calls[2:]
        for j in np.where(second_diff < -tol)[0]:
            butterfly_violations.append((float(t), float(self.strikes[j + 1])))

    calendar_violations = []
    for k in self.strikes:
        w = np.array([self._slice_iv(i, k) ** 2 * self.ttms[i] for i in range(len(self.ttms))])
        dec = np.where(np.diff(w) < -tol)[0]
        for i in dec:
            calendar_violations.append(
                (float(k), (float(self.ttms[i]), float(self.ttms[i + 1])))
            )

    return {
        "butterfly_ok": not butterfly_violations,
        "calendar_ok": not calendar_violations,
        "arbitrage_free": not butterfly_violations and not calendar_violations,
        "butterfly_violations": butterfly_violations,
        "calendar_violations": calendar_violations,
    }
is_arbitrage_free
is_arbitrage_free(tol: float = 1e-06) -> bool

Convenience boolean: arbitrage_report(...)['arbitrage_free'].

Source code in src/jaxfolio/options/surface.py
def is_arbitrage_free(self, tol: float = 1e-6) -> bool:
    """Convenience boolean: ``arbitrage_report(...)['arbitrage_free']``."""
    return bool(self.arbitrage_report(tol)["arbitrage_free"])

calibrate_svi

calibrate_svi(k: ndarray, w: ndarray) -> SVIParams

Least-squares-fit raw-SVI parameters to observed total variances.

Parameters:

Name Type Description Default
k ndarray

Log-moneyness of each observation.

required
w ndarray

Observed total implied variance (iv^2 * ttm) at each k.

required

Returns:

Type Description
SVIParams

The fitted slice. Bounds keep the fit in the no-vertical-arbitrage region (b >= 0, |rho| < 1, sigma > 0, a >= 0).

Source code in src/jaxfolio/options/surface.py
def calibrate_svi(k: np.ndarray, w: np.ndarray) -> SVIParams:
    """Least-squares-fit raw-SVI parameters to observed total variances.

    Parameters
    ----------
    k:
        Log-moneyness of each observation.
    w:
        Observed total implied variance (``iv^2 * ttm``) at each ``k``.

    Returns
    -------
    SVIParams
        The fitted slice. Bounds keep the fit in the no-vertical-arbitrage region
        (``b >= 0``, ``|rho| < 1``, ``sigma > 0``, ``a >= 0``).
    """
    k = np.asarray(k, dtype=float)
    w = np.asarray(w, dtype=float)
    w_mean = float(np.mean(w))

    def resid(theta):
        a, b, rho, m, sigma = theta
        model = a + b * (rho * (k - m) + np.sqrt((k - m) ** 2 + sigma**2))
        return model - w

    x0 = [max(w_mean * 0.5, 1e-6), 0.1, -0.3, 0.0, 0.1]
    lower = [0.0, 0.0, -0.999, k.min() - 1.0, 1e-4]
    upper = [max(w.max(), 1e-3) * 2 + 1e-6, 10.0, 0.999, k.max() + 1.0, 5.0]
    res = opt.least_squares(resid, x0, bounds=(lower, upper), max_nfev=2000)
    a, b, rho, m, sigma = res.x
    return SVIParams(a=float(a), b=float(b), rho=float(rho), m=float(m), sigma=float(sigma))

Execution framework

Research simulation, not live trading

The execution framework simulates fills against a modeled price with transaction costs. It is a backtesting / research tool — not a live broker, order-management system, or exchange connection. See the disclaimer.

costs

Transaction-cost models for the options execution simulator.

A :class:CostModel turns a mid price into a realistic fill price and a commission. Costs are intentionally simple and transparent (per-contract commission + a proportional half-spread / slippage in basis points) — enough to make simulated P&L reflect frictions without pretending to model a real venue's microstructure.

CostModel dataclass

CostModel(
    commission_per_contract: float = 0.65,
    slippage_bps: float = 5.0,
    min_commission: float = 0.0,
)

A commission + slippage model.

Attributes:

Name Type Description
commission_per_contract float

Flat cash commission charged per contract traded (per unit of absolute quantity), applied on both entry and exit.

slippage_bps float

Proportional slippage in basis points of the mid price, modeling the half-spread / market impact. A buy fills at mid * (1 + bps/1e4), a sell at mid * (1 - bps/1e4).

min_commission float

Floor applied to the per-trade commission.

fill_price
fill_price(mid: float, side: int) -> float

Fill price for a side trade (+1 buy, -1 sell) at mid.

Slippage always works against the trader: buys fill above mid, sells below. A negative mid is floored at 0 (options cannot be worth < 0).

Source code in src/jaxfolio/options/execution/costs.py
def fill_price(self, mid: float, side: int) -> float:
    """Fill price for a ``side`` trade (+1 buy, -1 sell) at ``mid``.

    Slippage always works against the trader: buys fill above mid, sells
    below. A negative mid is floored at 0 (options cannot be worth < 0).
    """
    slip = self.slippage_bps / 1e4
    price = mid * (1.0 + side * slip)
    return max(price, 0.0)
commission
commission(quantity: float) -> float

Commission for trading quantity contracts (sign-insensitive).

Source code in src/jaxfolio/options/execution/costs.py
def commission(self, quantity: float) -> float:
    """Commission for trading ``quantity`` contracts (sign-insensitive)."""
    raw = abs(quantity) * self.commission_per_contract
    return max(raw, self.min_commission)

book

Order/fill accounting and a mark-to-market option book.

This is the core of the execution layer: an :class:ExecutionSimulator prices an option at Black-Scholes mid, applies a :class:~jaxfolio.options.execution.costs.CostModel to get a fill, updates an :class:OptionBook of :class:Position objects and a cash balance, and can mark the whole book to market at any spot/vol/horizon.

Scope: this simulates fills against a modeled price. It is a research / backtesting tool — not a live broker, order-management system, or connection to any exchange. See DISCLAIMER.md.

Instrument dataclass

Instrument(kind: str, strike: float, expiry: float)

A vanilla option contract identity (what is traded).

Order dataclass

Order(instrument: Instrument, quantity: float)

An instruction to trade quantity contracts of instrument.

quantity is signed: positive = buy (long), negative = sell (short).

Fill dataclass

Fill(
    instrument: Instrument,
    quantity: float,
    mid: float,
    fill_price: float,
    commission: float,
)

The realized result of executing an :class:Order.

cash_flow property
cash_flow: float

Signed cash impact: buying pays (negative), selling receives (positive).

Position dataclass

Position(
    instrument: Instrument,
    quantity: float = 0.0,
    avg_price: float = 0.0,
)

A net position in one instrument, tracking quantity and average cost.

apply
apply(fill: Fill) -> None

Fold a fill into the position, updating the average entry price.

Source code in src/jaxfolio/options/execution/book.py
def apply(self, fill: Fill) -> None:
    """Fold a fill into the position, updating the average entry price."""
    new_qty = self.quantity + fill.quantity
    if self.quantity == 0 or (self.quantity > 0) == (fill.quantity > 0):
        # Opening or adding in the same direction: blend the average price.
        total = self.quantity + fill.quantity
        if total != 0:
            self.avg_price = (
                self.avg_price * self.quantity + fill.fill_price * fill.quantity
            ) / total
    elif abs(fill.quantity) > abs(self.quantity):
        # Flipped through zero: the residual carries the new fill's price.
        self.avg_price = fill.fill_price
    # else: partial close, avg_price of the remaining lot is unchanged.
    self.quantity = new_qty
    if abs(self.quantity) < 1e-12:
        self.quantity = 0.0
        self.avg_price = 0.0
market_value
market_value(
    spot: float,
    vol: float,
    rate: float,
    div: float,
    ttm_shift: float,
) -> float

Mark-to-model value of the position at a horizon.

Source code in src/jaxfolio/options/execution/book.py
def market_value(
    self, spot: float, vol: float, rate: float, div: float, ttm_shift: float
) -> float:
    """Mark-to-model value of the position at a horizon."""
    if self.quantity == 0.0:
        return 0.0
    t = max(self.instrument.expiry - ttm_shift, 1e-6)
    px = float(
        black_scholes_price(
            spot, self.instrument.strike, t, vol, rate, div, self.instrument.is_call
        )
    )
    return self.quantity * px

OptionBook dataclass

OptionBook(positions: dict[tuple, Position] = dict())

A collection of option positions keyed by instrument identity.

market_value
market_value(
    spot, vol, rate=0.0, div=0.0, ttm_shift=0.0
) -> float

Total mark-to-model value of all open positions.

Source code in src/jaxfolio/options/execution/book.py
def market_value(self, spot, vol, rate=0.0, div=0.0, ttm_shift=0.0) -> float:
    """Total mark-to-model value of all open positions."""
    return sum(p.market_value(spot, vol, rate, div, ttm_shift) for p in self.positions.values())
net_greeks
net_greeks(
    spot, vol, rate=0.0, div=0.0, ttm_shift=0.0
) -> dict[str, float]

Net position Greeks across the book.

Source code in src/jaxfolio/options/execution/book.py
def net_greeks(self, spot, vol, rate=0.0, div=0.0, ttm_shift=0.0) -> dict[str, float]:
    """Net position Greeks across the book."""
    agg = {"delta": 0.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0}
    for p in self.positions.values():
        if p.quantity == 0.0:
            continue
        t = max(p.instrument.expiry - ttm_shift, 1e-6)
        g = all_greeks(spot, p.instrument.strike, t, vol, rate, div, p.instrument.is_call)
        for k in agg:
            agg[k] += p.quantity * g[k]
    return agg

ExecutionSimulator dataclass

ExecutionSimulator(
    cost_model: CostModel = CostModel(),
    rate: float = 0.0,
    div: float = 0.0,
    cash: float = 0.0,
    book: OptionBook = OptionBook(),
    fills: list[Fill] = list(),
)

Executes orders against a modeled mid price and books the results.

Parameters:

Name Type Description Default
cost_model CostModel

The commission/slippage model applied to every fill.

CostModel()
rate float

Rate and dividend yield used both to price the option mid and to mark the book.

0.0
div float

Rate and dividend yield used both to price the option mid and to mark the book.

0.0
cash float

Starting cash balance (updated by every fill's cash flow).

0.0
total_costs property
total_costs: float

Total commission paid across all fills so far.

mid_price
mid_price(
    instrument: Instrument,
    spot: float,
    vol: float,
    ttm_shift: float = 0.0,
) -> float

Black-Scholes mid price of instrument at the given market state.

Source code in src/jaxfolio/options/execution/book.py
def mid_price(
    self, instrument: Instrument, spot: float, vol: float, ttm_shift: float = 0.0
) -> float:
    """Black-Scholes mid price of ``instrument`` at the given market state."""
    t = max(instrument.expiry - ttm_shift, 1e-6)
    return float(
        black_scholes_price(
            spot, instrument.strike, t, vol, self.rate, self.div, instrument.is_call
        )
    )
execute
execute(
    order: Order,
    spot: float,
    vol: float,
    ttm_shift: float = 0.0,
) -> Fill

Execute order at the current market state, updating book and cash.

Source code in src/jaxfolio/options/execution/book.py
def execute(self, order: Order, spot: float, vol: float, ttm_shift: float = 0.0) -> Fill:
    """Execute ``order`` at the current market state, updating book and cash."""
    mid = self.mid_price(order.instrument, spot, vol, ttm_shift)
    fill_price = self.cost_model.fill_price(mid, order.side)
    commission = self.cost_model.commission(order.quantity)
    fill = Fill(order.instrument, order.quantity, mid, fill_price, commission)
    self.book.apply(fill)
    self.cash += fill.cash_flow
    self.fills.append(fill)
    return fill
equity
equity(
    spot: float, vol: float, ttm_shift: float = 0.0
) -> float

Total equity = cash + mark-to-market value of the option book.

Source code in src/jaxfolio/options/execution/book.py
def equity(self, spot: float, vol: float, ttm_shift: float = 0.0) -> float:
    """Total equity = cash + mark-to-market value of the option book."""
    return self.cash + self.book.market_value(spot, vol, self.rate, self.div, ttm_shift)

rolling

Walk-forward simulation of a rolled option overlay.

Ties the execution layer to a price path: hold the underlying and repeatedly write / roll a short call (a covered-call overlay), marking the combined book to market every step and charging transaction costs at each roll. This is the options analogue of the walk-forward equity backtester in :mod:jaxfolio.backtest — a research simulation, not live trading.

OverlaySimResult dataclass

OverlaySimResult(
    equity: ndarray,
    stock_only: ndarray,
    times: ndarray,
    total_costs: float,
    n_rolls: int,
)

Result of a rolled-overlay simulation.

Attributes:

Name Type Description
equity ndarray

Total equity (stock + option book + cash) at each step.

stock_only ndarray

Buy-and-hold stock equity at each step, for comparison.

times ndarray

Elapsed time in years at each step.

total_costs float

Total commissions paid across all rolls.

n_rolls int

Number of times the call was written/rolled.

total_return property
total_return: float

Total return of the overlaid book over the path.

stock_return property
stock_return: float

Total return of plain buy-and-hold stock over the path.

simulate_covered_call_roll

simulate_covered_call_roll(
    prices,
    *,
    moneyness: float = 1.05,
    tenor: float = 0.25,
    roll_every: int = 21,
    vol: float = 0.25,
    shares: float = 1.0,
    cost_model: CostModel | None = None,
    rate: float = 0.0,
    div: float = 0.0,
    periods_per_year: int = 252,
) -> OverlaySimResult

Simulate a rolling covered-call overlay over a price path.

Holds shares of the underlying throughout and writes one short call per share at moneyness * spot with time-to-expiry tenor. Every roll_every steps the existing call is bought back and a fresh one written at the current spot, incurring costs from cost_model.

Parameters:

Name Type Description Default
prices

1-D array (or Polars Series) of underlying prices along the path.

required
moneyness float

Call strike relative to spot (1.05 = 5% out-of-the-money).

1.05
tenor float

Time-to-expiry (years) of each written call.

0.25
roll_every int

Number of steps between rolls. Should satisfy roll_every / periods_per_year < tenor so calls are rolled before expiry.

21
vol float

Flat implied vol used to price and mark the options.

0.25
shares float

Underlying shares held (and calls written) — the overlay is fully covered.

1.0

Returns:

Type Description
OverlaySimResult

Equity path of the overlaid book, the stock-only benchmark, costs, and the number of rolls.

Source code in src/jaxfolio/options/execution/rolling.py
def simulate_covered_call_roll(
    prices,
    *,
    moneyness: float = 1.05,
    tenor: float = 0.25,
    roll_every: int = 21,
    vol: float = 0.25,
    shares: float = 1.0,
    cost_model: CostModel | None = None,
    rate: float = 0.0,
    div: float = 0.0,
    periods_per_year: int = 252,
) -> OverlaySimResult:
    """Simulate a rolling covered-call overlay over a price path.

    Holds ``shares`` of the underlying throughout and writes one short call per
    share at ``moneyness * spot`` with time-to-expiry ``tenor``. Every
    ``roll_every`` steps the existing call is bought back and a fresh one written
    at the current spot, incurring costs from ``cost_model``.

    Parameters
    ----------
    prices:
        1-D array (or Polars Series) of underlying prices along the path.
    moneyness:
        Call strike relative to spot (``1.05`` = 5% out-of-the-money).
    tenor:
        Time-to-expiry (years) of each written call.
    roll_every:
        Number of steps between rolls. Should satisfy
        ``roll_every / periods_per_year < tenor`` so calls are rolled before
        expiry.
    vol:
        Flat implied vol used to price and mark the options.
    shares:
        Underlying shares held (and calls written) — the overlay is fully covered.

    Returns
    -------
    OverlaySimResult
        Equity path of the overlaid book, the stock-only benchmark, costs, and
        the number of rolls.
    """
    prices = np.asarray(prices, dtype=float).reshape(-1)
    if prices.size == 0:
        raise ValueError("prices path is empty")
    dt = 1.0 / periods_per_year
    sim = ExecutionSimulator(cost_model=cost_model or CostModel(), rate=rate, div=div, cash=0.0)

    equity = np.empty(prices.size)
    times = np.arange(prices.size) * dt
    current: Instrument | None = None
    write_index = 0
    n_rolls = 0

    for i, spot in enumerate(prices):
        need_roll = current is None or (i - write_index) >= roll_every
        if need_roll:
            if current is not None:
                # Buy to close the existing short call at its remaining maturity.
                shift = (i - write_index) * dt
                sim.execute(Order(current, +shares), spot, vol, shift)
            current = Instrument("call", moneyness * spot, tenor)
            sim.execute(Order(current, -shares), spot, vol, 0.0)  # sell to open
            write_index = i
            n_rolls += 1

        shift = (i - write_index) * dt
        book_equity = sim.equity(spot, vol, shift)  # cash + option MTM (short call is a liability)
        equity[i] = book_equity + shares * spot  # + stock market value

    stock_only = shares * prices
    return OverlaySimResult(
        equity=equity,
        stock_only=stock_only,
        times=times,
        total_costs=sim.total_costs,
        n_rolls=n_rolls,
    )