Financial Markets Instruments — Bond Analytics Report

Interest Rate Term Structure Modelling and Hedging Using Australian Government Bonds

Prepared by: Hai Nam Nguyen
This report analyses Australian Government bond data to construct and compare interest rate term structures and evaluate hedging strategies. Key objectives include:
  • 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.

Imports used for the calibration, optimisation, and hedge construction steps.
In [1]:
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
In [2]:
# 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
In [3]:
# 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.

In [4]:
@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
No description has been provided for this image
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
No description has been provided for this image
No description has been provided for this image

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) $$

In [5]:
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.