34 — Black-Scholes, the Greeks, and Rates Options¶
Learning objectives¶
Price a European call and put with Black-Scholes; verify put-call parity holds; compute and sanity-check the Greeks against a finite-difference bump; solve for implied volatility; understand how Black-76 extends the same model to options on forwards, and how caps/floors/swaptions build on that. This is the repo's first options/derivatives notebook - a new track alongside foundations, optimization, active, fixed income, fx_commodities, equity, and integration.
Free learning pack¶
reference/derivatives/black_scholes_and_greeks.mdreference/derivatives/put_call_parity.mdreference/derivatives/implied_volatility.mdreference/derivatives/options_on_forwards_and_rates_options.mdreference/derivatives/option_strategies.md- Black-Scholes model - Wikipedia https://en.wikipedia.org/wiki/Black%E2%80%93Scholes_model
Do not search for more material until these are insufficient.
PREDICT¶
A call and a put share the same strike (100), same expiry (1 year), and sit on a stock trading exactly at that strike (S=100), with a 5% risk-free rate. Without calculating: should the call be worth more than the put, less, or the same? Why might a nonzero risk-free rate break a naive "symmetric strike, so equal value" intuition?
Formula¶
d1 = (ln(S/K) + (r - q + 0.5*sigma^2)*T) / (sigma*sqrt(T))
d2 = d1 - sigma*sqrt(T)
call = S*exp(-q*T)*N(d1) - K*exp(-r*T)*N(d2)
put = K*exp(-r*T)*N(-d2) - S*exp(-q*T)*N(-d1)
from pm.options import black_scholes_call_price, black_scholes_put_price
S, K, r, sigma, T = 100.0, 100.0, 0.05, 0.20, 1.0
# MANUAL FIRST:
call_price = None # use black_scholes_call_price
put_price = None # use black_scholes_put_price
print("call:", call_price)
print("put:", put_price)
# CHECK (uncomment after your attempt):
# import numpy as np
# assert np.isclose(call_price, 10.450584, atol=1e-5)
# assert np.isclose(put_price, 5.573526, atol=1e-5)
Was your PREDICT right?¶
The call is worth more than the put here, even though S=K exactly - the risk-free rate breaks the symmetry: the call's payoff is effectively "paid for" with money that could otherwise earn interest, while the put buyer benefits from the strike's present value being discounted. This is exactly what put-call parity formalizes next.
PREDICT (put-call parity)¶
Using the call and put prices you just computed: without recomputing
from scratch, what should call - put equal, in terms of S, K, r, and
T? (No dividend here, so q=0.)
from pm.options import put_call_parity_residual
# MANUAL FIRST:
residual = None # use put_call_parity_residual(call_price, put_price, S, K, r, T)
print("parity residual:", residual)
# CHECK (uncomment after your attempt):
# import numpy as np
# assert np.isclose(residual, 0.0, atol=1e-8)
Now break it deliberately¶
Feed in a call/put pair that did NOT come from the same consistent model - does the residual still come out near zero?
# MANUAL FIRST:
bad_residual = None # put_call_parity_residual(call_price=11.0, put_price=5.0, spot=S, strike=K, rate=r, time_to_expiry=T)
print("bad pair residual:", bad_residual)
# CHECK (uncomment after your attempt):
# assert abs(bad_residual) > 0.01, "an inconsistent call/put pair should NOT satisfy parity"
Formula (Greeks)¶
delta_call = exp(-q*T)*N(d1) | delta_put = -exp(-q*T)*N(-d1)
gamma = exp(-q*T)*N'(d1) / (S*sigma*sqrt(T)) (same for call and put)
vega = S*exp(-q*T)*N'(d1)*sqrt(T) (same for call and put)
from pm.options import delta_call, delta_put, gamma, vega
# MANUAL FIRST:
d_call = None # delta_call(S, K, r, sigma, T)
d_put = None # delta_put(S, K, r, sigma, T)
g = None # gamma(S, K, r, sigma, T)
v = None # vega(S, K, r, sigma, T)
print("delta_call:", d_call, " delta_put:", d_put)
print("gamma:", g, " vega:", v)
# CHECK (uncomment after your attempt):
# import numpy as np
# assert np.isclose(d_call, 0.636831, atol=1e-5)
# assert np.isclose(d_put, -0.363169, atol=1e-5)
# assert np.isclose(d_call - d_put, 1.0, atol=1e-8), "delta_call - delta_put must equal exp(-qT); here q=0 so exactly 1.0"
Cross-check gamma against a finite-difference bump¶
Rather than trusting the closed-form Greek formula on faith, verify it against a direct bump of the pricing function itself: gamma is the second derivative of price with respect to spot, so a central finite-difference should recover the same number.
eps = 1e-3
# MANUAL FIRST:
fd_gamma = None # (black_scholes_call_price(S+eps, K, r, sigma, T) - 2*black_scholes_call_price(S, K, r, sigma, T) + black_scholes_call_price(S-eps, K, r, sigma, T)) / eps**2
print("analytic gamma:", g, " finite-difference gamma:", fd_gamma)
# CHECK (uncomment after your attempt):
# import numpy as np
# assert np.isclose(g, fd_gamma, atol=1e-3)
PREDICT (theta)¶
An investor is long this ATM call, doing nothing else, with one year to expiry. If literally nothing else changes - spot, rate, and volatility all stay exactly the same - and only one day passes, does the call's value go up, down, or stay the same? Why?
from pm.options import theta_call
# MANUAL FIRST:
theta = None # theta_call(S, K, r, sigma, T) - this is PER YEAR; divide by 365 for per-day
theta_per_day = None
print("theta (per year):", theta, " per day:", theta_per_day)
# CHECK (uncomment after your attempt):
# assert theta < 0, "an ATM call should lose value from time decay alone, all else fixed"
Formula (implied volatility)¶
No closed form - solved numerically so that
black_scholes_call_price(..., sigma, ...) == market_price.
from pm.options import implied_volatility_call
market_price = call_price # pretend this is a quoted market price
# MANUAL FIRST:
iv = None # implied_volatility_call(market_price, S, K, r, T)
print("implied vol:", iv, " (original sigma was:", sigma, ")")
# CHECK (uncomment after your attempt):
# import numpy as np
# assert np.isclose(iv, sigma, atol=1e-8)
Formula (Black-76: options on forwards)¶
d1 = (ln(F/K) + 0.5*sigma^2*T) / (sigma*sqrt(T))
d2 = d1 - sigma*sqrt(T)
call = exp(-r*T) * (F*N(d1) - K*N(d2))
Black-76 priced at the forward F = S*exp((r-q)*T) must reproduce the
same price as spot-based Black-Scholes - it's the same model.
import numpy as np
from pm.options import black76_call_price
q = 0.0
forward = S * np.exp((r - q) * T)
# MANUAL FIRST:
b76_call = None # black76_call_price(forward, K, r, sigma, T)
print("Black-76 call:", b76_call, " vs Black-Scholes call:", call_price)
# CHECK (uncomment after your attempt):
# import numpy as np
# assert np.isclose(b76_call, call_price, atol=1e-6)
PREDICT (rates options)¶
A cap is described in reference/derivatives/options_on_forwards_and_rates_options.md
as "a portfolio of caplets, each a call option on a forward interest
rate for one accrual period." If market rates rise sharply and stay
high, does a cap holder's payoff go up or down? What kind of borrower
would buy a cap, and why?
PREDICT (option strategies)¶
A PM holds a large, low-cost-basis stock position they don't want to sell (tax reasons). They want some downside protection but don't want to pay cash for it upfront. Which of covered call, protective put, or collar fits this constraint best, and what do they give up to get it?
Reference¶
reference/derivatives/black_scholes_and_greeks.md
reference/derivatives/put_call_parity.md
reference/derivatives/implied_volatility.md
reference/derivatives/options_on_forwards_and_rates_options.md
reference/derivatives/option_strategies.md
Promote¶
Use src/pm/options.py (black_scholes_call_price,
black_scholes_put_price, delta_call, delta_put, gamma, vega,
theta_call, theta_put, rho_call, rho_put,
implied_volatility_call, implied_volatility_put,
black76_call_price, black76_put_price) only after your own
implementation.
Test¶
pytest tests/test_options.py
ORAL CHECK¶
Explain to a PM why gamma and vega are identical for a call and a put at the same strike/expiry, but delta, theta, and rho are not - use put-call parity to justify it, not just "that's what the formula says." Then explain, in plain language, why a swaption needs an annuity factor that a plain Black-76 call on a forward price doesn't.
Try /tutor black-scholes or /tutor implied volatility for an
adaptive walkthrough.