Options models for fixed income

Callable and puttable bonds on a rate tree, a convertible, the prepayment option inside a mortgage pool, Merton's credit model and Hull-White. Five options that sit inside bonds, each run on free data from 2026-10-13.

Five options inside bonds

Most bonds carry an option somebody can exercise against the holder, or one the holder owns. Each model below prices one of them, on the Treasury curve of 2026-10-13 and free market data.

Data comes from FRED, Yahoo Finance and SEC filings. No API key and no subscription; the SEC only asks for a name and an email in the request header. This page shows the charts and the core of each model; the full code, every chart and the numbers file are in the repository on GitHub.

Read the limitations before quoting any number here. Rate vols come from history, the convertible is hypothetical, and the mortgage pool is priced at par.

All the code is on GitHub

Every model, chart and number on this page is in the repository, ready to clone and run on free data.

Watch the video on convertible and callable bonds

Three real bonds and the option inside each one, priced on free data: the DoorDash 0% convertible due 2030, the Viking Cruises 9.125% callable due 2031 and the Strategy 0% putable convertible due 2029. Black-Scholes, a Tsiveriotis-Fernandes tree and an interest rate tree with OAS, then convertible arbitrage and how to read a callable and a putable as trades. Code for the video in quant-models.

Convertible and Callable Bonds: Pricing the Hidden Options

On X

Follow me on X, my models are there

I post the models as I build them, the changes I make to them, and the results that did not work. Shorter than these write ups, and there are a lot more of them.

Follow me on X
Newsletter

Get exclusive models, prompts and code

One short email when there's something new. finance models, code walkthroughs, agentic prompts. No spam.

Built with
Anthropic Claude Python R ChatGPT GitHub Jupyter Excel PowerPoint VS Code SQL MATLAB

All the code is on GitHub

Each section below explains one model, shows its charts and the core of its code. The repository holds the rest: data loading, calibration, every chart, and the script that reproduces every number on the page.

1. Callable and puttable bonds

A callable bond lets the issuer buy it back, usually at par after a few years of call protection, so the holder is short a call. When rates fall the issuer refinances and the bond is called away, so its price stalls near the call price: negative convexity. A puttable lets the holder sell it back at par, and that put sets a floor under the price when rates rise.

Price of a straight, callable and puttable 10 year bond against the 10 year Treasury yield
Same 5.25% 10 year bond three ways, the whole Treasury curve shifted up and down and the tree refitted each time. Today's 10 year yield is 5.21%.

A Black-Derman-Toy tree reprices every discount factor of the 2026-10-13 curve, with a rate vol of 14% measured from one year of the 1 year yield. Stepping back from maturity and exercising wherever it pays puts the issuer's call at 2.88 per 100 and the holder's put at 4.14.

10 year 5.25% bond, call and put at par from year 2, per 100 face
BondPriceOption
Straight100.02
Callable97.14issuer's call 2.88
Puttable104.15holder's put 4.14
Black-Derman-Toy short rate tree, nodes coloured by who exercises
First five years of the tree. Orange nodes are where the issuer calls (low rates), cyan where the holder puts (high rates).

Pricing a callable or puttable bond on the tree

def bond_price(R, dt, T, coupon, kind="straight", K=100.0, first=2.0, freq=2):
    """Backward induction on the BDT short rate tree R. "call": the issuer calls
    at K on any coupon date from year `first`; "put": the holder puts at K."""
    n, step, c = int(round(T / dt)), int(round(1 / (freq * dt))), 100 * coupon / freq
    V = np.full(n + 1, 100.0 + c)
    for i in range(n - 1, -1, -1):
        V = np.exp(-R[i, :i + 1] * dt) * 0.5 * (V[1:i + 2] + V[:i + 1])
        if i > 0 and i % step == 0:
            if i * dt >= first:
                V = np.minimum(V, K) if kind == "call" else np.maximum(V, K) if kind == "put" else V
            V = V + c
    return float(V[0])

2. Convertible bonds

A convertible is a bond the holder can swap for a fixed number of shares, so it sits on two floors: the bond and the stock. Tsiveriotis and Fernandes (1998) split its value on a stock tree. Cash the bond still owes is discounted with credit risk, and the part that ends up as shares is discounted risk free.

