Fixed-Income Project

Zero-Coupon Yield Extraction, Bond Valuation, and Liability Hedging

Prepared by: Hai Nam Nguyen
This notebook presents an end-to-end fixed-income workflow: discount curve construction from market yields, coupon bond valuation, rate-risk measurement (Fisher-Weil duration and convexity), and construction of a duration-matched strategy to immunise a future liability.

Technical stack: Python, NumPy, pandas, SciPy (fsolve)

1. Data and Environment Setup

Import the numerical stack and prepare Australian Government Bond yield data for downstream valuation and hedging.

In [24]:
import pandas as pd
import numpy as np
from scipy.optimize import fsolve
In [25]:
df = pd.read_excel('RBAbondyields.xlsx', header = 10)
df.columns =["Date","YTM 2Y","YTM 3Y","YTM 5Y","YTM 10Y"]
df = df.dropna()
df = df.reset_index(drop=True)
df = df.sort_values(by = "Date")
df.iloc[:, 1:] = df.iloc[:, 1:] / 100
display(df)
Date YTM 2Y YTM 3Y YTM 5Y YTM 10Y
0 2013-09-02 0.02594 0.02800 0.03258 0.03989
1 2013-09-03 0.02652 0.02857 0.03312 0.04030
2 2013-09-04 0.02714 0.02925 0.03373 0.04055
3 2013-09-05 0.02752 0.02968 0.03412 0.04106
4 2013-09-06 0.02790 0.03007 0.03455 0.04177
... ... ... ... ... ...
2544 2024-02-29 0.03754 0.03702 0.03774 0.04141
2545 2024-03-01 0.03764 0.03712 0.03782 0.04146
2546 2024-03-04 0.03732 0.03682 0.03752 0.04106
2547 2024-03-05 0.03732 0.03682 0.03749 0.04096
2548 2024-03-06 0.03676 0.03624 0.03683 0.04012

2549 rows × 5 columns

2. Project Stage 1: Discount Curve Construction

Infer discount factors from observed par yields at 2Y, 3Y, 5Y, and 10Y maturities.

This stage converts observed par-yield data into a usable set of discount factors for fixed-income valuation.

Modelling Assumptions¶

  • The face value of each Australian Government Bond is normalized to $F=1$.
  • Rows with missing yields across 2Y, 3Y, 5Y, or 10Y maturities are removed to preserve consistency.
  • Bonds are treated as trading at par, so price equals face value ($P=F=1$) and coupon rate equals yield-to-maturity for that date.
  • Semi-annual coupon payments are assumed, with payment spacing of $0.5$ years.

Bond Pricing Framework¶

For maturity $T_i$, the coupon cash flow is:

$$ C_i = \frac{y(0,T_i)F}{2} $$

The par-bond pricing condition is:

$$ P = \sum_{i=1}^{n} e^{-y(0,T_i)T_i}C_i + e^{-y(0,T_n)T_n}F $$

With $P=1$, discount factors are solved for key maturities $T\in\{2,3,5,10\}$ each day.

Interpolation of Zero-Coupon Prices¶

Because cash-flow dates do not always align with available maturities, missing discount factors are estimated via log-linear interpolation of zero-coupon prices.

If $B(0,T_i)$ and $B(0,T_{i+1})$ are known and $T_i\le\tau\le T_{i+1}$:

$$ \ln B(0,\tau)=\ln B(0,T_i)+\frac{\tau-T_i}{T_{i+1}-T_i}\left(\ln B(0,T_{i+1})-\ln B(0,T_i)\right) $$

Numerical Constraints¶

  • Discount factors above 1 (consistent with negative yields) are economically valid and therefore acceptable within the analysis.
  • Discount factors below 0 are disallowed under no-arbitrage reasoning.
In [26]:
def Stage_1():
    F = 1
    
    def log_interpolate(T: float, x: np.ndarray) -> float:
        B_2, B_3, B_5, B_10 = x
        if T > 10 or T < 0:
            raise ValueError("T must be between 0 and 10")
        T_nodes = np.array([0,2,3,5,10])
        B_nodes = np.array([1, B_2, B_3, B_5, B_10])
        interpolation = np.exp(np.interp(T, T_nodes, np.log(B_nodes)))
        return interpolation

    df_result = pd.DataFrame(columns=["Date", "B_2", "B_3", "B_5", "B_10"])
    df_result["Date"] = df["Date"]

    def objective(x: np.ndarray, C: np.ndarray) -> np.ndarray:
        B_list = np.array([log_interpolate(j ,x) for j in np.arange(0.5, 10.5, 0.5)])
        P = np.array([])
        for k in range(len(C)):
            Maturity = np.array([2,3,5,10])
            P = np.append(P, C[k]/2 * np.sum(B_list[0: Maturity[k]*2]) + F * B_list[Maturity[k]*2 - 1])
        return P - 1
    
    for index, element in df.iterrows():
        C = element[["YTM 2Y", "YTM 3Y", "YTM 5Y","YTM 10Y"]].values
        B_result = fsolve(objective, np.ones(4), args=(C,))
        df_result.loc[index, ["B_2", "B_3", "B_5", "B_10"]] = B_result
        for b_val in B_result:
            if b_val < 0:
              print("Drop row with negative zero bond price")
              print(df_result.iloc[[index]])
              df_result = df_result.drop(index)
            if b_val > 1:
              print("Discount factor exceeding 1 - Accepted")
              print(df_result.iloc[[index]])
              
    return df_result

