This site is version 0.0.1. Most of the code on this site is drafted by an agent and needs review. I apologize for the AI-speak, we are working on rewriting it!
viv-lang.org
Python Viv

Implied volatility, binomial trees and Greeks of European options

Recover the implied volatility of 3,000 option quotes and plot the smile, watch a Cox-Ross-Rubinstein tree converge to the Black-Scholes price, and compare Greeks from Viv's reverse-mode grad with the closed forms. The pricing code is examples/options/*.viv, loaded as Python modules with vp.load.

Load the Black-Scholes and Greeks programs. vp.load compiles each .viv file to C once and caches the library; every annotated def becomes a Python function on NumPy arrays.

import numpy as np
import vipyr as vp
from vipyr.figure import figure

bs = vp.load("examples/options/bs.viv")
greeks = vp.load("examples/options/greeks.viv")

Implied volatility of 3,000 quotes

The quotes are synthetic: prices made from a known smile, sigma(k) = 0.20 - 0.10 k + 0.25 k^2 in the log-moneyness k = ln(K/F), at three maturities. Inverting every price recovers that smile, so the round-trip error says how accurate implied_vols is.

S, r, q = 100.0, 0.03, 0.01
maturities = [0.25, 1.0, 2.0]
n_per = 1000
K = np.concatenate([np.linspace(60.0, 160.0, n_per) for _ in maturities])
T = np.repeat(maturities, n_per)
F = S * np.exp((r - q) * T)
k = np.log(K / F)
true_vol = 0.20 - 0.10 * k + 0.25 * k**2
# out of the money: calls above the forward, puts below
is_call = (K >= F).astype(float)

price = bs.bs_prices(np.full_like(K, S), K, T, r, q, true_vol, is_call)
iv = bs.implied_vols(price, np.full_like(K, S), K, T, r, q, is_call)
rel = np.abs(iv - true_vol) / true_vol
print("quotes:", len(price))
print("worst relative error of the recovered volatility: %.1e" % rel.max())

fig = figure(
    "Implied volatility smile, recovered from 3,000 prices",
    "strike K",
    "implied volatility",
)
for t in maturities:
    m = T == t
    fig.plot(K[m][::40], iv[m][::40], "T = %g recovered" % t)
    fig.plot(K[m][::10], true_vol[m][::10], "T = %g input" % t)
for i, t in enumerate(maturities):
    fig.style("T = %g recovered" % t, "s%d noline circle" % (i + 1))
    fig.style("T = %g input" % t, "s%d dash thin nomarker" % (i + 1))
quotes: 3000
worst relative error of the recovered volatility: 8.4e-15
Implied volatility smile, recovered from 3,000 pricesT = 0.25 recoveredT = 0.25 inputT = 1 recoveredT = 1 inputT = 2 recoveredT = 2 input0.200.250.306080100120140strike Kimplied volatility

Binomial tree convergence to Black-Scholes

vp.finance.crr_price prices a European call on an N-step tree. The error against the closed form falls like 1/N and oscillates, because the strike sits at a different place between the tree's nodes as N changes.

option = dict(S=S, K=100.0, T=1.0, r=r, q=q, sigma=0.2, is_call=True)
exact = vp.finance.bs_price(**option).price
Ns = [10, 20, 40, 80, 160, 320, 640, 1280]
err = []
for N in Ns:
    p = vp.finance.crr_price(**option, N=N, american=False).price
    err.append(abs(p - exact))
    print("N = %4d   tree %.8f   |error| %.2e" % (N, p, abs(p - exact)))
slope = np.polyfit(np.log(Ns), np.log(err), 1)[0]
print("fitted slope of log|error| against log N: %.2f" % slope)

fig = figure(
    "Cox-Ross-Rubinstein error against tree depth",
    "steps N",
    "|tree - Black-Scholes|",
)
fig.plot(Ns, err, "CRR, European call")
fig.plot(Ns, [err[0] * Ns[0] / n for n in Ns], "1/N")
fig.style("1/N", "s2 dash thin nomarker")
fig.xscale("log")
fig.yscale("log")
N =   10   tree 8.63391092   |error| 1.93e-01
N =   20   tree 8.72989340   |error| 9.74e-02
N =   40   tree 8.77843999   |error| 4.89e-02
N =   80   tree 8.80284052   |error| 2.45e-02
N =  160   tree 8.81507107   |error| 1.23e-02
N =  320   tree 8.82119373   |error| 6.13e-03
N =  640   tree 8.82425687   |error| 3.06e-03
N = 1280   tree 8.82578890   |error| 1.53e-03
fitted slope of log|error| against log N: -1.00
Cox-Ross-Rubinstein error against tree depthCRR, European call1/N10⁻²10⁻¹10¹10²10³steps N|tree - Black-Scholes|

Greeks by automatic differentiation

bs_greeks_arr takes delta and vega from Viv's reverse-mode grad of the pricing function, and gamma from the identity gamma = vega / (S^2 sigma T). The closed forms below are written in NumPy, independently.

from math import erf, sqrt, pi


def closed_form(S, K, T, r, q, s):
    d1 = (np.log(S / K) + (r - q + 0.5 * s * s) * T) / (s * np.sqrt(T))
    Nd1 = 0.5 * (1.0 + np.vectorize(erf)(d1 / sqrt(2.0)))
    pdf = np.exp(-0.5 * d1 * d1) / sqrt(2.0 * pi)
    delta = np.exp(-q * T) * Nd1
    gamma = np.exp(-q * T) * pdf / (S * s * np.sqrt(T))
    vega = S * np.exp(-q * T) * pdf * np.sqrt(T)
    return delta, gamma, vega


Kg = np.linspace(70.0, 130.0, 61)
Sg = np.full_like(Kg, S)
Tg = np.full_like(Kg, 0.5)
sg = np.full_like(Kg, 0.22)
d_ad, g_ad, v_ad = greeks.bs_greeks_arr(Sg, Kg, Tg, r, q, sg, np.ones_like(Kg))
d_cf, g_cf, v_cf = closed_form(Sg, Kg, Tg, r, q, sg)
for name, a, b in (
    ("delta", d_ad, d_cf),
    ("gamma", g_ad, g_cf),
    ("vega", v_ad, v_cf),
):
    print(
        "%-5s  worst relative difference from the closed form: %.1e"
        % (name, np.max(np.abs(a - b) / np.abs(b)))
    )
delta  worst relative difference from the closed form: 1.1e-14
gamma  worst relative difference from the closed form: 9.7e-15
vega   worst relative difference from the closed form: 9.6e-15

Source

gallery/finance/plot_option_pricing.py, run as written when these pages were built. The output and the figures above are from that run.

The Viv program it calls is examples/options/gallery_smile.viv: its atlas, the defs and the calls between them, or open it in the workbench.