Financial Markets Instruments — Bond Analytics Report
Interest Rate Term Structure Modelling and Hedging Using Australian Government Bonds
- Deriving discount factors using bond prices and interpolation techniques
- Fitting the Nelson–Siegel yield curve model to market data
- Analysing and comparing instantaneous forward rate curves
- Designing duration-based and duration–convexity hedging strategies (Fisher/Weil)
- Re-evaluating hedge performance using updated market data
Technical stack: Python, NumPy, pandas, matplotlib
1. Data and Environment Setup
Load the Australian Government bond snapshots dated 1 April 2026 and 25 May 2026, then prepare the datasets used throughout the calibration and hedging analysis.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import fsolve
from scipy.optimize import minimize
from scipy.optimize import milp, LinearConstraint, Bounds
from matplotlib.lines import Line2D
from numba import njit
from joblib import Parallel, delayed
# Data on 01 April 2026
data = pd.read_excel("AusBond20260401.xlsx")
data.columns = data.columns.str.strip()
data = data.drop(columns=["StatusCode"])
data.columns = ["Code", "Security", "Bid", "Ask", "Last Trade", "Coupon", "Maturity", "Next Ex Date"]
data["Code"] = data["Code"].str.split().str[0]
data[["Bid", "Ask"]] = data[["Bid", "Ask"]].apply(pd.to_numeric, errors='coerce')
data["Maturity"] = data["Security"].str.extract(r'(\d{2}-\d{2}-\d{2})')
data["Maturity"] = pd.to_datetime(data["Maturity"], format='%d-%m-%y')
data["Next Ex Date"] = pd.to_datetime(data["Next Ex Date"], format='mixed')
data = data.sort_values(by=["Maturity"]).reset_index(drop=True)
data.info()
display(data)
<class 'pandas.core.frame.DataFrame'> RangeIndex: 28 entries, 0 to 27 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Code 28 non-null object 1 Security 28 non-null object 2 Bid 27 non-null float64 3 Ask 22 non-null float64 4 Last Trade 28 non-null float64 5 Coupon 28 non-null float64 6 Maturity 28 non-null datetime64[ns] 7 Next Ex Date 28 non-null datetime64[ns] dtypes: datetime64[ns](2), float64(4), object(2) memory usage: 1.9+ KB
| Code | Security | Bid | Ask | Last Trade | Coupon | Maturity | Next Ex Date | |
|---|---|---|---|---|---|---|---|---|
| 0 | GSBG26 | TREASURY BOND 4.25% 21-04-26 SEMI | 101.780 | 102.451 | 101.850 | 4.25 | 2026-04-21 | 2026-04-10 |
| 1 | GSBQ26 | TREASURY BOND 0.50% 21-09-26 SEMI | 98.163 | 98.463 | 98.293 | 0.50 | 2026-09-21 | 2026-09-10 |
| 2 | GSBG27 | TREASURY BOND 4.75% 21-04-27 SEMI | 102.260 | 102.750 | 102.360 | 4.75 | 2027-04-21 | 2026-04-10 |
| 3 | GSBU27 | TREASURY BOND 2.75% 21-11-27 SEMI | 98.100 | NaN | 98.160 | 2.75 | 2027-11-21 | 2026-05-12 |
| 4 | GSBI28 | TREASURY BOND 2.25% 21-05-28 SEMI | 95.500 | NaN | 96.100 | 2.25 | 2028-05-21 | 2026-05-12 |
| 5 | GSBU28 | TREASURY BOND 2.75% 21-11-28 SEMI | 96.400 | 96.700 | 96.350 | 2.75 | 2028-11-21 | 2026-05-12 |
| 6 | GSBG29 | TREASURY BOND 3.25% 21-04-29 SEMI | 97.590 | 97.550 | 97.550 | 3.25 | 2029-04-21 | 2026-04-10 |
| 7 | GSBU29 | TREASURY BOND 2.75% 21-11-29 SEMI | 92.000 | NaN | 94.580 | 2.75 | 2029-11-21 | 2026-05-12 |
| 8 | GSBI30 | TREASURY BOND 2.50% 21-05-30 SEMI | 92.987 | 93.287 | 92.807 | 2.50 | 2030-05-21 | 2026-05-12 |
| 9 | GSBW30 | TREASURY BOND 1.00% 21-12-30 SEMI | 84.200 | NaN | 84.570 | 1.00 | 2030-12-21 | 2026-06-11 |
| 10 | GSBK31 | TREASURY BOND 1.50% 21-06-31 SEMI | 85.900 | 86.400 | 85.250 | 1.50 | 2031-06-21 | 2026-06-11 |
| 11 | GSBU31 | TREASURY BOND 1.00% 21-11-31 SEMI | 79.000 | NaN | 81.750 | 1.00 | 2031-11-21 | 2026-05-12 |
| 12 | GSBI32 | TREASURY BOND 1.25% 21-05-32 SEMI | NaN | NaN | 81.380 | 1.25 | 2032-05-21 | 2026-05-12 |
| 13 | GSBU32 | TREASURY BOND 1.75% 21-11-32 SEMI | 81.310 | 86.000 | 83.540 | 1.75 | 2032-11-21 | 2026-05-12 |
| 14 | GSBG33 | TREASURY BOND 4.50% 21-04-33 SEMI | 99.400 | 101.980 | 100.040 | 4.50 | 2033-04-21 | 2026-04-10 |
| 15 | GSBU33 | TREASURY BOND 3.00% 21-11-33 SEMI | 89.538 | 90.038 | 89.758 | 3.00 | 2033-11-21 | 2026-05-12 |
| 16 | GSBI34 | TREASURY BOND 3.75% 21-05-34 SEMI | 92.340 | 96.000 | 93.860 | 3.75 | 2034-05-21 | 2026-05-12 |
| 17 | GSBW34 | TREASURY BOND 3.50% 21-12-34 SEMI | 91.180 | 91.680 | 90.322 | 3.50 | 2034-12-21 | 2026-06-11 |
| 18 | GSBK35 | TREASURY BOND 2.75% 21-06-35 SEMI | 82.310 | 87.000 | 84.650 | 2.75 | 2035-06-21 | 2026-06-11 |
| 19 | GSBW35 | TREASURY BOND 4.25% 21-12-35 SEMI | 95.947 | 96.447 | 95.454 | 4.25 | 2035-12-21 | 2026-06-11 |
| 20 | GSBE36 | TREASURY BOND 4.25% 21-03-36 SEMI | 94.500 | 95.990 | 94.540 | 4.25 | 2036-03-21 | 2026-09-10 |
| 21 | GSBS36 | TREASURY BOND 4.25% 21-10-36 SEMI | 96.059 | 96.859 | 95.651 | 4.25 | 2036-10-21 | 2026-04-10 |
| 22 | GSBG37 | TREASURY BOND 3.75% 21-04-37 SEMI | 91.222 | 92.022 | 91.430 | 3.75 | 2037-04-21 | 2026-04-10 |
| 23 | GSBK39 | TREASURY BOND 3.25% 21-06-39 SEMI | 83.527 | 84.327 | 83.065 | 3.25 | 2039-06-21 | 2026-06-11 |
| 24 | GSBI41 | TREASURY BOND 2.75% 21-05-41 SEMI | 74.000 | 82.100 | 75.260 | 2.75 | 2041-05-21 | 2026-05-12 |
| 25 | GSBE47 | TREASURY BOND 3.00% 21-03-47 SEMI | 71.042 | 70.900 | 70.900 | 3.00 | 2047-03-21 | 2026-09-10 |
| 26 | GSBK51 | TREASURY BOND 1.75% 21-06-51 SEMI | 50.705 | 51.505 | 49.839 | 1.75 | 2051-06-21 | 2026-06-11 |
| 27 | GSBK54 | TREASURY BOND 4.75% 21-06-54 SEMI | 92.000 | 93.490 | 92.530 | 4.75 | 2054-06-21 | 2026-06-11 |
# Data on 25 May 2026
data2 = pd.read_excel("AusBond20260525.xlsx")
data2.columns = data2.columns.str.strip()
data2 = data2.drop(columns=["StatusCode"])
data2.columns = ["Code", "Security", "Bid", "Ask", "Last Trade", "Coupon", "Maturity", "Next Ex Date"]
data2["Code"] = data2["Code"].str.split().str[0]
data2[["Bid", "Ask"]] = data2[["Bid", "Ask"]].apply(pd.to_numeric, errors='coerce')
data2["Maturity"] = data2["Security"].str.extract(r'(\d{2}-\d{2}-\d{2})')
data2["Maturity"] = pd.to_datetime(data2["Maturity"], format='%d-%m-%y')
data2["Next Ex Date"] = pd.to_datetime(data2["Next Ex Date"], format='mixed')
data2 = data2.sort_values(by=["Maturity"]).reset_index(drop=True)
data2.info()
display(data2)
<class 'pandas.core.frame.DataFrame'> RangeIndex: 27 entries, 0 to 26 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Code 27 non-null object 1 Security 27 non-null object 2 Bid 27 non-null float64 3 Ask 27 non-null float64 4 Last Trade 27 non-null float64 5 Coupon 27 non-null float64 6 Maturity 27 non-null datetime64[ns] 7 Next Ex Date 27 non-null datetime64[ns] dtypes: datetime64[ns](2), float64(4), object(2) memory usage: 1.8+ KB
| Code | Security | Bid | Ask | Last Trade | Coupon | Maturity | Next Ex Date | |
|---|---|---|---|---|---|---|---|---|
| 0 | GSBQ26 | TREASURY BOND 0.50% 21-09-26 SEMI | 98.748 | 99.047 | 98.735 | 0.50 | 2026-09-21 | 2026-09-10 |
| 1 | GSBG27 | TREASURY BOND 4.75% 21-04-27 SEMI | 100.630 | 100.680 | 100.500 | 4.75 | 2027-04-21 | 2026-10-12 |
| 2 | GSBU27 | TREASURY BOND 2.75% 21-11-27 SEMI | 97.390 | 97.540 | 97.390 | 2.75 | 2027-11-21 | 2026-11-12 |
| 3 | GSBI28 | TREASURY BOND 2.25% 21-05-28 SEMI | 95.640 | 95.820 | 95.690 | 2.25 | 2028-05-21 | 2026-11-12 |
| 4 | GSBU28 | TREASURY BOND 2.75% 21-11-28 SEMI | 95.990 | 96.066 | 96.054 | 2.75 | 2028-11-21 | 2026-11-12 |
| 5 | GSBG29 | TREASURY BOND 3.25% 21-04-29 SEMI | 96.761 | 97.061 | 97.048 | 3.25 | 2029-04-21 | 2026-10-12 |
| 6 | GSBU29 | TREASURY BOND 2.75% 21-11-29 SEMI | 94.250 | 94.460 | 93.870 | 2.75 | 2029-11-21 | 2026-11-12 |
| 7 | GSBI30 | TREASURY BOND 2.50% 21-05-30 SEMI | 92.605 | 92.905 | 92.013 | 2.50 | 2030-05-21 | 2026-11-12 |
| 8 | GSBW30 | TREASURY BOND 1.00% 21-12-30 SEMI | 86.000 | 86.070 | 86.070 | 1.00 | 2030-12-21 | 2026-06-11 |
| 9 | GSBK31 | TREASURY BOND 1.50% 21-06-31 SEMI | 86.744 | 86.910 | 86.473 | 1.50 | 2031-06-21 | 2026-06-11 |
| 10 | GSBU31 | TREASURY BOND 1.00% 21-11-31 SEMI | 82.540 | 82.910 | 82.910 | 1.00 | 2031-11-21 | 2026-11-12 |
| 11 | GSBI32 | TREASURY BOND 1.25% 21-05-32 SEMI | 82.480 | 82.600 | 81.430 | 1.25 | 2032-05-21 | 2026-11-12 |
| 12 | GSBU32 | TREASURY BOND 1.75% 21-11-32 SEMI | 83.760 | 83.840 | 83.850 | 1.75 | 2032-11-21 | 2026-11-12 |
| 13 | GSBG33 | TREASURY BOND 4.50% 21-04-33 SEMI | 99.240 | 99.330 | 99.000 | 4.50 | 2033-04-21 | 2026-10-12 |
| 14 | GSBU33 | TREASURY BOND 3.00% 21-11-33 SEMI | 89.016 | 89.516 | 89.016 | 3.00 | 2033-11-21 | 2026-11-12 |
| 15 | GSBI34 | TREASURY BOND 3.75% 21-05-34 SEMI | 93.230 | 93.330 | 93.330 | 3.75 | 2034-05-21 | 2026-11-12 |
| 16 | GSBW34 | TREASURY BOND 3.50% 21-12-34 SEMI | 92.173 | 92.673 | 92.220 | 3.50 | 2034-12-21 | 2026-06-11 |
| 17 | GSBK35 | TREASURY BOND 2.75% 21-06-35 SEMI | 85.810 | 85.900 | 85.920 | 2.75 | 2035-06-21 | 2026-06-11 |
| 18 | GSBW35 | TREASURY BOND 4.25% 21-12-35 SEMI | 96.990 | 97.454 | 96.992 | 4.25 | 2035-12-21 | 2026-06-11 |
| 19 | GSBE36 | TREASURY BOND 4.25% 21-03-36 SEMI | 95.780 | 95.910 | 95.870 | 4.25 | 2036-03-21 | 2026-09-10 |
| 20 | GSBS36 | TREASURY BOND 4.25% 21-10-36 SEMI | 94.958 | 95.758 | 93.537 | 4.25 | 2036-10-21 | 2026-10-12 |
| 21 | GSBG37 | TREASURY BOND 3.75% 21-04-37 SEMI | 90.327 | 91.127 | 91.225 | 3.75 | 2037-04-21 | 2026-10-12 |
| 22 | GSBK39 | TREASURY BOND 3.25% 21-06-39 SEMI | 84.327 | 85.127 | 85.127 | 3.25 | 2039-06-21 | 2026-06-11 |
| 23 | GSBI41 | TREASURY BOND 2.75% 21-05-41 SEMI | 75.090 | 75.540 | 74.680 | 2.75 | 2041-05-21 | 2026-11-12 |
| 24 | GSBE47 | TREASURY BOND 3.00% 21-03-47 SEMI | 71.700 | 72.361 | 70.050 | 3.00 | 2047-03-21 | 2026-09-10 |
| 25 | GSBK51 | TREASURY BOND 1.75% 21-06-51 SEMI | 50.908 | 51.708 | 50.300 | 1.75 | 2051-06-21 | 2026-06-11 |
| 26 | GSBK54 | TREASURY BOND 4.75% 21-06-54 SEMI | 93.110 | 93.400 | 93.110 | 4.75 | 2054-06-21 | 2026-06-11 |
1. Term Structure Calibration
Estimate discount factors from liquid bond prices, fit a Nelson-Siegel curve, and compare the resulting instantaneous forward rates.
Log-linear interpolation
Using the 1 April 2026 market snapshot and ACT/365 day count, we build a discount-factor curve from the observed bond maturities. For any maturity $\tau$ between two adjacent bond dates $T_i$ and $T_{i+1}$, the discount factor is interpolated in log space:
$$ \log B(0,\tau) = \log B(0,T_i) + \frac{\tau - T_i}{T_{i+1}-T_i} \big( \log B(0,T_{i+1}) - \log B(0,T_i) \big) $$
All bonds with available last-trade prices are retained for the interpolation step. Any bid-offer anomalies are left unchanged because they do not affect this curve construction.
Nelson-Siegel calibration
We only consider bonds with "Bid" and "Offer" prices. The forward rate cure $f(t)$ specified by the Niesel-Siegel parameterisation is given by:
$$ f(0,t) = f_0 + f_1 e^{-t/\gamma} + f_2 \frac{t}{\gamma} e^{-t/\gamma} $$
where $f_0$ controls the level, $f_1$ the slope component, $f_2$ the hump or trough shape, and $\gamma$ the speed of decay. The associated discount factor is obtained from
$$ \begin{aligned} B(0,T) &= \exp\left(-\int_0^T f(0,t)\,dt\right) \\ &= \exp(-f_0 T + f_1 \gamma(e^{-T/\gamma} - 1) + f_2(e^{-T/\gamma} - 1 + \frac{T}{\gamma}e^{-T/\gamma})) \end{aligned} $$
Using these discount factors, we obtain model prices $M_i$ for the bonds. The parameters are selected by minimising the pricing error against the observed bid-ask range:
$$ \sum_{i=1}^N \epsilon_{i}^2= \argmin_{f_0, f_1, f_2, \gamma} \sum_{i=1}^{N} (\text{max}(0, B_i - M_i) + \text{max}(0,M_i - O_i))^2 $$
A grid search over initial parameter values is used before optimisation to reduce the risk of settling in a poor local minimum. The calibration also keeps $\gamma > 0$ so the forward curve remains well behaved as $T \to \infty$.
$$ \begin{aligned} \lim_{T \to \infty} \frac{d}{dT} f(0,T) &= \lim_{T \to \infty} \frac{d}{dT} \left( f_0 + f_1 e^{-T/\gamma} + f_2 \frac{T}{\gamma} e^{-T/\gamma} \right) \\ &= 0 \end{aligned} $$
Instantaneous forward rates
For the log-linear interpolation method, the instantaneous forward rate is calculated as:
$$ f(0, T) = - \left. \frac{\partial \ln B(t,u)}{\partial u} \right|_{u = T_i} = \frac{\ln B(t, T_1) - \ln B(t, T_2)}{T_2 - T_1} $$
which is constant for all $T_i \in [T_1, T_2]$.
So the instantaneous forward rates at maturities ( T_i ) are undefined.
For the Nelson--Siegel parameterisation method, the instantaneous forward rate is given by:
$$ f(0, T) = \hat{f}_0 + \hat{f}_1 e^{-T / \hat{\gamma}} + \hat{f}_2 \frac{T}{\hat{\gamma}} e^{-T / \hat{\gamma}} $$
To plot the curves, we will use the maturities of bonds used in the log-linear interpolation calibration and add more points in between maturities to get a smoother curve (including time 0 at start).
Any undefined values of forward rates under log-lienar interpolation will be depicted by dashed lines in the forward rate curve.
@njit(cache=True)
def ns_discount_numba(t, f0, f1, f2, gamma_val):
if t <= 0.0:
return 1.0
z = t / gamma_val
exp_neg_z = np.exp(-z)
term1 = gamma_val * (1.0 - exp_neg_z)
term2 = gamma_val * (1.0 - exp_neg_z) - t * exp_neg_z
return np.exp(-f0 * t - f1 * term1 - f2 * term2)
@njit(cache=True)
def ns_objective_numba(x, maturity_times, coupon_times, coupon_counts, coupons, bid, ask, face):
f0 = x[0]
f1 = x[1]
f2 = x[2]
gamma_val = np.exp(x[3])
total = 0.0
n_bonds = maturity_times.shape[0]
for i in range(n_bonds):
pv_coupons = 0.0
for j in range(coupon_counts[i]):
pv_coupons += ns_discount_numba(coupon_times[i, j], f0, f1, f2, gamma_val)
pv_coupons *= coupons[i] * face / 2.0
pv_face = face * ns_discount_numba(maturity_times[i], f0, f1, f2, gamma_val)
model_price = pv_coupons + pv_face
lower_gap = bid[i] - model_price
upper_gap = model_price - ask[i]
err = 0.0
if lower_gap > 0.0:
err += lower_gap
if upper_gap > 0.0:
err += upper_gap
total += err * err
return total
def Task_1():
today = pd.Timestamp('2026-04-01')
# Log-Linear Interpolation
df_a = data.copy().sort_values('Maturity').reset_index(drop=True)
T_maturities_a = np.array([(m - today).days / 365 for m in df_a['Maturity']])
coupons = df_a['Coupon'].values / 100
price = df_a['Last Trade'].values
F = 100
def log_interpolate(T: float, T_array: np.ndarray, x: np.ndarray) -> float:
T_nodes = np.concatenate([[0], T_array])
B_nodes = np.concatenate([[1], x])
if len(T_nodes) != len(B_nodes):
raise ValueError("Maturity array and ZCB array must have the same length")
if T < 0 or T > T_nodes[-1]:
raise ValueError(f"T={T:.2f} out of interpolation range")
return np.exp(np.interp(T, T_nodes, np.log(B_nodes)))
def get_coupon_times(maturity):
times = []
d = maturity
while d > today:
times.append((d - today).days / 365)
d -= pd.DateOffset(months=6)
return np.array(sorted(times))
coupon_schedules = [get_coupon_times(m) for m in df_a['Maturity']]
def objective(x: np.ndarray) -> np.ndarray:
residuals = []
for i, (t_coupons, c) in enumerate(zip(coupon_schedules, coupons)):
pv_coupons = (c * F / 2) * np.sum([log_interpolate(t, T_maturities_a, x) for t in t_coupons])
pv_face = F * x[i]
residuals.append(pv_coupons + pv_face - price[i])
return np.array(residuals)
B_result_a = fsolve(objective, x0=np.exp(-0.05 * T_maturities_a))
B_result_a = np.asarray(B_result_a)
df_result_a = df_a[['Code', 'Maturity']].copy().reset_index(drop=True)
df_result_a['T'] = T_maturities_a
df_result_a['DiscountFactor'] = B_result_a
for index, b_val in enumerate(B_result_a):
if b_val < 0:
print("Drop row with negative discount factor")
print(df_result_a.iloc[[index]])
df_result_a = df_result_a.drop(index)
if b_val > 1:
print("Discount factor exceeding 1 - Accepted")
print(df_result_a.iloc[[index]])
display(df_result_a)
plt.figure(figsize=(10, 4))
plt.plot(df_result_a['T'], df_result_a['DiscountFactor'], linestyle='-', color='blue')
plt.title('Term Structure of Discount Factors')
plt.xlabel('Time to Maturity (Years)')
plt.ylabel('Discount Factor')
plt.grid()
plt.tight_layout()
plt.savefig('output1a.png', dpi=150, bbox_inches='tight')
plt.show()
# Nelson-Siegel Optimisation
df_b = data.copy().drop(columns=["Security", "Last Trade"]).dropna().reset_index(drop=True)
T_maturities_b = np.array([(m - today).days / 365 for m in df_b['Maturity']])
df_b["coupon"] = df_b["Coupon"] / 100
coupon_schedules_b = [get_coupon_times(m) for m in df_b['Maturity']]
max_coupon_count_b = max(len(times) for times in coupon_schedules_b)
coupon_times_b = np.zeros((len(coupon_schedules_b), max_coupon_count_b), dtype=np.float64)
coupon_counts_b = np.zeros(len(coupon_schedules_b), dtype=np.int64)
for i, times in enumerate(coupon_schedules_b):
coupon_counts_b[i] = len(times)
if len(times) > 0:
coupon_times_b[i, :len(times)] = times
T_maturities_b = np.ascontiguousarray(T_maturities_b, dtype=np.float64)
coupon_times_b = np.ascontiguousarray(coupon_times_b, dtype=np.float64)
coupon_counts_b = np.ascontiguousarray(coupon_counts_b, dtype=np.int64)
coupons_b = np.ascontiguousarray(df_b['coupon'].to_numpy(dtype=np.float64))
bid_b = np.ascontiguousarray(df_b['Bid'].to_numpy(dtype=np.float64))
ask_b = np.ascontiguousarray(df_b['Ask'].to_numpy(dtype=np.float64))
def NS_discount(t, f0, f1, f2, gamma):
if t == 0:
return 1.0
z = t / gamma
term1 = gamma * (1 - np.exp(-z))
term2 = gamma * (1 - np.exp(-z)) - t * np.exp(-z)
return float(np.exp(-f0 * t - f1 * term1 - f2 * term2))
def Nelson_Siegel_paras(x: np.ndarray) -> np.ndarray:
f0, f1, f2, log_gamma = x
gamma = float(np.exp(log_gamma))
return np.array([NS_discount(T, f0, f1, f2, gamma) for T in T_maturities_b])
def objective_ns(x: np.ndarray) -> float:
x = np.ascontiguousarray(x, dtype=np.float64)
return float(ns_objective_numba(x, T_maturities_b, coupon_times_b, coupon_counts_b, coupons_b, bid_b, ask_b, float(F)))
bounds = [(-0.5, 0.5), (-1.0, 1.0), (-1.0, 1.0), (0, 5.0)]
f0_grid = np.arange(0.01, 0.401, 0.01)
f1_grid = np.arange(-0.20, 0.201, 0.01)
f2_grid = np.arange(-0.20, 0.201, 0.01)
gamma_grid = np.arange(0.01, 5.01, 0.01)
seed_candidates = []
for f0_0 in f0_grid:
for f1_0 in f1_grid:
for f2_0 in f2_grid:
for gamma_0 in gamma_grid:
x0_ns = np.array([f0_0, f1_0, f2_0, np.log(gamma_0)], dtype=np.float64)
seed_score = objective_ns(x0_ns)
if np.isfinite(seed_score):
seed_candidates.append((seed_score, x0_ns))
if not seed_candidates:
raise RuntimeError("No valid Nelson-Siegel initial guesses were generated.")
seed_candidates.sort(key=lambda item: item[0])
n_refinements = min(20, len(seed_candidates))
best_result_ns = None
best_objective_ns = np.inf
for seed_score, x0_ns in seed_candidates[:n_refinements]:
result_ns = minimize(objective_ns, x0=x0_ns, method='L-BFGS-B', bounds=bounds, options={'maxiter': 10000})
if np.isfinite(result_ns.fun) and result_ns.fun < best_objective_ns:
best_objective_ns = result_ns.fun
best_result_ns = result_ns
if best_result_ns is None:
raise RuntimeError("Nelson-Siegel optimization did not converge for any initial guess.")
result_ns = best_result_ns
f0, f1, f2, log_gamma = result_ns.x
gamma = float(np.exp(log_gamma))
print(f"Optimized Nelson-Siegel parameters: f0={f0:.6f}, f1={f1:.6f}, f2={f2:.6f}, gamma={gamma:.6f}")
print(f"Best objective value: {best_objective_ns}")
B_result_b = Nelson_Siegel_paras(result_ns.x)
df_b_ns = df_b[['Code', 'Maturity']].copy()
df_b_ns['T'] = T_maturities_b
df_b_ns['DiscountFactor'] = B_result_b
df_b_ns['ImpliedRate'] = np.where(df_b_ns['T'] > 0, -np.log(df_b_ns['DiscountFactor']) / df_b_ns['T'], np.nan)
display(df_b_ns)
plt.figure(figsize=(10, 4))
plt.plot(df_b_ns['T'], df_b_ns['DiscountFactor'], linestyle='-', color='orange')
plt.title('Term Structure of Discount Factors (Nelson-Siegel)')
plt.xlabel('Time to Maturity (Years)')
plt.ylabel('Discount Factor')
plt.grid()
plt.tight_layout()
plt.savefig('output1b.png', dpi=150, bbox_inches='tight')
plt.show()
# Plotting the term structure of instantaneous forward rates
n = 1000
T_maturities_c = np.insert(T_maturities_a.copy(), 0, 0.0)
B_result_c = np.insert(B_result_a.copy(), 0, 1.0)
segments = [np.linspace(T_maturities_c[j], T_maturities_c[j+1], n+2) for j in range(len(T_maturities_c)-1)]
T_grids = np.unique(np.concatenate(segments))
ins_fw_rate_a = np.array([])
for i in range(len(T_grids)):
if T_grids[i] == 0 or np.any(np.isclose(T_grids[i], T_maturities_c)):
ins_fw_rate_a = np.append(ins_fw_rate_a, np.nan)
continue
for j in range(len(B_result_c) - 1):
if T_grids[i] > T_maturities_c[j] and T_grids[i] < T_maturities_c[j+1]:
fwr = np.log(B_result_c[j] / B_result_c[j+1]) / (T_maturities_c[j+1] - T_maturities_c[j])
ins_fw_rate_a = np.append(ins_fw_rate_a, fwr)
break
else:
ins_fw_rate_a = np.append(ins_fw_rate_a, np.nan)
ins_fw_rate_b = f0 + f1 * np.exp(-T_grids / gamma) + f2 * (T_grids / gamma) * np.exp(-T_grids / gamma)
plt.figure(figsize=(12, 5))
ax = plt.gca()
ax.plot(T_grids, ins_fw_rate_a, label='(Log-linear interpolation)', color='steelblue')
for t in T_maturities_c:
idx = np.searchsorted(T_grids, t)
y_left = ins_fw_rate_a[idx - 1] if idx > 0 and not np.isnan(ins_fw_rate_a[idx - 1]) else None
y_right = ins_fw_rate_a[idx + 1] if idx < len(ins_fw_rate_a) - 1 and not np.isnan(ins_fw_rate_a[idx + 1]) else None
if y_left is not None and y_right is not None:
ax.vlines(x=t, ymin=min(y_left, y_right), ymax=max(y_left, y_right),
color='steelblue', linestyle='--', linewidth=0.8, alpha=0.6)
ax.plot(T_grids, ins_fw_rate_b, label='(Nelson-Siegel optimisation)', color='orange')
custom_lines = [
Line2D([0], [0], color='steelblue', linestyle='-', label='(Log-linear interpolation)'),
Line2D([0], [0], color='steelblue', linestyle='--', label='(Undefined values of instantaneous forward rate)'),
Line2D([0], [0], color='orange', linestyle='-', label='(Nelson-Siegel optimisation)'),
]
ax.legend(handles=custom_lines)
plt.title('Instantaneous Forward Rate using log-linear and Nelson-Siegel Methods')
plt.xlabel('Time to Maturity (Years)')
plt.ylabel('Instantaneous Forward Rate')
plt.grid()
plt.savefig('forward_rate.png', dpi=150, bbox_inches='tight')
plt.show()
return df_result_a, df_b_ns
result_1_a, result_1_b = Task_1()
| Code | Maturity | T | DiscountFactor | |
|---|---|---|---|---|
| 0 | GSBG26 | 2026-04-21 | 0.054795 | 0.997307 |
| 1 | GSBQ26 | 2026-09-21 | 0.473973 | 0.980479 |
| 2 | GSBG27 | 2027-04-21 | 1.054795 | 0.954059 |
| 3 | GSBU27 | 2027-11-21 | 1.641096 | 0.928718 |
| 4 | GSBI28 | 2028-05-21 | 2.139726 | 0.907523 |
| 5 | GSBU28 | 2028-11-21 | 2.643836 | 0.885958 |
| 6 | GSBG29 | 2029-04-21 | 3.057534 | 0.869382 |
| 7 | GSBU29 | 2029-11-21 | 3.643836 | 0.844737 |
| 8 | GSBI30 | 2030-05-21 | 4.139726 | 0.825871 |
| 9 | GSBW30 | 2030-12-21 | 4.726027 | 0.800974 |
| 10 | GSBK31 | 2031-06-21 | 5.224658 | 0.779565 |
| 11 | GSBU31 | 2031-11-21 | 5.643836 | 0.764858 |
| 12 | GSBI32 | 2032-05-21 | 6.142466 | 0.743352 |
| 13 | GSBU32 | 2032-11-21 | 6.646575 | 0.730381 |
| 14 | GSBG33 | 2033-04-21 | 7.060274 | 0.713239 |
| 15 | GSBU33 | 2033-11-21 | 7.646575 | 0.696439 |
| 16 | GSBI34 | 2034-05-21 | 8.142466 | 0.674526 |
| 17 | GSBW34 | 2034-12-21 | 8.728767 | 0.646459 |
| 18 | GSBK35 | 2035-06-21 | 9.227397 | 0.636014 |
| 19 | GSBW35 | 2035-12-21 | 9.728767 | 0.616150 |
| 20 | GSBE36 | 2036-03-21 | 9.978082 | 0.611086 |
| 21 | GSBS36 | 2036-10-21 | 10.564384 | 0.589862 |
| 22 | GSBG37 | 2037-04-21 | 11.063014 | 0.579914 |
| 23 | GSBK39 | 2039-06-21 | 13.230137 | 0.508627 |
| 24 | GSBI41 | 2041-05-21 | 15.147945 | 0.452863 |
| 25 | GSBE47 | 2047-03-21 | 20.983562 | 0.325447 |
| 26 | GSBK51 | 2051-06-21 | 25.238356 | 0.249405 |
| 27 | GSBK54 | 2054-06-21 | 28.241096 | 0.216715 |
Optimized Nelson-Siegel parameters: f0=0.058620, f1=-0.011542, f2=-0.022244, gamma=3.323685 Best objective value: 0.15649618117278188
| Code | Maturity | T | DiscountFactor | ImpliedRate | |
|---|---|---|---|---|---|
| 0 | GSBG26 | 2026-04-21 | 0.054795 | 0.997428 | 0.046992 |
| 1 | GSBQ26 | 2026-09-21 | 0.473973 | 0.978238 | 0.046420 |
| 2 | GSBG27 | 2027-04-21 | 1.054795 | 0.952773 | 0.045866 |
| 3 | GSBU28 | 2028-11-21 | 2.643836 | 0.886991 | 0.045359 |
| 4 | GSBG29 | 2029-04-21 | 3.057534 | 0.870402 | 0.045396 |
| 5 | GSBI30 | 2030-05-21 | 4.139726 | 0.827625 | 0.045702 |
| 6 | GSBK31 | 2031-06-21 | 5.224658 | 0.785508 | 0.046209 |
| 7 | GSBU32 | 2032-11-21 | 6.646575 | 0.731584 | 0.047023 |
| 8 | GSBG33 | 2033-04-21 | 7.060274 | 0.716216 | 0.047275 |
| 9 | GSBU33 | 2033-11-21 | 7.646575 | 0.694722 | 0.047635 |
| 10 | GSBI34 | 2034-05-21 | 8.142466 | 0.676825 | 0.047939 |
| 11 | GSBW34 | 2034-12-21 | 8.728767 | 0.656023 | 0.048295 |
| 12 | GSBK35 | 2035-06-21 | 9.227397 | 0.638655 | 0.048593 |
| 13 | GSBW35 | 2035-12-21 | 9.728767 | 0.621506 | 0.048887 |
| 14 | GSBE36 | 2036-03-21 | 9.978082 | 0.613099 | 0.049030 |
| 15 | GSBS36 | 2036-10-21 | 10.564384 | 0.593656 | 0.049360 |
| 16 | GSBG37 | 2037-04-21 | 11.063014 | 0.577488 | 0.049631 |
| 17 | GSBK39 | 2039-06-21 | 13.230137 | 0.511274 | 0.050706 |
| 18 | GSBI41 | 2041-05-21 | 15.147945 | 0.458227 | 0.051518 |
| 19 | GSBE47 | 2047-03-21 | 20.983562 | 0.326668 | 0.053318 |
| 20 | GSBK51 | 2051-06-21 | 25.238356 | 0.254739 | 0.054184 |
| 21 | GSBK54 | 2054-06-21 | 28.241096 | 0.213664 | 0.054649 |
2. Hedging Analysis
Use the calibrated term structure to value the floating stream, then construct duration-based and duration-convexity hedges with liquid Australian Government bonds.
Let $K$ denote the notional of the floating stream, $T_0 = 0$ at 01/04/2026, $T_1 = $ 25/05/2026 and $T_i$ for $i \in \{2, \ldots, n\}$.
The cashflow of the floating rate payment at time $T_i$ is: $$ CF_i = K\left(\frac{B(0, T_{i-1})}{B(0, T_i)} - 1\right) $$
The forward simple interest rates applied to those $CF_i$ are:
$$ f(T_1, T_{i-1}, T_i) = \left(\frac{B(T_1, T_{i-1})}{B(T_1, T_i)} - 1\right) \frac{1}{T_i - T_{i-1}} $$
Present value at $T_1$ of floating rate payments:
$$ V_{\text{float}}(T_1) = \sum_{i=2}^{n} CF_i \cdot B(T_1, T_i) = K \sum_{i=2}^{n} \left(\frac{B(T_1, T_{i-1})}{B(T_1, T_i)} - 1\right) \, B(T_1, T_i) = K (1 - B(T_1, T_n)) $$
Under no arbitrage argument, we can enter into a forward rate agreement for $B(T_1, T_{n})$ at time $T_1$ such that:
$$ B(T_1, T_{n}) = \frac{B(0, T_{n})}{B(0, T_1)} $$
$\textbf{Present value of the floating stream on 01/04/2026:}$
$$ \begin{aligned} V_{\text{float}}(0) &= K(1 - B(T_1, T_{n})) B(0, T_1)\\ &= K(1 - \frac{B(0, T_{n})}{B(0, T_1)}) B(0, T_1)\\ &= K(B(0, T_1) - B(0, T_{n})) \end{aligned} $$
If we apply a small parallel shift of size $x$ to the yield curve, the present value becomes: $$ V_{\text{float}}(x) = K (e^{-(y(0,T_1)+x)T_1} - e^{-(y(0,T_{n})+x)T_{n}}) $$
Using Taylor series expansion in the form: $$ V_{float}(x) = V_{float}(0) + \frac{\mathrm{d}V_{float}(0)}{\mathrm{d}x} x + \frac{1}{2} \frac{\mathrm{d}^2V_{float}(0)}{\mathrm{d}x^2} x^2 $$
Using linear approximation and quadratic approximation, we get:
$$ \begin{aligned} \frac{\mathrm{d}V_{\text{float}}(0)}{\mathrm{d}x} &= \frac{\mathrm{d}V_{\text{float}}(x)}{\mathrm{d}x}\bigg|_{x=0} = \frac{\mathrm{d}}{\mathrm{d}x} \left(K\left(e^{-(y(0,T_1)+x)T_1} - e^{-(y(0,T_n)+x)T_n}\right)\right)\bigg|_{x=0} \\ &= -K\left(T_1 e^{-y(0,T_1)T_1} - T_n e^{-y(0,T_n)T_n}\right) \\ &= -K\left(T_1 B(0,T_1) - T_n B(0,T_n)\right) \end{aligned} $$
And
$$ \begin{aligned} \frac{\mathrm{d}^2 V_{\text{float}}(0)}{\mathrm{d}x^2} &= \frac{\mathrm{d}^2 V_{\text{float}}(x)}{\mathrm{d}x^2}\bigg|_{x=0} = K\left(T_1^2 B(0,T_1) - T_n^2 B(0,T_n)\right) \end{aligned} $$
$\textbf{Fisher-Weil duration of the floating stream:}$
$$ \mathcal{D}_{\text{float}} = -\frac{1}{V_{\text{float}}(0)} \frac{\mathrm{d}V_{\text{float}}(0)}{\mathrm{d}x}\bigg|_{x=0} = \frac{T_1 B(0,T_1) - T_n B(0,T_n)}{B(0,T_1) - B(0,T_n)} $$
$\textbf{Fisher-Weil convexity of the floating stream:}$
$$ \mathcal{C}_{\text{float}} = \frac{1}{V_{\text{float}}(0)} \frac{\mathrm{d}^2V_{\text{float}}(0)}{\mathrm{d}x^2}\bigg|_{x=0} = \frac{T_1^2 B(0,T_1) - T_n^2 B(0,T_n)}{B(0,T_1) - B(0,T_n)} $$
$\textbf{Present value at 01/04/2026 of a bond:}$
$$ V_{\text{bond}}(0) = \sum_{j=1}^{m} C_j \cdot e^{-y(0,t_j)t_j} = \sum_{j=1}^{m} C_j \cdot B(0,t_j) $$ $$ V_{\text{bond}}(x) = \sum_{j=1}^{m} C_j \cdot e^{-(y(0,t_j)+x)t_j} $$
where $C_j$ is the cashflow at time $t_j$, the face value $100 is included in the last cashflow.
$\textbf{Fisher-Weil duration of the bond:}$ $$ \mathcal{D}_{\text{bond}}^{\text{DW}} = \frac{\sum_{j=1}^{m} t_j\, C_j \cdot B(0,t_j)}{\sum_{j=1}^{m} C_j \cdot B(0,t_j)} $$
$\textbf{Fisher-Weil convexity of a bond:}$ $$ \mathcal{C}_{\text{bond}}^{\text{DW}} = \frac{\sum_{j=1}^{m} t_j^2\, C_j \cdot B(0,t_j)}{\sum_{j=1}^{m} C_j \cdot B(0,t_j)} $$
We need to solve for the below sytems of linear equations for the $Q_i$ quantity of bond $i$ for our hedge.
$\textbf{For duration hedging:}$
$$ \begin{aligned} \begin{pmatrix} \mathcal{D}_{\text{bond}(1)} V_{\text{bond}(1)}^0 & \mathcal{D}_{\text{bond}(2)} V_{\text{bond}(2)}^0 \\ V_{\text{bond}(1)}^0 & V_{\text{bond}(2)}^0 \end{pmatrix} \begin{pmatrix} Q_1 \\ Q_2 \end{pmatrix} &= \begin{pmatrix} -\mathcal{D}_{\text{float}}^{\text{DW}} \cdot V_{\text{float}}^0 \\ 0 \end{pmatrix} \end{aligned} $$
$\textbf{For duration-convexity hedging:}$
$$ \begin{aligned} \begin{pmatrix} \mathcal{D}_{\text{bond}(1)} V_{\text{bond}(1)}^0 & \mathcal{D}_{\text{bond}(2)} V_{\text{bond}(2)}^0 & \mathcal{D}_{\text{bond}(3)} V_{\text{bond}(3)}^0 \\ \mathcal{C}_{\text{bond}(1)} V_{\text{bond}(1)}^0 & \mathcal{C}_{\text{bond}(2)} V_{\text{bond}(2)}^0 & \mathcal{C}_{\text{bond}(3)} V_{\text{bond}(3)}^0 \\ V_{\text{bond}(1)}^0 & V_{\text{bond}(2)}^0 & V_{\text{bond}(3)}^0 \end{pmatrix} \begin{pmatrix} Q_1 \\ Q_2 \\ Q_3 \end{pmatrix} &= \begin{pmatrix} -\mathcal{D}_{\text{float}} \cdot V_{\text{float}}^0 \\ -\mathcal{C}_{\text{float}} \cdot V_{\text{float}}^0 \\ 0 \end{pmatrix} \end{aligned} $$
We exclude any bonds with maturity before 25/05/2026 to avoid rolling risk.
Since $Q_i$ solved above can be fractional, we round to the nearest integer and check the hedging performance of the rounded hedge.
Any shortage or surplus of cash from the set up will be borrowed/invested at the market discount rates.
The change in value of the hedge portfolio (bonds) on 25/05/2026 is:
$$ \sum_{i=1}^a Q_i \cdot (V_{\text{bond}(2i)} - V_{\text{bond}(1i)}), \quad a \in \{2, 3\} $$
The change in the value of the floating stream with data on 25/05/2026 is:
$$ K \left(1 - B^*(T_1, T_n) \right) - K\left(B(0, T_1) - B(0, T_n) \right) $$
def Task_2(df_result_a, data, data_2c):
from itertools import combinations
today = pd.Timestamp('2026-04-01')
today2 = pd.Timestamp('2026-05-25')
stream_start = pd.Timestamp('2026-05-25')
stream_end = pd.Timestamp('2036-05-25')
notional = 10_000_000
F = 100
T_nodes = np.concatenate([[0], df_result_a['T'].values])
B_nodes = np.concatenate([[1], df_result_a['DiscountFactor'].values])
def log_interpolate(T):
return np.exp(np.interp(T, T_nodes, np.log(B_nodes)))
def year_transform(start, end):
return (end - start).days / 365
t0 = year_transform(today, stream_start)
tn = year_transform(today, stream_end)
B_starts = log_interpolate(t0); B_ends = log_interpolate(tn)
PV_stream = notional * (B_starts - B_ends)
print(f"PV of floating stream: {PV_stream:,.2f}")
FW_duration_float = (t0 * B_starts - tn * B_ends) / (B_starts - B_ends)
FW_convexity_float = (t0**2 * B_starts - tn**2 * B_ends) / (B_starts - B_ends)
dollar_duration_float = FW_duration_float * PV_stream
dollar_convexity_float = FW_convexity_float * PV_stream
print(f"Fisher-Weil Duration of stream: {FW_duration_float}")
print(f"Fisher-Weil Convexity of stream: {FW_convexity_float}")
print(f"Dollar Duration of stream: {dollar_duration_float:,.2f}")
print(f"Dollar Convexity of stream: {dollar_convexity_float:,.2f}\n")
def get_coupon_times(maturity, valuation_date):
times = []; d = maturity
while d > valuation_date:
times.append((d - valuation_date).days / 365); d -= pd.DateOffset(months=6)
return np.array(sorted(times))
all_liquid = data.copy().dropna(subset=['Bid', 'Ask'])
excluded = all_liquid.loc[all_liquid['Maturity'] <= stream_start, 'Code'].tolist()
df_bonds = all_liquid.loc[all_liquid['Maturity'] > stream_start].reset_index(drop=True)
print(f"Excluded (mature on/before 25 May): {excluded}")
bond_stats = []
for _, row in df_bonds.iterrows():
t_c = get_coupon_times(row['Maturity'], today)
c = row['Coupon'] / 100
B_c = log_interpolate(t_c)
cf = c * F / 2 * np.ones(len(t_c)); cf[-1] += F
PV_bond = np.sum(cf * B_c)
bond_stats.append({
'Code': row['Code'], 'Maturity': row['Maturity'], 'Coupon': row['Coupon'],
'Price': row['Last Trade'], 'PV_bond': PV_bond,
'FW_Duration': np.sum(t_c * cf * B_c) / PV_bond,
'FW_Convexity': np.sum(t_c**2 * cf * B_c) / PV_bond,
})
df_stats = pd.DataFrame(bond_stats)
print("\nCandidate bonds:")
display(df_stats[['Code','Maturity','FW_Duration','FW_Convexity']])
COND_MAX = 1e12
def best_combo(n_bonds, rhs):
best = None; best_lev = np.inf
for idx in combinations(range(len(df_stats)), n_bonds):
sub = df_stats.iloc[list(idx)]
PV = sub['PV_bond'].values
D = sub['FW_Duration'].values * PV
if n_bonds == 2:
A = np.array([D, PV])
else:
C = sub['FW_Convexity'].values * PV
A = np.array([D, C, PV])
if abs(np.linalg.det(A)) < 1e-6 or np.linalg.cond(A) > COND_MAX:
continue
Q = np.linalg.solve(A, rhs)
lev = np.max(np.abs(Q)) * F
if lev < best_lev:
best_lev = lev; best = (idx, np.round(Q).astype(int))
return best
idx_a, Q_a = best_combo(2, np.array([-dollar_duration_float, 0]))
idx_b, Q_b = best_combo(3, np.array([-dollar_duration_float, -dollar_convexity_float, 0]))
bonds_a = df_stats.iloc[list(idx_a)].reset_index(drop=True)
bonds_b = df_stats.iloc[list(idx_b)].reset_index(drop=True)
def report(bonds, Q, rhs, label, with_convexity):
print(f"\n── {label} ──")
PV = bonds['PV_bond'].values
for code, q in zip(bonds['Code'], Q):
print(f" {code}: Q={q:,d} ({'SHORT' if q<0 else 'LONG'}), face=AUD {q*F:,.0f}")
print(f" Duration match: {np.sum(Q * bonds['FW_Duration'].values * PV):,.2f} (target {rhs[0]:,.2f}), difference: {(np.sum(Q * bonds['FW_Duration'].values * PV) - rhs[0]):,.2f}")
if with_convexity:
print(f" Convexity match: {np.sum(Q * bonds['FW_Convexity'].values * PV):,.2f} (target {rhs[1]:,.2f}), difference; {(np.sum(Q * bonds['FW_Convexity'].values * PV) - rhs[1]):,.2f}")
print(f" PV match: {np.sum(Q * PV):,.2f} (target {rhs[-1]:,.2f})")
print(f" Max face (leverage): AUD {np.max(np.abs(Q))*F:,.0f}")
report(bonds_a, Q_a, [-dollar_duration_float, 0], "Duration Hedge", False)
report(bonds_b, Q_b, [-dollar_duration_float, -dollar_convexity_float, 0], "Duration + Convexity Hedge", True)
print("HEDGE EVALUATION ON 25 MAY 2026")
df2 = data_2c.copy().dropna(subset=['Last Trade']).sort_values('Maturity').reset_index(drop=True)
T2 = np.array([(m - today2).days / 365 for m in df2['Maturity']])
cp2, pr2 = df2['Coupon'].values/100, df2['Last Trade'].values
sc2 = [get_coupon_times(m, today2) for m in df2['Maturity']]
def li2(t, x):
Tn = np.concatenate([[0], T2]); Bn = np.concatenate([[1], x]); return np.exp(np.interp(t, Tn, np.log(Bn)))
def obj2(x):
return np.array([(c*F/2)*np.sum([li2(t,x) for t in tc])+F*x[i]-pr2[i] for i,(tc,c) in enumerate(zip(sc2,cp2))])
B2 = fsolve(obj2, x0=np.exp(-0.05*T2))
dfb2 = pd.DataFrame({'Code': df2['Code'], 'Term': T2, 'DiscountFactor': B2})
print("Discount factors on 25 May 2026:")
display(dfb2)
T2n = np.concatenate([[0], T2]); B2n = np.concatenate([[1], B2])
interp2 = lambda t: np.exp(np.interp(t, T2n, np.log(B2n)))
PV_stream_2 = notional * (interp2(max(year_transform(today2, stream_start),0)) - interp2(year_transform(today2, stream_end)))
dStream = PV_stream_2 - PV_stream
print(f"\nStream PV 1 Apr={PV_stream:,.2f}, 25 May={PV_stream_2:,.2f}, ΔStream={dStream:,.2f}")
cmap = dict(zip(df_stats['Code'], df_stats['Coupon']))
mmap = dict(zip(df_stats['Code'], df_stats['Maturity']))
def bond_pv_at(code, interp, vdate):
t = get_coupon_times(mmap[code], vdate)
if len(t)==0: return 0.0
cf = cmap[code]/100*F/2*np.ones(len(t)); cf[-1]+=F
return np.sum(cf*interp(t))
def evaluate(hedge, label):
print("\n"+"="*56+f"\n{label}\n"+"="*56)
V1=V2=0.0
for code,Q in hedge:
v1=Q*bond_pv_at(code, log_interpolate, today)
if mmap[code]>today2: v2=Q*bond_pv_at(code, interp2, today2); note=""
else: v2=Q*(F+cmap[code]/100*F/2); note=" [MATURED]"
V1+=v1; V2+=v2
print(f" {code}: Q={Q:>9,} ΔV={v2-v1:>13,.2f}{note}")
dH=V2-V1
print(f"Change in Bond Hedge={dH:,.2f} NET P&L={dStream+dH:,.2f} (whereas if unhedged={dStream:,.2f})")
evaluate(list(zip(bonds_a['Code'], Q_a)), "Duration Hedge OUTCOME")
evaluate(list(zip(bonds_b['Code'], Q_b)), "Duration + Convexity Hedge OUTCOME")
return {'PV_stream':PV_stream, 'df_stats':df_stats,
'hedge_a':{'bonds':list(bonds_a['Code']), 'Q':list(map(int,Q_a))},
'hedge_b':{'bonds':list(bonds_b['Code']), 'Q':list(map(int,Q_b))}}
result_2 = Task_2(result_1_a, data, data2)
PV of floating stream: 3,889,824.12 Fisher-Weil Duration of stream: -15.406929580981345 Fisher-Weil Convexity of stream: -160.2572466596799 Dollar Duration of stream: -59,930,246.33 Dollar Convexity of stream: -623,372,503.81 Excluded (mature on/before 25 May): ['GSBG26'] Candidate bonds:
| Code | Maturity | FW_Duration | FW_Convexity | |
|---|---|---|---|---|
| 0 | GSBQ26 | 2026-09-21 | 0.473973 | 0.224650 |
| 1 | GSBG27 | 2027-04-21 | 1.020355 | 1.068712 |
| 2 | GSBU28 | 2028-11-21 | 2.540215 | 6.634195 |
| 3 | GSBG29 | 2029-04-21 | 2.889241 | 8.688593 |
| 4 | GSBI30 | 2030-05-21 | 3.911002 | 15.902071 |
| 5 | GSBK31 | 2031-06-21 | 5.000572 | 25.755600 |
| 6 | GSBU32 | 2032-11-21 | 6.212841 | 40.415696 |
| 7 | GSBG33 | 2033-04-21 | 5.990540 | 40.059425 |
| 8 | GSBU33 | 2033-11-21 | 6.747976 | 49.513495 |
| 9 | GSBI34 | 2034-05-21 | 6.935471 | 53.495911 |
| 10 | GSBW34 | 2034-12-21 | 7.426110 | 61.311151 |
| 11 | GSBK35 | 2035-06-21 | 8.016464 | 70.536030 |
| 12 | GSBW35 | 2035-12-21 | 7.898284 | 71.388172 |
| 13 | GSBE36 | 2036-03-21 | 8.151793 | 75.445916 |
| 14 | GSBS36 | 2036-10-21 | 8.358701 | 81.484100 |
| 15 | GSBG37 | 2037-04-21 | 8.850541 | 90.769124 |
| 16 | GSBK39 | 2039-06-21 | 10.412349 | 126.678600 |
| 17 | GSBI41 | 2041-05-21 | 11.751224 | 163.186120 |
| 18 | GSBE47 | 2047-03-21 | 14.387862 | 262.396952 |
| 19 | GSBK51 | 2051-06-21 | 17.553722 | 391.571116 |
| 20 | GSBK54 | 2054-06-21 | 14.767695 | 319.012530 |
── Duration Hedge ── GSBQ26: Q=-42,656 (SHORT), face=AUD -4,265,600 GSBK54: Q=45,313 (LONG), face=AUD 4,531,300 Duration match: 59,930,903.22 (target 59,930,246.33), difference: 656.89 PV match: 25.68 (target 0.00) Max face (leverage): AUD 4,531,300 ── Duration + Convexity Hedge ── GSBQ26: Q=-74,652 (SHORT), face=AUD -7,465,200 GSBK35: Q=64,076 (LONG), face=AUD 6,407,600 GSBK39: Q=23,039 (LONG), face=AUD 2,303,900 Duration match: 59,930,141.54 (target 59,930,246.33), difference: -104.79 Convexity match: 623,370,563.57 (target 623,372,503.81), difference; -1,940.24 PV match: -1.10 (target 0.00) Max face (leverage): AUD 7,465,200 HEDGE EVALUATION ON 25 MAY 2026 Discount factors on 25 May 2026:
| Code | Term | DiscountFactor | |
|---|---|---|---|
| 0 | GSBQ26 | 0.326027 | 0.984888 |
| 1 | GSBG27 | 0.906849 | 0.958923 |
| 2 | GSBU27 | 1.493151 | 0.934475 |
| 3 | GSBI28 | 1.991781 | 0.914357 |
| 4 | GSBU28 | 2.495890 | 0.896220 |
| 5 | GSBG29 | 2.909589 | 0.879889 |
| 6 | GSBU29 | 3.495890 | 0.850642 |
| 7 | GSBI30 | 3.991781 | 0.829706 |
| 8 | GSBW30 | 4.578082 | 0.815604 |
| 9 | GSBK31 | 5.076712 | 0.791152 |
| 10 | GSBU31 | 5.495890 | 0.780961 |
| 11 | GSBI32 | 5.994521 | 0.749443 |
| 12 | GSBU32 | 6.498630 | 0.741214 |
| 13 | GSBG33 | 6.912329 | 0.722600 |
| 14 | GSBU33 | 7.498630 | 0.702058 |
| 15 | GSBI34 | 7.994521 | 0.685323 |
| 16 | GSBW34 | 8.580822 | 0.662636 |
| 17 | GSBK35 | 9.079452 | 0.646369 |
| 18 | GSBW35 | 9.580822 | 0.627662 |
| 19 | GSBE36 | 9.830137 | 0.620526 |
| 20 | GSBS36 | 10.416438 | 0.586154 |
| 21 | GSBG37 | 10.915068 | 0.592999 |
| 22 | GSBK39 | 13.082192 | 0.524986 |
| 23 | GSBI41 | 15.000000 | 0.456791 |
| 24 | GSBE47 | 20.835616 | 0.313590 |
| 25 | GSBK51 | 25.090411 | 0.252241 |
| 26 | GSBK54 | 28.093151 | 0.217476 |
Stream PV 1 Apr=3,889,824.12, 25 May=3,901,223.29, ΔStream=11,399.17 ======================================================== Duration Hedge OUTCOME ======================================================== GSBQ26: Q= -42,656 ΔV= -18,853.95 GSBK54: Q= 45,313 ΔV= 26,281.54 Change in Bond Hedge=7,427.59 NET P&L=18,826.76 (whereas if unhedged=11,399.17) ======================================================== Duration + Convexity Hedge OUTCOME ======================================================== GSBQ26: Q= -74,652 ΔV= -32,996.18 GSBK35: Q= 64,076 ΔV= 81,376.52 GSBK39: Q= 23,039 ΔV= 47,506.42 Change in Bond Hedge=95,886.75 NET P&L=107,285.92 (whereas if unhedged=11,399.17)
3. Summary
The report constructs a discount-factor curve from 1 April 2026 bond prices, fits a Nelson-Siegel term structure, and compares the implied instantaneous forward rates with a log-linear interpolation benchmark.
Using the calibrated curve, the floating stream is valued and hedged with liquid Australian Government bonds under duration and duration-convexity objectives. The resulting hedge is then re-evaluated on 25 May 2026 to measure performance under updated market data.