Take UBER at $71.32, one year realised vol of 35%, and a hypothetical 5 year 1% convertible that converts 30% above today's price. With cash discounted at Treasuries plus the 1.03% ICE BofA BBB spread, the bond alone is worth 78.4 and the shares 76.9. On the tree the convertible is worth 102.6, so the right to convert adds 24.1.

Convertible value against the UBER share price, with the bond floor and the conversion value
Value against the share price: close to the bond floor when the stock falls, close to the shares when it rises, above both in between.
Share of the convertible's value in the equity part and the cash part
Tsiveriotis-Fernandes split. The higher the stock, the more of the value is equity and the less of it carries credit risk.

Tsiveriotis-Fernandes, backward through the stock tree

for i in range(n - 1, -1, -1):
    S = S[:-1] / u
    B = np.exp(-(r + spread) * dt) * (p * B[:-1] + (1 - p) * B[1:])   # credit risky
    E = np.exp(-r * dt) * (p * E[:-1] + (1 - p) * E[1:])              # risk free
    if i > 0 and i % step == 0:
        B = B + coupon
    convert = CR * S > B + E
    E = np.where(convert, CR * S, E)
    B = np.where(convert, 0.0, B)
price = B[0] + E[0]

3. Mortgage-backed security prepayment option

A US mortgage can be repaid at par any day with no penalty, so every homeowner holds a call: when rates fall, they refinance. A mortgage-backed security pools thousands of these loans, and its holder is short all those calls. Prices stop rising when rates fall, and the bond gets longer when they rise.

Price of a new MBS pool and of the same loans with no refinancing, against the mortgage rate
New 7.00% pool at the 7.40% Freddie Mac rate of 2026-10-08, on 4,000 Hull-White paths. The shaded gap is the value of the homeowners' call.

Rates come from Hull-White paths with the vol set so the 10 year yield moves 67 bp a year, as it did over the last year, and a simple S-curve decides who refinances. If rates fall 1 point, the pool gains 3.1, against 7.6 for the same loans if nobody could refinance. If they rise 1 point, it loses 5.4.

New 30 year pool, whole Treasury curve shifted, price per 100
RatesMBS poolNo refinancing
Down 1 point+3.1+7.6
Up 1 point-5.4-6.7
Average life of the pool against the mortgage rate
Average life: 9.6 years today, 1.8 if rates fall 2.5 points, 11.7 if they rise 2.5. When rates rose in 2022, bank portfolios full of MBS got longer exactly when it hurt.
Freddie Mac 30 year mortgage rate since 1990 against today's rate
Loans taken in 2020 and 2021 pay 2.65% to 3.65%. At 7.40% those calls sit deep out of the money, which is why so few people move house.

Pool cash flows on each rate path, with the refinancing S-curve

for k in range(360):
    sched = bal * w / (1 - (1 + w) ** -(360 - k)) - bal * w       # scheduled principal
    cpr = 0.06 * min(1.0, (k + 1) / 30)                            # housing turnover
    cpr = cpr + 0.45 / (1 + np.exp(-((wac - m[:, k]) - 0.01) / 0.002))   # refinancing
    prep = (1 - (1 - cpr) ** (1 / 12)) * (bal - sched)
    cf[:, k] = bal * coupon / 12 + sched + prep
    bal = bal - sched - prep

price = (cf * disc * np.exp(-oas * t)).sum(1).mean()               # average over the paths

4. Merton credit model

Merton showed in 1974 that a company's shares are a call option on its assets, struck at what it owes. If the assets end below the debt, shareholders walk away and lenders take the firm. So a corporate bond is a safe bond minus a put on the company. Moody's EDF default model grew out of this idea.

Boeing equity as a call on its assets, and debt as a safe bond minus a put
Equity and debt against the value of Boeing's assets. Equity bends like a call around the default point, debt flattens like a short put.

Boeing's shares are worth $147bn and swing 34% a year, against a default point of $25bn: debt due within a year plus half the rest, the KMV convention. Solving the two Merton equations backs out $172bn of assets at 29% vol, 6.6 standard deviations from default. In November 2020, with debt more than doubled and the Covid crash inside its one year vol, it was 1.75.

