Numerical Methods and Algorithms in Finance › Worked Example: A Toy Callable-Bond Workflow

Michael BrenndoerferNovember 1, 20257 min read
Quantitative FinanceAll chapters
Part 1 of 15Mathematical Foundations
Chapter 8Numerical Methods and Algorithms in Finance › Worked Example: A Toy Callable-Bond Workflow
Reading section 5 of 10 · Worked Example: A Toy Callable-Bond Workflow · 7 minSection 5 of 10 · 7 min

Book contents

Section 5 of 10 · 7 min read

Numerical Methods and Algorithms in Finance › Worked Example: A Toy Callable-Bond WorkflowOne section · Sections in this chapter5 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's combine these numerical methods in a deliberately simplified illustration for a bond with one embedded call date. A callable bond gives the issuer the right to redeem it on specified call dates; falling rates often increase the incentive to exercise that right. This is not a realistic OAS engine: it uses one call date, a static normally distributed rate shift, truncated integration, and a deterministic spline curve. It omits calibrated arbitrage-free rate dynamics, a full exercise schedule, and model-risk controls.

First, we'll construct a yield curve from synthetic illustrative inputs:

In[31]:
Code
# Synthetic illustrative yield-curve inputs
market_maturities = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10])
market_rates = np.array([5.0, 5.1, 5.2, 5.0, 4.8, 4.7, 4.8, 5.0]) / 100

# Build interpolated yield curve using cubic splines
yield_curve = CubicSpline(market_maturities, market_rates)


def discount_factor(t):
    """Calculate discount factor for time t using the yield curve."""
    if t <= 0:
        return 1.0
    rate = yield_curve(t)
    return np.exp(-rate * t)

Now let's price a callable bond. The bond has a 6% annual coupon, integer-year maturity 10, and one par call at year 5 immediately after that year's coupon. This continuous normal-density implementation requires positive finite rate-shift dispersion. It deliberately reuses today's zero curve at residual tenors at the call date, rather than using the initial curve's implied forward discount ratios D(0,t)/D(0,5)D(0,t)/D(0,5). Even with zero rate shift, the two valuation legs therefore do not form a calibrated arbitrage-free term-structure model.

In[32]:
Code
from scipy.integrate import quad


def price_callable_bond(
    coupon_rate,
    face_value,
    maturity,
    call_date,
    call_price,
    yield_curve_func,
    oas=0.0,
    vol=0.01,
):
    """
    Illustrate callable-bond calculations using numerical integration.

    Simplified model: the bond is called if the straight bond value
    exceeds the call price just after the call-date coupon.
    """
    if not np.all(
        np.isfinite(
            [coupon_rate, face_value, maturity, call_date, call_price, oas, vol]
        )
    ):
        raise ValueError("Callable-bond inputs must be finite")
    if (
        maturity < 1
        or int(maturity) != maturity
        or not 0 < call_date < maturity
        or int(call_date) != call_date
    ):
        raise ValueError(
            "Require integer annual maturity and call date, with 0 < call_date < maturity"
        )
    if coupon_rate < 0 or face_value <= 0 or call_price <= 0 or vol <= 0:
        raise ValueError(
            "Require nonnegative coupon and positive face value, call price, and vol"
        )
    # Annual coupon payments
    payment_dates = np.arange(1, maturity + 1)
    coupon = coupon_rate * face_value

    def discount_factor_oas(t, rate_shift=0.0):
        """Calculate discount factor using yield curve plus OAS."""
        if t <= 0:
            return 1.0
        rate = float(yield_curve_func(t)) + oas + rate_shift
        factor = np.exp(-rate * t)
        if not np.isfinite(rate) or not np.isfinite(factor):
            raise RuntimeError("Toy discount factor is not finite")
        return factor

    # Price straight bond (without call feature)
    straight_bond_pv = 0
    for t in payment_dates:
        cf = coupon if t < maturity else coupon + face_value
        straight_bond_pv += cf * discount_factor_oas(t)

    # Simple model for call value using numerical integration
    # Integrate over possible rate scenarios at call date
    def call_value_integrand(rate_shift):
        def shifted_df(t):
            return discount_factor_oas(t, rate_shift=rate_shift)

        # Toy convention: reset today's curve at residual tenor, not forward ratios.
        remaining_pv = 0
        for t in payment_dates[payment_dates > call_date]:
            cf = coupon if t < maturity else coupon + face_value
            remaining_pv += cf * shifted_df(t - call_date)

        # Call exercised if remaining value > call price
        call_benefit = max(remaining_pv - call_price, 0)

        # Normal density for rate shift
        density = np.exp(-0.5 * (rate_shift / vol) ** 2) / (vol * np.sqrt(2 * np.pi))

        return call_benefit * density

    # Integrate over rate scenarios
    call_option_value, _ = quad(call_value_integrand, -4 * vol, 4 * vol)

    # Discount call option value to today
    call_option_value *= discount_factor_oas(call_date)

    # Callable bond = straight bond - call option (issuer benefits from call)
    callable_bond_price = straight_bond_pv - call_option_value

    return {
        "straight_bond_price": straight_bond_pv,
        "call_option_value": call_option_value,
        "callable_bond_price": callable_bond_price,
    }


