Fixed-Income Project
Zero-Coupon Yield Extraction, Bond Valuation, and Liability Hedging
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.
import pandas as pd
import numpy as np
from scipy.optimize import fsolve
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.
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)}} $$
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:
| 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.
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.