Boeing distance to default, weekly since 2018
Distance to default, weekly. Balance sheets from SEC filings, carried forward from each quarter end; equity vol over the trailing year.
Same model on five companies, 2026-10-13
CompanyEquityDebtEquity volDistance to default
Carnival$36bn$24bn49%3.6
Intel$519bn$51bn76%3.9
AT&T$157bn$144bn27%6.0
Boeing$147bn$46bn34%6.6
Apple$4,981bn$84bn24%19.2
Distance to default for Carnival, Intel, AT&T, Boeing and Apple
Carnival and Intel sit closest to default on this measure, Apple furthest.

Solving Merton's two equations

def solve(E, sE, D, r, T=1.0):
    """Asset value and asset vol from equity value and equity vol."""
    def gap(z):
        V, sV = z
        d1 = (np.log(V / D) + (r + sV**2 / 2) * T) / (sV * np.sqrt(T))
        d2 = d1 - sV * np.sqrt(T)
        return [(V * norm.cdf(d1) - D * np.exp(-r * T) * norm.cdf(d2) - E) / E,
                (norm.cdf(d1) * sV * V - sE * E) / E]
    V, sV = fsolve(gap, [E + D, sE * E / (E + D)])
    dd = (np.log(V / D) + (r - sV**2 / 2) * T) / (sV * np.sqrt(T))
    return V, sV, dd

D = debt_short + 0.5 * debt_long          # KMV default point

5. Hull-White short rate model

Hull-White treats the short rate like a spring. It reverts at speed a toward a target path, fitted so today's curve comes out exactly, while shocks of size sigma push it around. Rates are normally distributed, which gives closed forms for bond options, caps and swaptions.

Fitted on month end 3 month T-bill yields since 1990, a comes out at 0.12, a half life of 5.8 years for any shock, and sigma at 0.71% a year. On 5,000 exact paths the model reprices the 5 year Treasury zero within 0.5 bp, and 90% of paths put the 3 month rate between 3.2% and 7.3% five years out.

Hull-White short rate paths over ten years with percentile bands
Short rate paths, the 5 to 95% and 25 to 75% bands, and today's forward curve that the mean follows.
Distribution of the 3 month rate in five years on Hull-White paths
Where the 3 month rate lands in five years, across 5,000 paths.

Calibration and exact simulation

# a and sigma from an AR(1) on month end 3 month T-bill yields
phi, c0 = np.polyfit(bills[:-1], bills[1:], 1)
a = -np.log(phi) * 12
sigma = residuals.std() * np.sqrt(2 * a / (1 - np.exp(-2 * a / 12)))

# exact simulation: r = x + alpha, alpha puts the mean on today's forward curve
decay, sd = np.exp(-a * dt), sigma * np.sqrt((1 - np.exp(-2 * a * dt)) / (2 * a))
for i in range(steps):
    x[:, i + 1] = x[:, i] * decay + sd * rng.standard_normal(N)
alpha = f0 + sigma**2 / (2 * a**2) * (1 - np.exp(-a * t))**2
r = x + alpha

Run it

Full code in the repository: one file per model, the Treasury curve, the data loaders and the script that writes every chart and number. Data is cut at 2026-10-13, so a run on a later day gives the numbers on this page.

Clone and run

git clone https://github.com/davidalmeida90/quant-models
cd quant-models/fixed-income-options-models
pip install -r ../requirements.txt
export SEC_USER_AGENT="Your Name your@email"     # the SEC API asks for one
python run_all.py                                # charts/ and charts/facts.json

Limitations


Every run on one line

Free data, as of 2026-10-13
ModelPricesResult
Black-Derman-Toy10 year bond, call or put from year 2100.02 / 97.14 / 104.15
Tsiveriotis-Fernandes5 year UBER convertible102.6, option 24.1
MBS prepaymentnew pool, rates down 1 point+3.1 vs +7.6
MertonBoeing distance to default6.6, low 1.75
Hull-White5 year Treasury zerowithin 0.5 bp