# Price the callable bond
result = price_callable_bond(
    coupon_rate=0.06,
    face_value=1000,
    maturity=10,
    call_date=5,
    call_price=1000,
    yield_curve_func=yield_curve,
    vol=0.015,  # 1.5 percentage-point rate-shift standard deviation
)
Out[33]:
Console
Callable Bond Valuation:
  Straight bond price:   $1070.34
  Call option value:     $49.07
  Callable bond price:   $1021.28

  The call feature reduces the bond's value to investors by $49.07

Under these synthetic inputs, the straight bond price exceeds par because the 6% coupon is above the illustrative curve. The one-date static-shift model assigns the embedded call an option value of approximately $49.07, which is deducted from the straight-bond value.

Now let's use root-finding to calculate the option-adjusted spread (OAS), which is the constant spread over the yield curve that equates the model price to a market price:

In[34]:
Code
def price_with_oas(
    oas,
    coupon_rate,
    face_value,
    maturity,
    call_date,
    call_price,
    yield_curve_func,
    vol=0.01,
):
    """Illustrate a callable-bond price with a spread over the toy curve."""
    result = price_callable_bond(
        coupon_rate=coupon_rate,
        face_value=face_value,
        maturity=maturity,
        call_date=call_date,
        call_price=call_price,
        yield_curve_func=yield_curve_func,
        oas=oas,
        vol=vol,
    )
    return result["callable_bond_price"]


def find_oas(
    market_price,
    coupon_rate,
    face_value,
    maturity,
    call_date,
    call_price,
    yield_curve_func,
    vol=0.01,
    tol=1e-8,
    price_tol=1e-8,
    max_iter=50,
):
    """Fit a spread in the toy model using Newton-Raphson."""
    validate_solver_controls(tol, max_iter)
    validate_solver_controls(price_tol, max_iter)
    if not np.isfinite(market_price) or market_price <= 0:
        raise ValueError("market_price must be finite and positive")

    def objective(oas):
        return (
            price_with_oas(
                oas,
                coupon_rate,
                face_value,
                maturity,
                call_date,
                call_price,
                yield_curve_func,
                vol,
            )
            - market_price
        )

    # Newton-Raphson with numerical derivative
    oas = 0.01  # Initial guess: 100 bps
    for _ in range(max_iter):
        f = objective(oas)
        fp = (objective(oas + 0.0001) - objective(oas - 0.0001)) / 0.0002

        if not np.isfinite(f) or not np.isfinite(fp) or fp == 0:
            raise RuntimeError("OAS residual or derivative is not usable")

        oas_new = oas - f / fp
        if not np.isfinite(oas_new):
            raise RuntimeError("OAS step is not finite")

        if abs(oas_new - oas) < tol and abs(objective(oas_new)) < price_tol:
            return oas_new
        if oas_new == oas:
            raise RuntimeError("OAS stagnated before the price tolerance was met")

        oas = oas_new

    raise RuntimeError(f"OAS solver did not converge in {max_iter} iterations")


# Suppose the callable bond trades at $1050
market_price = 1050
oas = find_oas(market_price, 0.06, 1000, 10, 5, 1000, yield_curve, vol=0.015)
Out[35]:
Console
For a market price of $1050.00:
  Option-Adjusted Spread: -56.0 basis points
  A negative fitted OAS indicates rich pricing relative to
  the option-adjusted benchmark curve under this toy model.

Here tol measures the spread step in decimal-rate units, while price_tol measures the toy repricing residual in monetary units; both must pass. Inner quadrature accuracy and the truncated rate-shift range remain separate error sources, so a small outer residual is not a certificate of realistic pricing accuracy.

OAS is the model-dependent constant spread over a specified benchmark curve that makes the option-adjusted model price match the observed price. Its sign is relative to that benchmark and model; it is not a pure credit component and can also absorb liquidity and model effects. With consistent benchmark and optionality assumptions, OAS can support relative-value comparisons across bonds.

This toy example demonstrates how root-finding, interpolation, and integration can be linked. It is not a realistic OAS engine: it uses one call date, a static normally distributed rate shift, truncated integration, and a deterministic spline curve, without calibrated arbitrage-free rate dynamics, a full exercise schedule, or model-risk controls.

The fitted OAS of approximately -56 basis points indicates that this callable bond is rich relative to the selected option-adjusted curve in this simplified model. The value should not be compared with another bond unless the benchmark and model assumptions are consistent.