stage_1_result = Stage_1()
display(stage_1_result)
Discount factor exceeding 1 - Accepted
           Date      B_2       B_3       B_5     B_10
1859 2021-06-11  1.00006  0.997541  0.969311  0.86346
Date B_2 B_3 B_5 B_10
0 2013-09-02 0.94976 0.919807 0.849641 0.667184
1 2013-09-03 0.948673 0.918255 0.847375 0.664542
2 2013-09-04 0.947513 0.916402 0.844826 0.663155
3 2013-09-05 0.946802 0.915231 0.843198 0.65968
4 2013-09-06 0.946093 0.914173 0.841393 0.654766
... ... ... ... ... ...
2544 2024-02-29 0.928315 0.895846 0.829368 0.660893
2545 2024-03-01 0.928133 0.895582 0.829047 0.660592
2546 2024-03-04 0.928716 0.896372 0.830268 0.663289
2547 2024-03-05 0.928716 0.896372 0.830397 0.664004
2548 2024-03-06 0.929738 0.897906 0.833115 0.669703

2549 rows × 5 columns

3. Project Stage 2: Bond Valuation and Risk Metrics

Price selected bonds on the valuation date and compute Fisher-Weil duration and convexity.

For Stage 2, the valuation date is set to 21 April 2021.

The bonds $\mathcal{B}_1,\mathcal{B}_2,\mathcal{B}_3,\mathcal{B}_4$ pay semi-annual coupons on predetermined schedules, with principal repaid at maturity. Where issue dates do not align with regular coupon schedules, the first period may be a stub.

First Coupon Dates¶

  • $\mathcal{B}_1$: 21 July 2021
  • $\mathcal{B}_2$: 21 July 2021
  • $\mathcal{B}_3$: 21 May 2021
  • $\mathcal{B}_4$: 21 June 2021

Each first coupon is more than 7 days after valuation date, so the buyer is assumed entitled to the first coupon payment.

Subsequent coupon dates are generated every six months. Time conversion uses a month-based convention where one month equals $\frac{1}{12}$ of a year.

Discount factors from Stage 1 are available at $T\in\{2,3,5,10\}$. For intermediate maturities, log-linear interpolation is applied. For example, at $T=2.5$:

$$ \ln B(0,2.5)=\ln B(0,2)+\frac{2.5-2}{3-2}\left(\ln B(0,3)-\ln B(0,2)\right) $$

Using interpolated discount factors and coupon cash flows, bond present value is computed by discounting all future payments:

$$ V(0)=\sum_{j=1}^{n} C_j\,B(0,T_j) $$

with semi-annual coupon amount $C_j=\frac{\text{Coupon Rate}\times F}{2}$ and $F=1$.

After pricing all bonds, interest-rate sensitivity is measured using Fisher-Weil duration and convexity.

For a small parallel shift $x$ in the term structure, define shifted value:

$$ V(x)=\sum_{j=1}^{n} C_j e^{-\left(y(0,T_j)+x\right)T_j} $$

Using a second-order Taylor approximation:

$$ \Delta V\approx \frac{dV(0)}{dx}x+\frac{1}{2}\frac{d^2V(0)}{dx^2}x^2 $$

and in relative form:

$$ \frac{\Delta V}{V(0)}\approx -\mathcal{D}_{FW}x+\frac{1}{2}\mathcal{C}x^2 $$

Fisher-Weil Duration¶

$$ \mathcal{D}_{FW}=-\frac{1}{V(0)}\frac{dV(0)}{dx}\Big|_{x=0} =\frac{1}{V(0)}\sum_{j=1}^{n}T_j C_j e^{-y(0,T_j)T_j} $$

Convexity¶

$$ \mathcal{C}=\frac{1}{V(0)}\frac{d^2V(x)}{dx^2}\Big|_{x=0} =\frac{1}{V(0)}\sum_{j=1}^{n}T_j^2 C_j e^{-y(0,T_j)T_j} $$

In discount-factor form:

$$ \boxed{\mathcal{D}_{FW}=\frac{\sum_{j=1}^{n}T_j C_j B(0,T_j)}{\sum_{j=1}^{n}C_j B(0,T_j)}} $$

$$ \boxed{\mathcal{C}=\frac{\sum_{j=1}^{n}T_j^2 C_j B(0,T_j)}{\sum_{j=1}^{n}C_j B(0,T_j)}} $$

In [27]:
today = pd.to_datetime("2021-04-21")

def Stage_2() -> pd.DataFrame:
  F = 1
  df_bond = pd.DataFrame(columns = ["Bond","Maturity", "Coupon rate (%)"])
  df_bond["Bond"] = np.array(["B1","B2","B3","B4"])
  df_bond["Maturity"] = pd.to_datetime(["2025-04-21","2026-07-21","2028-05-21","2029-12-21"])
  df_bond["Coupon rate (%)"] = np.array([3.25, 4.25, 2.25, 1])

  stage_1_B_row = stage_1_result[stage_1_result["Date"] == pd.to_datetime("2021-04-21")]

  if stage_1_B_row.empty:
      raise ValueError(f"No discount factors found for date {today.strftime('%Y-%m-%d')}")
  B_valid_values = stage_1_B_row.iloc[0, 1:].values

  def B_interpolate(T:float) -> float:
    if T > 10 or T < 0:
      raise ValueError("T must be between 0 and 10")
    log_input_array = np.insert(B_valid_values, 0, 1.0).astype(float)
    interpolation = np.exp(np.interp(T, np.array([0,2,3,5,10]), np.log(log_input_array)))
    return interpolation

  def year_transform(y: pd.Timestamp) -> float:
    time_stamp = ((y.year - today.year)*12 + (y.month - today.month)) / 12
    return time_stamp

  C_list = []
  T_list = []
  DF_list = []
  P_list = []

  for index, element in df_bond.iterrows():
    maturity_dt = element["Maturity"]
    coupon_rate = element["Coupon rate (%)"]
    coupon_amount_per_period = (coupon_rate / 100) / 2 * F

    each_bond_CF_list = []
    each_bond_T_list = []
    each_bond_DF_list = []

    final_cash_flow = F + coupon_amount_per_period
    final_time = year_transform(maturity_dt)
    final_df = B_interpolate(final_time)

    each_bond_CF_list.append(final_cash_flow)
    each_bond_T_list.append(final_time)
    each_bond_DF_list.append(final_df)

    Price = final_cash_flow * final_df

    current_coupon_date = maturity_dt - pd.DateOffset(months=6)

    while current_coupon_date > today:
      coupon_time = year_transform(current_coupon_date)
      df_for_coupon = B_interpolate(coupon_time)

      each_bond_CF_list.append(coupon_amount_per_period)
      each_bond_T_list.append(coupon_time)
      each_bond_DF_list.append(df_for_coupon)

      Price += coupon_amount_per_period * df_for_coupon

      current_coupon_date -= pd.DateOffset(months=6)

    C_list.append(np.array(each_bond_CF_list))
    T_list.append(np.array(each_bond_T_list))
    DF_list.append(np.array(each_bond_DF_list))
    P_list.append(Price)

  for i in range(len(C_list)):
    if len(C_list[i]) != len(T_list[i]) or len(C_list[i]) != len(DF_list[i]) or len(T_list[i]) != len(DF_list[i]):
      print(f"Error: Mismatch in lengths for bond {i}")

  def quad_approx() -> tuple[list[float], list[float]]:
    all_FW_durations = []
    all_convexity = []
    for i in range(len(C_list)):
      single_bond_CF = C_list[i]
      single_bond_T = T_list[i]
      single_bond_DF = DF_list[i]

      pv_C = single_bond_CF * single_bond_DF
      sum_pv = np.sum(pv_C)
      D_FW = np.sum(single_bond_T * pv_C) / sum_pv
      convex_correction = np.sum(single_bond_T **2 * pv_C) / sum_pv

      all_FW_durations.append(D_FW)
      all_convexity.append(convex_correction)

    return all_FW_durations, all_convexity

  FWD , CC = quad_approx()

  df_bond["Price ($)"] = P_list
  df_bond["FW Duration"] = FWD
  df_bond["Convexity"] = CC

  return df_bond

stage_2_result = Stage_2()
print("The results of Stage 2 are:")
stage_2_result
The results of Stage 2 are:
Out[27]:
Bond Maturity Coupon rate (%) Price ($) FW Duration Convexity
0 B1 2025-04-21 3.25 1.110608 3.795462 14.875304
1 B2 2026-07-21 4.25 1.190380 4.760826 24.141345
2 B3 2028-05-21 2.25 1.077364 6.540017 45.115025
3 B4 2029-12-21 1.00 0.960987 8.275156 70.627548

4. Project Stage 3: Liability Immunisation Strategy

Build a two-bond immunisation portfolio that matches liability value and neutralises first-order rate exposure.

This stage uses Stage 2 outputs for $\mathcal{B}_1$ and $\mathcal{B}_4$, specifically bond values and Fisher-Weil durations.

Let $Q_1$ and $Q_2$ denote units invested in $\mathcal{B}_1$ and $\mathcal{B}_4$, and let the present value of the liability of $120 million be $V_L(0)$.

Constraint 1: Present-Value Matching¶

The hedge portfolio must match liability value:

$$ \boxed{Q_1V_{\mathcal{B}_1}(0)+Q_2V_{\mathcal{B}_4}(0)-V_L(0)=0} $$

Constraint 2: Duration Neutrality¶

The hedged position should have zero net first-order sensitivity to a small parallel curve shift:

$$ Q_1\frac{dV_{\mathcal{B}_1}(x)}{dx}\Big|_{x=0} +Q_2\frac{dV_{\mathcal{B}_4}(x)}{dx}\Big|_{x=0} -\frac{dV_L(x)}{dx}\Big|_{x=0}=0 $$

Using Fisher-Weil duration,

$$ \mathcal{D}_{FW}=-\frac{1}{V(0)}\frac{dV(0)}{dx}\Big|_{x=0} $$

the second equation is written as:

$$ \boxed{V_L(0)\mathcal{D}_{FW}(L)-Q_1V_{\mathcal{B}_1}(0)\mathcal{D}_{FW}(\mathcal{B}_1)-Q_2V_{\mathcal{B}_4}(0)\mathcal{D}_{FW}(\mathcal{B}_4)=0} $$

Solving the two equations yields hedge quantities that meet both funding and duration-immunization requirements.

In [28]:
def Stage_3():
  data_task_1 = stage_1_result[stage_1_result["Date"] == today]
  data_task_2 = stage_2_result[(stage_2_result["Bond"] == "B1") | (stage_2_result["Bond"] == "B4")][["Price ($)","FW Duration"]]
  liability = 120000000
  pv_B1 = data_task_2.iloc[0,0]
  pv_B4 = data_task_2.iloc[1,0]
  fw_B1 = data_task_2.iloc[0,1]
  fw_B4 = data_task_2.iloc[1,1]

  if data_task_1.empty:
    raise ValueError(f"No discount factors found for date {today.strftime('%Y-%m-%d')}")

  B_values = data_task_1.iloc[0, 1:].values

  def B_interpolate(T):
    if T > 10 or T < 0:
      raise ValueError("T must be between 0 and 10")
    log_input_array = np.insert(B_values, 0, 1.0).astype(float)
    interpolation = np.exp(np.interp(T, np.array([0,2,3,5,10]), np.log(log_input_array)))
    return interpolation

  def year_transform(y):
    time_stamp = ((y.year - today.year)*12 + (y.month - today.month)) / 12
    return time_stamp

  def target(x):
    Q1, Q2 = x
    pv_liability = liability * B_interpolate(year_transform(pd.to_datetime("2027-04-21")))
    fw_liability = year_transform(pd.to_datetime("2027-04-21"))
    net_pv = Q1 * pv_B1 + Q2 * pv_B4 - pv_liability
    net_FW = fw_liability * pv_liability - Q1 * fw_B1 * pv_B1 - Q2 * fw_B4 * pv_B4
    return [net_pv, net_FW]

  Q_result = fsolve(target, [500000,500000])

  return Q_result

stage_3_result = Stage_3()
print("The results of Stage 3 are:")
print("The long position in Bond 1 should be", stage_3_result[0])
print("The long position in Bond 4 should be", stage_3_result[1])
The results of Stage 3 are:
The long position in Bond 1 should be 51583772.57376712
The long position in Bond 4 should be 57764763.579416126

Stage 3 Conclusion¶

Stage 3 delivers a solution by constructing a two-bond hedge using $\mathcal{B}_1$ and $\mathcal{B}_4$. The portfolio is calibrated to satisfy both key requirements: matching the present value of the $\$120$ million liability and neutralizing first-order interest-rate sensitivity through duration matching.

As a result, the hedged position is robust to small parallel shifts in the yield curve while remaining fully funded on 21 April 2027. This demonstrates how valuation outputs from Stage 2 can be directly translated into an implementable fixed-income risk management strategy.