Skip to content

This document provides a comprehensive guide coverering a wide range of topics in finance, particularly focusing on bond valuation and portfolio optimization strategies. Here's a summarized overview:

  • Time Value of Money: Explores the concept of present and future values, highlighting how money's worth changes over time due to investment returns. This section includes the calculations of future value and present value, along with understanding the discount rate and cumulative present value of future cash flows.

  • Bond Pricing and Duration: Discusses various aspects of bond valuation including pricing coupon-bearing bonds, understanding yield to maturity, and bond price relationships with interest rate fluctuations. It delves into the Macaulay Duration and its calculation, emphasizing its role in measuring a bond's sensitivity to interest rate changes.

  • Liability Driven Investing (LDI): Focuses on strategies to hedge future liabilities, particularly using zero-coupon and coupon-bearing bonds. The analysis includes simulations of interest rates and bond prices, comparisons of hedging strategies, and discussions on funding ratio and liability matching using duration-matched portfolios.

  • CIR Model: Introduces the Cox-Ingersoll-Ross (CIR) model for simulating interest rate fluctuations and its application in pricing zero-coupon bonds.

  • PSP and LHP Strategies: Examines Portfolio Optimization with a blend of Performance-Seeking Portfolio (PSP) and Liability-Hedging Portfolio (LHP). Various allocation strategies like fixed-mix, glide path, floor allocator, and drawdown allocator are discussed, highlighting their impact on portfolio risk and returns.

  • Modified Duration: Discusses the concept of Modified Duration, a measure used to estimate how much a bond's price will change with a change in its yield to maturity (YTM). The analysis includes practical computations and comparisons of bond prices under different YTM scenarios.

Throughout, Python libraries and custom functions are utilized for calculations and simulations, providing a practical approach to understanding these complex financial concepts. The analysis is comprehensive, covering theoretical aspects, practical applications, and risk management in portfolio optimization.

===

This segment employs Python libraries to illustrate fundamental principles of Portfolio Optimization:

python
import pandas as pd  # Utilized for data manipulation
import numpy as np  # Employed for numerical computations
import matplotlib.pyplot as plt  # Applied for creating visualizations
import scipy.stats  # Engaged for statistical calculations
from pandas_datareader import data  # Used to retrieve financial data
from datetime import datetime  # Essential for managing date and time entities
from scipy.optimize import minimize  # Implemented for conducting optimization tasks
import PortfolioOptimizationKit as pok  # A specialized toolkit dedicated to portfolio optimization
import seaborn as sns
from tabulate import tabulate

# Setting up the visualization style
sns.set(style="darkgrid")

Understanding the Time Value of Money

From the initial week's discussions, it's understood that for any specified time frame (t,t+k), where k>1, the compound return over that period is defined as:

Rt,t+k=(1+Rt,t+1)(1+Rt+1,t+2)(1+Rt+k1,t+k)1,

This equation represents the compounded growth from one period to the next. When returns are constant over each interval, say at a rate R the formula simplifies to:

Rt,t+k=(1+R)k1.

This simplification assumes a consistent return rate across each time period, compounding the growth.

Assuming Pt as the price at time t and Pt+k as the price after all returns have compounded over time, the relationship between the initial and final prices can be expressed as:

Pt+k=Pt(1+Rt,t+k)={Pt(1+Rt,t+1)(1+Rt+1,t+2)(1+Rt+k1,t+k)if returns are different,Pt(1+R)kif all returns are equal to R.

The first case handles scenarios where returns vary over different periods, while the second simplifies the calculation when returns are consistent. This illustrates the fundamental principle of the time value of money, where the value of a sum changes over time due to potential returns from investments.

Exploring Present and Future Value Concepts

In financial terms, Pt+k represents the future value, FV, of a sum equivalent to Pt.

Conversely, Pt is termed the present value, PV, of the future sum Pt+k expected at time t+k.

To illustrate, consider an investment of $100 today at a 5% annual interest rate. After one year, this investment would amount to $105. In this context, both $100 today, PV, and the future $105, FV, one year hence are equivalent for someone anticipating a 5% annual return, assuming no inflation. This exemplifies the concept that $100 today, compounded for one year at 5%, yields a future value of $105.

In scenarios where returns R remain constant over time, the relationship between present and future values can be described as follows:

  • To determine the future value, FV, from the present value, PV, the formula is:
FV=PV(1+R)k.

This equation compounds the present value over k periods at an interest rate of R.

  • Conversely, to calculate the present value, PV, from a known future value, FV, the formula becomes:
PV=FV(1+R)k.

This formula 'discounts' the future value back to the present, considering the interest rate over k periods.

These equations are fundamental in finance, used for calculating the equivalent value of money at different points in time, accounting for the potential returns (interests) that could be earned if invested.

Understanding the Discount Rate

The term

1(1+R)k,

is known as the discount factor. It represents the present value of $1 unit of currency to be received in k periods, at an interest rate of R.

Scenario Analysis: Consider being presented with two options: receiving $1000 today or the same amount in three years.

The logical choice is to take the money now. Here's why: if the present value, PV, is $1000, this sum could be invested to accrue additional value over three years. Assuming an annual investment rate of 3%, the future value of $1000 today would be:

FV=1000(1+0.03)3=1.092.7$.

Opting to receive $1000 in three years means that sum is the future value. To understand its present value, assuming the same interest rate, the calculation is:

PV=1000(1+0.03)3=915,14$,

This indicates that accepting $1000 in three years is equivalent to receiving $915.14 today, a lesser amount than $1000.

Another Scenario: What if the choice is between $1000 today or $1100 in three years?

This decision is more complex. If the hypothetical investment still yields a 3% annual return, then the present value of $1150 after three years is:

PV=1150(1+0.03)3=1052.41$.

In this case, receiving $1150 in three years is equivalent to $1052.41 today, more than the $1000 immediate option. Therefore, opting to wait for the larger sum is more beneficial.

These examples highlight the importance of understanding the time value of money and how the discount rate can influence financial decisions. The discount factor helps compare the value of money at different times, providing a basis for informed financial choices.

Grasping the Cumulative Present Value of Future Cash Flows

The cumulative present value, PV, of future cash flows is a method used to calculate the present worth of a series of future financial inflows and outflows. This technique is pivotal in determining the current value of future sums of money or streams of cash flows given a specified rate of return.

Consider a scenario where Company A has an obligation to pay Company B a sum of $25000 over three years, divided as follows: $8000 in the first year, $11000 in the second year, and $6000 in the third year. Company B applies an annual interest rate of 5%. The question arises: what is the present value of this obligation?

The present value of $8000 due in one year (denoted as FV1) is calculated as:

PV1=80001+0.05=7619.05$.

Similarly, for $11000 due in two years (FV2):

PV2=110001+0.05=9977.33$.

and for $6000 due in three years (FV3):

PV3=60001+0.05=5183.03$.

Summing these values gives the present value of the total obligation:

PVtotal=PV1+PV2+PV3=22779.4$.

In a general sense, if future cash flows are denoted as FVt the present value of a liability L can be represented as:

PV(L)=i=1NFVti(1+R)ti:=i=1NB(ti)Lti,

where Lti denotes the future cash flow at time ti, and B(ti):=1/(1+R)ti represents the discount factor.

python
PV = pok.discount(10, 0.03)
print(PV)

FV = PV * (1 + 0.03) ** 10
print(FV)

Consider two sets of liabilities (future cash flows) over the next three years:

python
L = pd.DataFrame([[8000, 11000], [11000, 2000], [6000, 15000]], index=[1, 2, 3])
print(L)

Assuming the first liability has an annual rate of 5% and the second one 3%, the present value of these liabilities is:

python
r = [0.05, 0.03]  # Defines interest rates for the two liabilities.
PV = pok.present_value(L, r)  # Calculates the present value of the liabilities using the defined interest rates.
print(PV)  # Displays the present values in a formatted table.

The total future values of the liabilities are $25000 and $28000, with their present values approximated to $22779.4 and $26292, respectively. If current assets are equivalent to or exceed these present values, meeting the future obligations wouldn't pose a problem, given the known interest rates. However, if the assets are worth less than these amounts, fulfilling the obligations could become challenging.

Understanding the Funding Ratio

The funding ratio is a straightforward yet crucial metric used in financial management to ascertain whether the current value of assets is sufficient to cover future liabilities. It is defined as the ratio between the current asset value and the present value, PV, of liabilities:

FR=assets valuesPV(L).

A funding ratio greater than 1 indicates that the current assets are more than adequate to cover the liabilities, whereas a ratio less than 1 signals potential future shortfalls.

Consider a scenario where the present value of a liability, PV(L), has been computed, and one needs to evaluate if the current assets are sufficient to meet this obligation.

python
# Assume current asset values
asset = [20000, 27332] 

# Calculate the funding ratio using the pok.funding_ratio function from the toolkit
FR = pok.funding_ratio(asset, L, r)

# Print the calculated funding ratio in a formatted table
print(tabulate(FR, headers='keys', tablefmt='github'))

In this example, if the current assets amount to $20000, they would not be sufficient to cover a future liability of $25000 due in three years for the first liability. Conversely, a current asset value of $27332 would adequately cover a second liability of $28000 due in the same period.

The following function show_funding_ratio is crafted to visually represent the funding ratio against various rates and asset values:

python
def show_funding_ratio(asset, L, r):
    fr = pok.funding_ratio(asset, L, r)  # Calculate funding ratio.
    print("Funding ratio: {:.3f}".format(float(fr)))  # Print funding ratio.

    # Set up a two-panel plot for visual representation.
    fig, ax = plt.subplots(1, 2, figsize=(15, 3))

    # Ensure r and fr are of the same length for plotting.
    r_array = np.full_like(fr, r)  # Create an array of r repeated to match fr length.
    
    # Plot the funding ratio against interest rates.
    ax[0].scatter(r_array, fr)
    ax[0].set_xlabel("rates")
    ax[0].set_ylabel("funding ratio")
    ax[0].set_xlim([0.0, max(r_array)*1.1])  # Adjust the x-axis limit based on r values.
    ax[0].set_ylim([min(fr)*0.9, max(fr)*1.1])  # Adjust the y-axis limit based on fr values.
    ax[0].plot(r_array, fr, color="b", alpha=0.5)
    # Draw red dashed lines from the point to the axes.
    ax[0].vlines(x=r, ymin=0, ymax=fr, colors='r', linestyles='dashed')
    ax[0].hlines(y=fr, xmin=0, xmax=r, colors='r', linestyles='dashed')
    ax[0].grid()

    # Handle the case where asset is not iterable.
    if not hasattr(asset, '__iter__'):  # If asset is not an iterable
        asset = [asset]  # Convert it into a list

    # Plot the funding ratio against asset values.
    ax[1].scatter(asset, fr)
    ax[1].set_xlabel("assets")
    ax[1].set_ylabel("funding ratio")
    ax[1].set_xlim([min(asset)*0.9, max(asset)*1.1])  # Adjust the x-axis limit based on asset values.
    ax[1].set_ylim([min(fr)*0.9, max(fr)*1.1])  # Adjust the y-axis limit based on fr values.
    ax[1].plot(asset, fr, color="b", alpha=0.5)
    # Draw red dashed lines from the point to the axes.
    for a in asset:
        ax[1].vlines(x=a, ymin=0, ymax=fr, colors='r', linestyles='dashed')
    ax[1].hlines(y=fr, xmin=0, xmax=max(asset), colors='r', linestyles='dashed')
    ax[1].grid()

    plt.show()  # Display the plots.

# Demonstration of the funding ratio function
r = 0.02  # Define an interest rate.
asset = 24000  # Define an asset value.
L = pd.DataFrame([8000, 11000, 6000], index=[1, 2, 3])  # Define liabilities.
show_funding_ratio(asset, L, r)  # Call the function to display the funding ratio.

This function takes the given asset value, liability, and interest rate, computes the funding ratio, and then visualizes how this ratio changes relative to variations in the interest rates and asset values. The plots help understand the sensitivity of the funding ratio to these parameters, allowing for better financial planning and risk assessment.

Nominal Rate and Effective Annual Interest Rate

Before delving into a stochastic equation that models the variations in interest rates, it's beneficial to revisit compound returns, albeit from a slightly different perspective that introduces a new set of terms.

Short-rate vs. Long-Rate (Annualized)

Remember, for a constant return R, the compound return over a duration from t to t+k is expressed as:

Rt,t+k=(1+R)k1.

Take, for instance, the act of borrowing an amount P:=1$, for one year at an annual interest rate of rann:=10%. Assuming the repayment is in one installment at the year's end, the return after one year is 10% , leading to a repayment of:

1+0.11=1.1$.

Now, consider the scenario where P is borrowed at the same interest rate r:=10%, but repayments are semi-annual. This situation results in a semi-annual rate of r/2=5%, accumulating to a total compound return of:

(1+r2)21=0.1025$,

resulting in a total repayment of 1+0.10251=1.1025$.

If repayments were instead monthly, the total would be:

(1+r12)121=0.1047$,

leading to a total repayment of 1+0.10471=1.1047$.

This illustrates that with more frequent compounding, the total compound return increases, necessitating a higher repayment amount.

Generally, for a nominal interest rate r (also known as the instantaneous interest rate) and N being the number of periods (or payment intervals for investments, loans, etc.), the total return is:

R=(1+rN)N1.

Here, R represents the annualized return or effective annual interest rate, derived from discrete compounding.

Additionally, the following relationship can be derived from the above formula:

r=N((1+R)1/N1).

Following the theoretical understanding of nominal and effective annual interest rates, the code below demonstrates the practical calculation using Python. It calculates the effective annual interest rate based on a nominal interest rate and a specified number of compounding periods per year.

python
# Define the nominal interest rate as 10% and the number of compounding periods as monthly (12 times a year)
nominal_rate = 0.1
periods_per_year = 12

# Calculate the rate for each period (month) and store it in a DataFrame for the first 10 periods
rets = pd.DataFrame([nominal_rate / periods_per_year for i in range(10)])
# Display the first three monthly rates to verify the calculations
print(rets.head(3))

The next part of the code computes the annualized return using a function the PortfolioOptomizerKit

python
# Calculate the annualized return
ann_ret = pok.annualize_rets(rets, periods_per_year)[0]
print("Annualized Return: ", ann_ret)

# Calculate the effective annual interest rate using the formula for discrete compounding
R = (1 + nominal_rate / periods_per_year) ** periods_per_year - 1
print("Effective Annual Interest Rate: ", R)

Continuous Compounding

When the formula for annualized return is examined, it becomes evident that as N , the number of compounding periods, increases indefinitely, the expression converges to:

limN(1+rN)Nt=ert,

resulting in the approximation for continuously compounded returns:

R=limN(1+rN)Nt1ert1.

Continuous compounding is a theoretical model, but it does have practical applications in certain scenarios.

python
# Generate a range of N values from 1 to 12, totaling 30 points.
N = np.linspace(1,12,30)
# Define a range of nominal rates from 5% to 20%.
nom_rates = np.arange(0.05,0.2,0.01)

# Initialize a plot with specified size.
fig, ax = plt.subplots(1,1,figsize=(10,6))

# Iterate over each nominal rate.
for r in nom_rates:
    # Plot discrete compounding for each rate and N.
    ax.plot(N, (1 + r / N)**N - 1)
    # Plot the line for continuously compounded return for each rate.
    ax.axhline(y=np.exp(r) -  1, color="r", linestyle="-.", linewidth=0.7)
    # Set the y-label as the formula for discrete compounding.
    ax.set_ylabel("Discrete compounding: (1+r/N)^N - 1")
    # Set the x-label as 'payments (N)'.
    ax.set_xlabel("payments (N)")
# Enable grid for better readability.
plt.grid()
plt.show()

This script generates a plot illustrating discrete compounding for various nominal interest rates and the number of payments. It also demonstrates the convergence of discrete compounding to continuous compounding as the number of payments increases.

The graph visualizes the relationship between discrete compounding and the number of payments for different nominal rates, with the continuously compounded return represented by red dashed lines. As the frequency of payments increases, the discrete compounding approaches the continuous compounding rate.

The following Python code snippets illustrate how to calculate discrete and continuous compounding rates using pok module.

python
# Assuming 'pok' is a module containing the necessary functions

# Set the nominal rate.
r = 0.1

# Calculate discrete compounding rate with 12 periods per year.
R_disc = pok.compounding_rate(r, periods_per_year=12)
print("Discrete Compounding Rate: ", R_disc)

# Calculate continuous compounding rate.
R_cont = pok.compounding_rate(r)
print("Continuous Compounding Rate: ", R_cont)

# Convert back the continuous compounding rate to the nominal rate.
print("Nominal Rate from Continuous Compounding: ", pok.compounding_rate_inv(R_cont))

CIR Model: Simulating Interest Rate Fluctuations

The CIR model, named after Cox, Ingersoll, and Ross, is a mechanism to simulate the variations in interest rates and serves as an enhancement of the Vasicek model. Its primary function is to circumvent the issue of negative interest rates. As a one-factor model, or short-rate model, it posits that interest rate movements are influenced solely by a single market risk factor. This model is often employed in the assessment of interest rate derivatives.

The dynamics of interest rates are defined as follows:

drt=a(brt)dt+σrtdWt,

where, Wt represents a Brownian motion, encapsulating the random market risk factor. The term b denotes the (long-term) mean interest rate, and brt reflects the current deviation of the interest rate from this long-term mean. The parameter a signifies the mean-reversion speed, indicating the rate at which the system returns to the mean interest rate.

The fluctuations in interest rates are therefore contingent on the long-term mean rate (b) and the speed at which the rates adjust back to this mean (a), after deviations caused by random shocks (σrtdWt). Notably, the standard deviation factor σrt precludes negative interest rates for all positive a and b. Additionally, a zero interest rate is also avoided if 2abσ2.

Especially when the rate rt is nearing zero, the standard deviation σrt diminishes, lessening the impact of random fluctuations. In such scenarios, the drift factor predominantly influences the rate, nudging it upward towards equilibrium.

Applying the CIR Model to Price Zero-Coupon Bonds

In the context of the no-arbitrage principle, zero-coupon bonds can be evaluated using the interest rate process delineated by the CIR model. The price P(t,T) of a zero-coupon bond with maturity T is exponentially affine to the interest rate and is expressed as:

P(t,T)=A(t,T)eB(t,T)rt,

where

A(t,T):=(2he(a+h)τ/22h+(a+h)(ehτ1))2ab/σ2,B(t,T):=2(ehτ1)2h+(a+h)(ehτ1),h:=a2+2σ2,τ:=Tτ.

Future discussions might delve deeper into this topic.

python
def show_cir(n_years=10, n_scenarios=10, a=0.05, b=0.05, sigma=0.04, periods_per_year=12, r0=None):
    rates, zcb_price = erk.simulate_cir(n_years=n_years, n_scenarios=n_scenarios, a=a, b=b, sigma=sigma, periods_per_year=periods_per_year, r0=r0)
    
    fig, ax = plt.subplots(1,2,figsize=(20,5))
    rates.plot(ax=ax[0], grid=True, title="CIR model: interest rates", color="indianred", legend=False)
    zcb_price.plot(ax=ax[1], grid=True, title="CIR model: ZCB price", color="indianred", legend=False)

cir_controls = widgets.interact(show_cir, 
                                n_years = (1, 10, 1), 
                                n_scenarios = (1, 200, 1), 
                                a = (0.005, 1, 0.005), 
                                b = (0.002, 0.15, 0.001), 
                                sigma = (0.001, 0.15, 0.001), 
                                periods_per_year = [12, 52, 252], 
                                r0 = (0.002, 0.30, 0.01)
                               )

It's crucial to acknowledge that interactive elements are best experienced through a Jupyter notebook, as it provides the necessary interface for ipywidgets. Running these widgets in a standard script or terminal won't yield interactive visualizations. Environments like VSCode, which typically incorporate Jupyter notebook support, facilitate the execution and interaction with these widgets within the editor.

Liability Hedging

Given a model to project interest rate changes and the corresponding fluctuations in zero-coupon bond prices, one can evaluate the efficacy of using zero-coupon bonds as a hedge against cash. Variations in interest rates significantly impact future liabilities and funding ratios. Thus, it's prudent to understand how a portfolio might respond to these changes.

The core issue is: there is a future liability to be met, and with fluctuating interest rates, it's crucial to implement a hedging strategy to ensure that the asset's increase in value will suffice.

Consider:

python
# Initial asset amount in millions of dollars
asset_0  = 0.75
# Total liability in millions of dollars
tot_liab = 1

# Nominal rate of the liability
mean_rate = 0.03
# Time horizon for the liability in years
n_years   = 10
# Number of different interest rate scenarios to simulate
n_scenarios = 10
# Number of periods per year for compounding
periods_per_year = 12

Here, the initial asset is $0.75 million (asset_0), and the total liability is $1 million (tot_liab) due in 10 years. The mean nominal rate of this liability is 3%.

Simulating Interest Rates and Zero-Coupon Bond Prices

Interest rates for the next 10 years are simulated starting from the mean rate:

python
# Simulate interest rates using the CIR model and calculate corresponding ZCB prices
rates, zcb_price = pok.simulate_cir(n_years=n_years, n_scenarios=n_scenarios, 
                                    a=0.05, b=mean_rate, sigma=0.08, periods_per_year=periods_per_year)
print(rates.head())

The liabilities over time are approximated using the simulated zero-coupon bond prices, reflecting the impact of interest rate changes:

python
# Assign the simulated ZCB prices as the liabilities
L = zcb_price
print(L.head())

Hedging with Zero-Coupon Bonds

The objective is to meet the liability in 10 years by investing the current assets in a zero-coupon bond:

python
# Calculate the price of a ZCB maturing in 10 years with a rate equal to the mean rate
zcb = pd.DataFrame(data=[tot_liab], index=[n_years])
zcb_price_0 = pok.present_value(zcb, mean_rate)
print(zcb_price_0)

This calculation estimates the present value of a zero-coupon bond that will pay off $1 million plus 3% interest in 10 years. The investment strategy involves purchasing such zero-coupon bonds:

python
# Calculate the number of bonds that can be bought with the initial assets
n_bonds = float(asset_0 / zcb_price_0)
print(n_bonds)

With the number of bonds determined, the future value of the assets invested in the zero-coupon bond can be tracked:

python
# Calculate the future asset value of the zero-coupon bond investment
asset_value_of_zcb = n_bonds * zcb_price
print(asset_value_of_zcb.head())

Hedging by Holding Cash

Alternatively, consider holding cash instead of investing in zero-coupon bonds:

python
# Calculate the future asset value when holding cash, accounting for compounding interest
asset_value_in_cash = asset_0 * (1 + rates/periods_per_year).cumprod()
print(asset_value_in_cash.head())

Comparing the Two Investment Strategies

Visualizing the future value of both investment strategies:

python
# Plotting the future values of assets when invested in cash and zero-coupon bonds
fig, ax = plt.subplots(1,2,figsize=(20,5))

asset_value_in_cash.plot(ax=ax[0], grid=True, legend=False, color="indianred", title="Future value of asset put in cash")
asset_value_of_zcb.plot(ax=ax[1], grid=True, legend=False, color="indianred", title="Future value of asset put in ZCB")
ax[0].axhline(y=1.0, linestyle=":", color="black")
ax[1].axhline(y=1.0, linestyle=":", color="black")
ax[0].set_ylabel("millions $")
ax[1].set_ylabel("millions $")
if periods_per_year == 12:
    ax[0].set_xlabel("months ({:.0f} years)".format((len(asset_value_in_cash.index)-1)/periods_per_year))
    ax[1].set_xlabel("months ({:.0f} years)".format((len(asset_value_in_cash.index)-1)/periods_per_year))

plt.show()

While the cash investment's increase in value appears smoother, there are scenarios where it fails to meet the $1 million liability. Conversely, despite fluctuations, zero-coupon bond investments consistently meet the required amount at maturity.

The funding ratios for both investments are then examined:

python
# Calculate the funding ratios for both cash and zero-coupon bond investments
fr_cash = asset_value_in_cash / L
fr_zcb  = asset_value_of_zcb  / L

# Plotting the funding ratios and their percentage changes for both investments
fig, ax = plt.subplots(2,2,figsize=(20,8))

fr_cash.plot(ax=ax[0,0], grid=True, legend=False, color="indianred", 
             title="Funding ratios of investment in cash ({} scenarios)".format(n_scenarios))
fr_zcb.plot(ax=ax[0,1], grid=True, legend=False, color="indianred", 
            title="Funding ratios of investment in ZCB ({} scenarios)".format(n_scenarios))

ax[0,0].axhline(y=1.0, linestyle=":", color="black")
ax[0,1].axhline(y=1.0, linestyle=":", color="black")

fr_cash.pct_change().plot(ax=ax[1,0], grid=True, legend=False, color="indianred",
                          title="Pct changes in funding ratios of investment in cash ({} scenarios)".format(n_scenarios))
fr_zcb.pct_change().plot(ax=ax[1,1], grid=True, legend=False, color="indianred", 
                         title="Pct changes in funding ratios of investment in ZCB ({} scenarios)".format(n_scenarios))
plt.show()

For a more extensive analysis, the terminal funding ratios are considered:

python
# Simulate a larger number of scenarios
n_scenarios = 5000
rates, zcb_price = pok.simulate_cir(n_years=n_years, n_scenarios=n_scenarios, a=0.05, 
                                    b=mean_rate, sigma=0.08, periods_per_year=periods_per_year)
# Assign the simulated ZCB prices as liabilities
L = zcb_price
# Recalculate the ZCB and cash investments
zcb = pd.DataFrame(data=[tot_liab], index=[n_years])
zcb_price_0 = pok.present_value(zcb, mean_rate)
n_bonds = float(asset_0 / zcb_price_0)
asset_value_of_zcb = n_bonds * zcb_price
asset_value_in_cash = asset_0 * (1 + rates/periods_per_year).cumprod()

# Calculate terminal funding ratios
terminal_fr_zcb  = asset_value_of_zcb.iloc[-1]  / L.iloc[-1]
terminal_fr_cash = asset_value_in_cash.iloc[-1] / L.iloc[-1]

# Plotting histograms of terminal funding ratios for cash and zero-coupon bond investments
ax = terminal_fr_cash.plot.hist(label="(Terminal) Funding Ratio of investment in cash", bins=50, figsize=(12,5), color="orange", legend=True)
terminal_fr_zcb.plot.hist(ax=ax, grid=True, label="(Terminal) Funding Ratio of investment in ZCB", bins=50, legend=True, color="blue", secondary_y=True)
ax.axvline(x=1.0, linestyle=":", color="k")
ax.set_xlabel("funding ratios")
plt.show()

This analysis shows that while cash investments can perform well in many scenarios, there's a significant risk of failing to meet liabilities. Conversely, zero-coupon bond investments consistently provide a funding ratio of at least 1, ensuring the liabilities are met.

Coupon-Bearing Bonds

Contrasting with zero-coupon bonds, which offer a single cash flow comprising the principal (also known as the face value, or par value) plus accrued interest, a coupon-bearing bond disburses regular coupons throughout its maturity. The final cash flow includes the last coupon in addition to the principal:

Bond price=PV=i=1N(Cti(1+YTM)ti)+F(1+YTM)tN=C(1(1+YTM)tNYTM)+F(1+YTM)tN,

where N is the total number of coupons, Cti represents the coupon paid at time ti (all are identical), F is the bond's face value, and YTM denotes the yield to maturity. Yield to maturity is the annual rate yield achieved when an investor holds the bond until its maturity.

Cash Flow from a Bond

Consider a bond's cash flow:

python
# Bond parameters
principal        = 100 
maturity         = 3
ytm              = 0.05
coupon_rate      = 0.03 
coupons_per_year = 2

# Calculating bond cash flows
cf = pok.bond_cash_flows(principal=principal, maturity=maturity, coupon_rate=coupon_rate, coupons_per_year=coupons_per_year)
print(cf)

With bi-annual coupons, each payment is $0.015 (half the coupon rate) over 6 periods, with the final payment including both the face value and the last coupon.

Bond Price Calculation

The bond's price is calculated as:

python
# Calculating the bond price given its parameters and YTM
bond_price = pok.bond_price(principal=principal, maturity=maturity, coupon_rate=coupon_rate, coupons_per_year=coupons_per_year, ytm=ytm)
print(bond_price)

# Calculating the total sum paid by the bond if held until maturity 
tot_bond_paym = cf.sum()[0]
print(tot_bond_paym)

# Calculating the gain from investing in the bond
gain = -bond_price + tot_bond_paym
print(gain)

The Yield to Maturity (YTM) of 0.05 approximately represents the annual rate that, after compounding, yields a total amount equal to tot_bond_paym from an initial investment equal to bond_price:

python
# Calculating the annual rate corresponding to the YTM
r = (tot_bond_paym / bond_price )**(1/maturity) - 1
print(r)

Yield to Maturity and Bond Price Relationship

The relationship between the bond's selling price and its face value is dependent on the YTM in relation to the coupon rate:

python
# Calculating bond prices under different scenarios to illustrate the relationship between YTM and bond price
# Bond selling at a discount: bond price is smaller than face value
pok.bond_price(principal=100, maturity=3, coupon_rate=0.03, coupons_per_year=2, ytm=0.05)

# Bond selling at a premium: bond price is larger than face value
pok.bond_price(principal=100, maturity=3, coupon_rate=0.03, coupons_per_year=2, ytm=0.02)

# Bond selling at par: bond price is equal to face value
pok.bond_price(principal=100, maturity=3, coupon_rate=0.03, coupons_per_year=2, ytm=0.03)

# Plotting the relationship between YTM and bond price
coupon_rate = 0.04
principal = 100
ytm = np.linspace(0.01, 0.10, 20)
bond_prices = [erk.bond_price(maturity=3, principal=principal, coupon_rate=coupon_rate, coupons_per_year=2, ytm=r) for r in ytm]

# Visualizing the bond price as a function of YTM
ax = pd.DataFrame(bond_prices, index=ytm).plot(grid=True, title="Relation between bond price and YTM", figsize=(9,4), legend=False)
ax.axvline(x=coupon_rate, linestyle=":", color="black")
ax.axhline(y=principal, linestyle=":", color="black")
ax.set_xlabel("YTM")
ax.set_ylabel("Bond price (Face value)")
plt.show()

The bond sells at a discount when its price is below the face value, typically when the YTM is greater than the coupon rate. It sells at a premium when the price exceeds the face value, usually when the YTM is lower than the coupon rate. When the bond sells at par, its price equals the face value, occurring when the YTM matches the coupon rate. Thus, the bond price and YTM share an inverse relationship.

Variations in Bond Price

The yield to maturity is not a constant but varies over time. It's an interest rate that fluctuates, consequently altering the bond's price.

Observing Price Changes with Interest Rate Fluctuations

To understand how a coupon-bearing bond's price shifts with changing interest rates, consider simulating these rates using the CIR model:

python
# Simulation parameters
n_years          = 10
n_scenarios      = 10
b                = 0.03  # Long-term mean interest rate
periods_per_year = 2

# Simulating interest rates using the CIR model
rates, _ = pok.simulate_cir(n_years=n_years, n_scenarios=n_scenarios, a=0.02, b=b, sigma=0.02, periods_per_year=periods_per_year)
print(rates.tail())

# Bond characteristics
principal        = 100
maturity         = n_years
coupon_rate      = 0.04
coupons_per_year = periods_per_year

# Calculate bond prices based on the simulated interest rates
bond_prices = pok.bond_price(principal=principal, maturity=maturity, coupon_rate=coupon_rate, 
                             coupons_per_year=coupons_per_year, ytm=rates)

# Plotting the changes in interest rates and corresponding bond prices
fig, ax = plt.subplots(1,2,figsize=(20,5))
rates.plot(ax=ax[0], grid=True, legend=False) 
bond_prices.plot(ax=ax[1], grid=True, legend=False)
ax[0].set_xlabel("months")
ax[0].set_ylabel("interest rate (ytms)")
ax[1].set_xlabel("months")
ax[1].set_ylabel("bond price")
plt.show()

The left graph illustrates interest rate changes over time, while the right graph shows the corresponding shifts in bond price. Notably, the price of bonds at maturity converges across all scenarios, reflecting the principal plus coupon-interest.

Calculating Total Return of a Coupon-Bearing Bond

To comprehend the total returns of these bonds, one might initially consider the price changes. However, this approach omits coupon payments:

python
# Computing return by percentage changes in bond price
bond_rets = bond_prices.pct_change().dropna()

# Annualizing the returns
pok.annualize_rets(bond_rets, periods_per_year=periods_per_year)

This calculation yields a uniform negative return across all bonds, an artifact of disregarding coupon payments and the coupon rate exceeding the mean interest rate b.

In contrast, bonds regularly disburse coupons. To accurately calculate total returns, consider these payments:

python
# Calculating total bond returns by considering coupon payments
bond_rets = pok.bond_returns(principal=principal, bond_prices=bond_prices, coupon_rate=coupon_rate, 
                             coupons_per_year=coupons_per_year, periods_per_year=periods_per_year)
pok.annualize_rets(bond_rets, periods_per_year=periods_per_year)

These figures represent the correctly computed total returns of the bonds, aligning closely with the mean rate b (0.03).

In cases where the bond price and interest rate are fixed, the total return is as follows:

python
# Setting a fixed yield to maturity
ytm = 0.035
# Calculating the bond price with the given YTM
b_price = pok.bond_price(principal=principal, maturity=maturity, coupon_rate=coupon_rate, coupons_per_year=coupons_per_year, ytm=ytm) 

# Calculating total returns for the bond at the given price
b_ret = pok.bond_returns(principal=principal, bond_prices=b_price, coupon_rate=coupon_rate, 
                         coupons_per_year=coupons_per_year, periods_per_year=periods_per_year, maturity=maturity)

# Displaying the bond price and return
print("Bond price:  {:.6f}".format(b_price))
print("Bond return: {:.6f}".format(b_ret))

Here, the return approximates the YTM, demonstrating the relationship between a bond's yield to maturity and its total return.

Macaulay Duration

Macaulay Duration represents the weighted average time to receive the bond's cash flows. Consider a bond with a series of fixed cash flows, including coupon payments and the principal's final payment. The total present value of these cash flows is:

PV=i=1NPVi.

The Macaulay duration is then calculated as:

MacD:=i=1NtiPVii=1NPVi=i=1NtiPViPV=i=1Nwitiwithwi:=PViPV,

where PVi is the present value of the cash flow paid at time ti. For a zero-coupon bond, which only pays at maturity, the Macaulay duration equals the bond's maturity.

Calculating Macaulay Duration for a Bond

python
# Bond parameters
principal        = 1000
maturity         = 3
ytm              = 0.06
coupon_rate      = 0.06
coupons_per_year = 2

# Calculating bond cash flows
cf = pok.bond_cash_flows(principal=principal, maturity=maturity, coupon_rate=coupon_rate, coupons_per_year=coupons_per_year)
print(cf)

# Calculating Macaulay Duration using the YTM divided by the number of coupons per year
macd = pok.mac_duration(cf, discount_rate=ytm/coupons_per_year) 
macd = macd / coupons_per_year
print(macd)

The calculated duration of the bond is approximately 2.79 years versus the actual maturity of 3 years. The duration is adjusted to reflect the number of total periods (from 1 to 6) by dividing by the number of coupons per year.

Alternative Approach: Normalizing Cash Flow Dates

python
# Normalizing cash flows dates
cf = pok.bond_cash_flows(principal=principal, maturity=maturity, coupon_rate=coupon_rate, coupons_per_year=coupons_per_year)
cf.index = cf.index / coupons_per_year
print(cf)

# Calculating Macaulay Duration using only the YTM as discount rate
pok.mac_duration(cf, discount_rate=ytm)

In this method, only the YTM is used as the discount rate. The interpretation is that, as the bond pays coupons during its life, the effective time to recoup the investment is less than the maturity due to the receipt of money throughout the bond's term.

Validating Zero-Coupon Bond Duration

python
# Zero-Coupon Bond: only one cash flow at maturity
maturity = 3
cf = pd.DataFrame(data=[100], index=[maturity])
# Calculating Macaulay Duration for a zero-coupon bond, the rate is irrelevant
macd = pok.mac_duration(cf, discount_rate=0.05) # the rate does not impact the duration
print(macd)

This confirms that for a zero-coupon bond, the Macaulay Duration is equal to the maturity, regardless of the interest rate. This demonstrates that duration is a measure of the time-weighted cash flows of a bond and provides an insight into the bond's sensitivity to interest rate changes.

Liability Driven Investing (LDI)

Creating Duration-Matched Portfolios

The aim is to construct a bond portfolio with a duration matching that of a future liability. Consider an asset with the following value:

python
# Initial asset value
asset_value = 130000

This code initializes the asset value, which is the amount available to invest in the bond portfolio.

Assume there are future liabilities represented by a series of cash flows. The goal is to determine the weight distribution across various bonds in the portfolio so that the portfolio's duration aligns with the liabilities' duration.

python
# Interest rate and liabilities defined
interest_rate = 0.04

L = pd.DataFrame([100000, 100000], index=[10,12])
print(L)

# Calculating Macaulay duration of liabilities
macd_liab = pok.mac_duration(L, discount_rate=interest_rate)
print("Liability duration: {:.3f} years".format(macd_liab))

In this example, two equal payments are due in 10 and 12 years, resulting in a liability duration of about 10.96 years.

If an ideal zero-coupon bond matching the liability duration is unavailable, the strategy might involve investing in available bonds with different maturities, such as 10-year and 20-year bonds:

python
# Defining bond parameters
principal = 1000
maturity_short = 10
coupon_rate_short = 0.05 
coupons_per_year_short = 1
ytm_short = interest_rate

maturity_long = 20
coupon_rate_long = 0.05 
coupons_per_year_long = 1
ytm_long = interest_rate

First, examine the durations of these bonds:

python
# Calculating cashflows for short and long bonds
cf_short = pok.bond_cash_flows(principal=principal, maturity=maturity_short, coupon_rate=coupon_rate_short, coupons_per_year=coupons_per_year_short)
cf_long  = pok.bond_cash_flows(principal=principal, maturity=maturity_long, coupon_rate=coupon_rate_long, coupons_per_year=coupons_per_year_long)

# Calculating Macaulay durations for short and long bonds
macd_short = pok.mac_duration(cf_short, discount_rate=ytm_short /coupons_per_year_short) /coupons_per_year_short
macd_long  = pok.mac_duration(cf_long,  discount_rate=ytm_long  /coupons_per_year_long)  /coupons_per_year_long
print("(Short) bond duration: {:.3f} years".format(macd_short))
print("(Long) bond duration:  {:.3f} years".format(macd_long))

Next, determine the investment proportions for the short and long bonds to match the portfolio duration with the liabilities:

wds+(1w)d=dliabw=dliabddsd.

where dliab is the duration of the liability, ds is the duration of the short-term bond, and d is the duration of the long-term bond.

python
# Calculating weight for the short bond to match the portfolio duration with liability duration
w_short = (macd_liab - macd_long) / (macd_short - macd_long)
print(w_short)

This output indicates that approximately 48% of the funds should be allocated to the short-term bond, and the remaining 52% to the long-term bond.

Before proceeding, determine the bond prices:

python
# Determine prices for both short and long-term bonds
bondprice_short = pok.bond_price(principal=principal, maturity=maturity_short, coupon_rate=coupon_rate_short, 
                                 coupons_per_year=coupons_per_year_short, ytm=ytm_short)
bondprice_long  = pok.bond_price(principal=principal, maturity=maturity_long, coupon_rate=coupon_rate_long, 
                                 coupons_per_year=coupons_per_year_long, ytm=ytm_long)
print("Price of the short-term bond: {:.2f}".format(bondprice_short))
print("Price of the long-term bond: {:.2f}".format(bondprice_long))

Determine the portfolio's cash flows:

python
# Calculate portfolio cashflows for short and long-term bonds
portfolio_cf_short = w_short     * asset_value / bondprice_short * cf_short
portfolio_cf_long  = (1-w_short) * asset_value / bondprice_long  * cf_long

# Combine cashflows from both bonds
portfolio_cf = pd.concat([portfolio_cf_short, portfolio_cf_long], axis=1).fillna(0)        
portfolio_cf.columns = ["Cashflow from short-term bond", "Cashflow from long-term bond"]

# Add the total cashflow of the portfolio
portfolio_cf["Total Portfolio Cashflow"] = portfolio_cf.sum(axis=1)
print(portfolio_cf)

These cashflows represent the individual contributions from the short and long-term bonds and the aggregate portfolio cashflow. Verify the portfolio's duration matches the liability's duration:

python
# Convert the portfolio cashflow to a dataframe
portfolio_cf = pd.DataFrame(portfolio_cf["Total Portfolio Cashflow"])
portfolio_cf.columns = [0]

# Calculate Macaulay duration for the portfolio
macd_portfolio = pok.mac_duration(portfolio_cf, discount_rate=interest_rate)
print("Duration of the portfolio: {:.3f} years".format(macd_portfolio))

The output should confirm the portfolio's duration is aligned with the liability's duration, ensuring that the strategy is correctly implemented.

Exceptionally, the method for computing the funding ratio in PortfolioOptimazerKit will be modifed to consider the present value of both liabilities and bond cashflows:

python
def funding_ratio(asset_value, liabilities, r):
    '''Computes the funding ratio between the present value of holding assets and the present 
    value of the liabilities given an interest rate r (or a list of)'''
    return pok.present_value(asset_value, r) / pok.present_value(liabilities, r)

Finally, assess the funding ratios for different investment strategies:

python
# Series of cashflows for short and long-term bonds
short_bond_asset = asset_value / bondprice_short * cf_short
long_bond_asset  = asset_value / bondprice_long * cf_long

# Range of interest rates
rates = np.linspace(0, 0.1, 20)

# Calculate funding ratios for different investment strategies
funding_ratios = pd.DataFrame(
    {
        "Funding Ratio with Short-term Bond": [funding_ratio(short_bond_asset, L, r)[0] for r in rates],
        "Funding Ratio with Long-term Bond": [funding_ratio(long_bond_asset, L, r)[0] for r in rates],
        "Funding Ratio with Duration Matched Bonds": [funding_ratio(portfolio_cf, L, r)[0] for r in rates]
    }, index = rates
)

# Plotting funding ratios against interest rates
ax = funding_ratios.plot(grid=True, figsize=(10,5), title="Funding ratios with varying interest rates")
ax.set_xlabel("Interest rates")
ax.set_ylabel("Funding ratios")
ax.axhline(y=1, linestyle="--", c="k")
plt.show()

This plot illustrates how investing in a portfolio with a duration matched to the liabilities guarantees a funding ratio greater than or equal to 1 across a range of interest rates, contrasting with the other strategies where the funding ratio can fall below 1 under certain conditions.

Constructing Portfolios with Non-Matching Bond Durations

In scenarios where the available bonds do not perfectly match the desired duration, an adjustment is needed. The method duration_match_weight calculates the weights for two bonds to achieve the target duration:

python
def duration_match_weight(d1, d2, d_liab):
    w1 = (d_liab - d2) / (d1 - d2)
    w2 = 1 - w1
    return w1, w2

This function calculates the proportions to invest in two bonds to match a target duration.

Consider the following flat interest rate and liabilities:

python
flat_yield = 0.05

# Defining future liabilities
L = pd.DataFrame([100000, 200000, 300000], index=[3,5,10])
print(L)

# Calculating the Macaulay duration for these liabilities
macd_L = pok.mac_duration(L, discount_rate=flat_yield)
print("Duration of liabilities: ", macd_L)

This setup specifies three future payments, with the calculated Macaulay duration indicating the weighted average time until these cash flows are received.

The available bonds are detailed as follows:

python
principal = 1000

# Details for Bond 1
maturity_b1         = 15
coupon_rate_b1      = 0.05
ytm_b1              = flat_yield
coupons_per_year_b1 = 2

# Details for Bond 2
maturity_b2         = 5
coupon_rate_b2      = 0.06
ytm_b2              = flat_yield
coupons_per_year_b2 = 4

Calculate the cash flows for these bonds and normalize the dates:

python
# Calculate cash flows for both bonds and normalize dates
cf_b1 = pok.bond_cash_flows(principal=principal, maturity=maturity_b1, coupon_rate=coupon_rate_b1, coupons_per_year=coupons_per_year_b1)
cf_b2 = pok.bond_cash_flows(principal=principal, maturity=maturity_b2, coupon_rate=coupon_rate_b2, coupons_per_year=coupons_per_year_b2)
cf_b1.index = cf_b1.index / coupons_per_year_b1
cf_b2.index = cf_b2.index / coupons_per_year_b2

This normalization ensures that the cash flows are comparable despite the different coupon frequencies.

Next, calculate the durations of these bonds:

python
# Compute Macaulay durations for both bonds
macd_b1 = pok.mac_duration(cf_b1,discount_rate=ytm_b1) 
print("Duration of Bond 1: ", macd_b1)

macd_b2 = pok.mac_duration(cf_b2,discount_rate=ytm_b2)
print("Duration of Bond 2: ", macd_b2)

These durations reflect the weighted average time until each bond's cash flows are received.

Determine the weights for these bonds to match the liabilities' duration:

python
# Calculate the weights for the bonds to match the liability duration
w_b1, w_b2 = duration_match_weight(macd_b1, macd_b2, macd_L)
print("Weight in Bond 1: ", w_b1)
print("Weight in Bond 2: ", w_b2)

These weights indicate the proportion of total assets to allocate to each bond to achieve the target portfolio duration.

To verify the constructed portfolio's duration matches the liability's duration, compute the bond prices:

python
# Calculate prices for both bonds
bprice_b1 = pok.bond_price(principal=principal, maturity=maturity_b1, coupon_rate=coupon_rate_b1, 
                           coupons_per_year=coupons_per_year_b1, ytm=ytm_b1)
bprice_b2 = pok.bond_price(principal=principal, maturity=maturity_b2, coupon_rate=coupon_rate_b2, 
                           coupons_per_year=coupons_per_year_b2, ytm=ytm_b2)
print("Price of Bond 1: ", bprice_b1)
print("Price of Bond 2: ", bprice_b2)

These prices are crucial for determining the number of each bond to purchase.

Calculate the portfolio's cash flows:

python
# Compute portfolio cashflows from both bonds
portfolio_cf_b1 = w_b1 * asset_value / bprice_b1 * cf_b1
portfolio_cf_b2 = w_b2 * asset_value / bprice_b2 * cf_b2

# Combine cashflows from both bonds
portfolio_cf = pd.concat([portfolio_cf_b1, portfolio_cf_b2], axis=1).fillna(0)        
portfolio_cf.columns = ["Cashflow from Bond 1", "Cashflow from Bond 2"]

# Add the total cashflow of the portfolio
portfolio_cf["Total Portfolio Cashflow"] = portfolio_cf.sum(axis=1)
print(portfolio_cf)

The individual and total cash flows are computed for the portfolio, considering the allocation to each bond.

Finally, compute the duration of the portfolio:

python
# Convert the portfolio cashflow to a dataframe
portfolio_cf = pd.DataFrame(portfolio_cf["Total Portfolio Cashflow"].rename(0))

# Calculate Macaulay duration for the portfolio
macd_portfolio = pok.mac_duration(portfolio_cf, discount_rate=flat_yield)  
print("Duration of the portfolio: ", macd_portfolio)
print("Duration of the liabilities: ", macd_L)

This output should confirm whether the portfolio's duration is closely aligned with the liability's duration. The slight difference in duration between the portfolio and the liabilities indicates the challenge of matching durations when the available bonds do not pay an equal number of coupons per year.

Integrating Performance-Seeking Portfolio (PSP) with Liability-Hedging Portfolio (LHP)

In the Liability Driven Investing (LDI) framework, portfolios often consist of two distinct components. One is the Performance-Seeking Portfolio (PSP), focused on diversified and efficient access to risk premia for profit. The other is the Liability-Hedging Portfolio (LHP), aimed at hedging against future liabilities. An investor typically holds these two blocks: one for performance and the other for hedging.

The previous section emphasized the LHP aspect of the LDI strategy, constructing a bond portfolio to hedge against liabilities by matching the duration.

Naive PSP/LHP Weighting Strategy

A rudimentary approach to LDI is a fixed-mix combination of PSP and LHP, where the allocation to the PSP is adjusted to reach a target risk level. This section explores mixing an LHP (composed of bonds) with a PSP (composed of stocks) using a fixed-mix strategy, where allocation weights are predetermined.

Firstly, consider a set of two bonds available for investment - a short-term and a long-term bond:

python
# Bond details
principal = 100

# Short-term bond parameters
maturity_short         = 10
coupon_rate_short      = 0.028
coupons_per_year_short = 2

# Long-term bond parameters
maturity_long          = 20
coupon_rate_long       = 0.035
coupons_per_year_long  = 2

Next, generate interest rates and zero-coupon bond prices for a number of scenarios:

python
# Simulation parameters
n_scenarios      = 1000
n_years          = np.max([maturity_short, maturity_long])  # = maturity_long
mean_rate        = 0.03
periods_per_year = 2

# Simulating rates and zero-coupon bond prices
rates, zcb_price = pok.simulate_cir(n_years=n_years, n_scenarios=n_scenarios, a=0.05, b=mean_rate, 
                                    sigma=0.02, periods_per_year=periods_per_year)
print(rates.tail())

This simulation provides the basis for pricing the bonds and understanding the potential future environment.

Using the generated rates, calculate bond prices (note this may take some time with a large number of scenarios):

python
# Calculate bond prices for short and long-term bonds
l = int(coupons_per_year_short * n_years / periods_per_year)

bond_pr_short = pok.bond_price(principal=principal, maturity=maturity_short, coupon_rate=coupon_rate_short, 
                               coupons_per_year=coupons_per_year_short, ytm=rates.iloc[:l+1,:]) 

bond_pr_long = pok.bond_price(principal=principal, maturity=maturity_long, coupon_rate=coupon_rate_long, 
                              coupons_per_year=coupons_per_year_long, ytm=rates).iloc[:l+1,:]

Since the bonds have different maturities, the correct number of rows is selected to align the bond price calculations.

Calculate the returns for these bonds:

python
# Calculate returns for both short and long-term bonds
bond_rets_short = pok.bond_returns(principal=principal, bond_prices=bond_pr_short, coupon_rate=coupon_rate_short, 
                                   coupons_per_year=coupons_per_year_short, periods_per_year=periods_per_year)

bond_rets_long = pok.bond_returns(principal=principal, bond_prices=bond_pr_long, coupon_rate=coupon_rate_long, 
                                   coupons_per_year=coupons_per_year_long, periods_per_year=periods_per_year)

These returns will form the basis of the LHP, a fixed-mix of the two bonds. A 60/40 allocation is chosen for demonstration (60% in the short-term bond and 40% in the long-term bond):

python
# Define the weight for the short-term bond
w1 = 0.6

# Mix the returns of the two bonds to form the LHP
bond_rets = pok.ldi_mixer(bond_rets_short, bond_rets_long, allocator=pok.ldi_fixed_allocator, w1=w1)
print(bond_rets.head())

The dataframe contains scenarios of returns for a liability-hedging portfolio consisting of the two bonds with a fixed 60/40 allocation.

Construct the PSP composed of stocks, generated via random walks:

python
# Simulate stock prices and returns for the PSP
stock_price, stock_rets = pok.simulate_gbm_from_prices(n_years=maturity_short, n_scenarios=n_scenarios, 
                                                       mu=0.07, sigma=0.1, periods_per_year=2, start=100.0)

This simulation provides a hypothetical PSP consisting of stocks, an essential contrast to the bond-focused LHP in a balanced LDI strategy. Combining these components allows for tailored risk management and performance targeting in line with an investor's liabilities and goals.

Implementing Fixed-Mixed Allocation in PSP/LHP Strategy

In the realm of Liability Driven Investing (LDI), one common strategy is to blend a Performance-Seeking Portfolio (PSP) with a Liability-Hedging Portfolio (LHP) using a fixed-mix allocation. This approach involves pre-determining the proportion of assets allocated to each portfolio throughout the investment's life.

For demonstration, let's consider a 70/30 allocation, where 70% of the investment is in a diversified portfolio of stocks (PSP) and 30% in a portfolio of bonds (LHP):

python
# Define the fixed allocation weights for Stocks/Bonds
w1 = 0.7

# Combine the returns of the stocks and bonds using the fixed allocation
stock_bond_rets = pok.ldi_mixer(stock_rets, bond_rets, allocator=pok.ldi_fixed_allocator, w1=w1)
print(stock_bond_rets.head())

This code mixes the stock and bond returns based on the defined allocation, creating a combined PSP/LHP portfolio.

Next, generate a statistical summary of this PSP/LHP portfolio:

python
# Compute and print the stats summary of the PSP/LHP portfolio
stock_bond_rets_stats = pok.summary_stats(stock_bond_rets, risk_free_rate=0, periods_per_year=2)
print(stock_bond_rets_stats.tail())

The summary provides detailed statistics for each scenario. To get a broader view, the average of these above statistics across all scenarios can be calculated:

python
# Print the mean statistics across all scenarios
print(stock_bond_rets_stats.mean())

The statistics provided are a summary of the performance metrics for a fixed-mix PSP/LHP strategy, where PSP is primarily stocks and LHP is bonds. Here's an interpretation of each statistic:

Portfolio Statistics:

  • Annualized Return (Ann. return): Rann=0.054179. This is the average annualized return of the portfolio. It indicates that, on average, the portfolio yields a 5.41% return per year.

  • Annualized Volatility (Ann. vol): σann=0.070199. This represents the annualized volatility of the portfolio, a measure of the variation in returns. A 7.00% annual volatility indicates moderate fluctuations in the portfolio's value.

  • Sharpe Ratio: Sharpe=0.796418. This measures the excess return (return over the risk-free rate) per unit of risk (volatility). A ratio of 0.794 suggests the portfolio offers a decent return for the taken risk, with higher values generally indicating better risk-adjusted returns.

  • Skewness: Skewness=0.033944. This measures the asymmetry of the return distribution. A negative skewness indicates that the distribution of returns is skewed left, meaning there are more frequent instances of returns that are lower than the mean. In other words, the portfolio experiences relatively more extreme negative returns than positive ones. On the other hand, a skewness close to 0 indicates a symmetrical distribution of returns. Here, the near-zero skewness suggests returns are fairly symmetrically distributed around the mean.

  • Kurtosis: Kurtosis=2.708695. This measures the 'tailedness' of the return distribution. A kurtosis less than 3 (the kurtosis of a normal distribution) suggests a distribution with thinner tails, indicating fewer extreme returns (both positive and negative) than a normal distribution.

  • Historic Conditional Value at Risk (Historic CVar): CVarhist=0.067921. This represents the average loss over a specified period, assuming that loss is beyond the Value at Risk (VaR) threshold. A 6.79% CVaR indicates that, in the worst 5% of cases, the average loss would be 6.55%.

  • Cornish-Fisher Value at Risk (C-F Var): VaRCF=0.052318. This is a measure that adjusts the VaR to account for skewness and kurtosis. A 5.23% C-F Var implies that, considering the actual distribution of returns, the portfolio's VaR is 5.23%.

  • Maximum Drawdown: Max Drawdown=0.088324. This is the largest peak-to-trough decline in the portfolio's value. Here, the portfolio has experienced a maximum loss of 8.83% from a peak during its lifetime.

To further analyze the strategy's effectiveness, especially in terms of risk management, the terminal statistics are considered:

python
# Define a floor value for risk assessment
floor = 0.8

# Calculate and print the summary stats of terminal parameters for different investment strategies
ldi_stats = pd.concat([
    pok.summary_stats_terminal(bond_rets, floor=floor, periods_per_year=periods_per_year, name="Bonds only"),
    pok.summary_stats_terminal(stock_rets, floor=floor, periods_per_year=periods_per_year, name="Stocks only"),
    pok.summary_stats_terminal(stock_bond_rets, floor=floor, periods_per_year=periods_per_year, name="70/30 Stocks/Bonds"),
], axis=1)
print(ldi_stats)

This output reveals the risk and return profiles of investing solely in bonds, stocks, or the mixed PSP/LHP. It particularly highlights the probability of breaching the defined floor.

  • Mean Annualized Return: For 'Bonds only' (3.12%), 'Stocks only' (6.19%), and '70/30 Stocks/Bonds' (5.37%), indicating the return balance between risk and reward.

  • Mean Wealth: Indicates the average final wealth for each strategy, with stocks showing the highest mean wealth due to higher returns.

  • Mean Wealth Standard Deviation: Reflects the variability in final wealth, with stocks showing the highest variability, indicating higher risk.

  • Probability of Breach: Indicates the likelihood of the portfolio's value falling below a predefined floor. Stocks show a 0.4% probability of breaching, reflecting higher risk.

  • Expected Shortfall: Represents the average shortfall when the floor is breached. For stocks, the expected shortfall is about 9.7%, indicating the extent of loss when the floor is breached.

To visualize the distribution of terminal wealths and assess the risk of breaching the floor visually:

python
# Plotting histograms of terminal wealth for different investment strategies
fig, ax = plt.subplots(1,2,figsize=(20,5))
sns.distplot( pok.terminal_wealth(bond_rets), bins=40, color="red", label="Bonds only", ax=ax[0]) 
sns.distplot( pok.terminal_wealth(stock_rets), bins=40, color="blue", label="Stocks only", ax=ax[1])
sns.distplot( pok.terminal_wealth(stock_bond_rets), bins=40, color="orange", label="70/30 Stocks/Bonds", ax=ax[1])
plt.suptitle("Terminal wealth histograms")
ax[0].axvline( x=pok.terminal_wealth(bond_rets).mean(), linestyle="-.", color="red", linewidth=1)
ax[1].axvline( x=pok.terminal_wealth(stock_rets).mean(), linestyle="-.", color="blue", linewidth=1)
ax[1].axvline( x=pok.terminal_wealth(stock_bond_rets).mean(), linestyle="-.", color="orange", linewidth=1)
ax[1].axvline( x=floor, linestyle="--", color="k")
ax[1].set_xlim(left=0.1) 
ax[0].legend(), ax[0].grid()
ax[1].legend(), ax[1].grid()
plt.show()

These histograms show the distribution of terminal wealths for each investment strategy. The plot on the right includes a vertical line representing the floor, allowing for visual identification of scenarios where the terminal wealth falls below this threshold. In both the PSP (stocks) and mixed PSP/LHP (stocks/bonds) cases, there is a possibility of breaching the floor, highlighting the importance of considering risk when designing an LDI strategy.

Glide Path Weight Allocation Strategy

Rather than adhering to a fixed weight allocation throughout an investment strategy's entire lifespan, a dynamic approach can be employed. This involves employing a fixed weight allocation that evolves over time, particularly adjusting the weights allocated to the PSP (Performance-Seeking Portfolio).

An example of such a strategy is starting with an 80/20 allocation in stocks/bonds and gradually shifting to a 20/80 allocation by maturity:

python
# Generate the glide path allocation between stocks and bonds from 80/20 to 20/80
print(pok.ldi_glidepath_allocator(stock_rets, bond_rets, start=0.8, end=0.2))

This code snippet demonstrates how to create a glide path that adjusts the asset allocation between stocks and bonds over time, starting predominantly with stocks and gradually shifting towards bonds.

With this evolving allocation, the mixed PSP/LHP portfolio's returns would look like this:

python
# Calculate the returns of the PSP/LHP strategy with a glide path allocation
stock_bond_rets_glide = pok.ldi_mixer(stock_rets, bond_rets, allocator=pok.ldi_glidepath_allocator, start=0.8, end=0.2)
print(stock_bond_rets_glide.head())

This output will show the initial returns for a portfolio where the allocation between stocks and bonds changes over time according to the specified glide path.

To evaluate the effectiveness of this approach, especially in terms of risk management, the terminal statistics can be compared across various strategies:

python
# Define a floor value for risk assessment
floor = 0.8

# Calculate and print the summary stats of terminal parameters for different investment strategies
ldi_stats = pd.concat([
    pok.summary_stats_terminal(bond_rets, floor=floor, periods_per_year=periods_per_year, name="Bonds only"),
    pok.summary_stats_terminal(stock_rets, floor=floor, periods_per_year=periods_per_year, name="Stocks only"),
    pok.summary_stats_terminal(stock_bond_rets, floor=floor, periods_per_year=periods_per_year, name="70/30 Stocks/Bonds"),
    pok.summary_stats_terminal(stock_bond_rets_glide, floor=floor, periods_per_year=periods_per_year, name="Glide 80/20 Stocks/Bonds"),
], axis=1)
print(ldi_stats)

This summary provides insights into the risk and return profiles of different strategies, including the glide path approach. It highlights important metrics such as the mean annualized return, mean wealth, and probability of breaching the predefined floor.

The glide path strategy, which becomes more conservative as it approaches maturity, may offer a better risk-adjusted approach. It potentially reduces the likelihood of breaching the floor in exchange for a slightly lower return and terminal wealth. It's important to note that the results are scenario-dependent and may vary with different simulations of rates and stock prices.

Integrating Floor Considerations with Performance-Seeking and Liability-Hedging Portfolios

To enhance investment strategy, this section introduces allocators that consider a predefined floor value, the minimum acceptable portfolio level. The mixed PSP/LHP strategy now employs zero-coupon bonds (ZCBs) as a proxy for the Liability-Hedging Portfolio (LHP) instead of coupon-bearing bonds.

python
# Parameters for simulating interest rates and zero-coupon bond prices
n_scenarios      = 1000
n_years          = 10
mean_rate        = 0.03
periods_per_year = 12 

# Simulating rates and zero-coupon bond prices
rates, zcb_price = pok.simulate_cir(n_years=n_years, n_scenarios=n_scenarios, a=0.05, b=mean_rate, 
                                    sigma=0.02, periods_per_year=periods_per_year)

# Computing zero-coupon bond returns and simulating stock prices
zcb_rets = zcb_price.pct_change().dropna()
stock_price, stock_rets = pok.simulate_gbm_from_prices(n_years=n_years, n_scenarios=n_scenarios, 
                                                       mu=0.07, sigma=0.15, periods_per_year=periods_per_year)

ZCB returns are directly calculated from percentage changes in price, as they don't distribute coupons during their life.

Fixed 70/30 Allocation Strategy:

Initial implementation of a fixed 70/30 stocks/bonds allocation:

python
w1 = 0.7  # Allocation weight for stocks
stock_zcb_rets = pok.ldi_mixer(stock_rets, zcb_rets, allocator=pok.ldi_fixed_allocator, w1=w1)

floor = 0.8  # Predefined floor value
# Calculating and comparing summary stats of terminal parameters for ZCBs, stocks, and 70/30 stocks/ZCBs
ldi_stats = pd.concat([
    pok.summary_stats_terminal(zcb_rets, floor=floor, periods_per_year=periods_per_year, name="ZCB only"),
    pok.summary_stats_terminal(stock_rets, floor=floor, periods_per_year=periods_per_year, name="Stocks only"),
    pok.summary_stats_terminal(stock_zcb_rets, floor=floor, periods_per_year=periods_per_year, name="70/30 Stocks/ZCB"),
], axis=1).round(4)
print(ldi_stats)

Investing solely in ZCBs results in lower returns but ensures no breach of the floor. A mixed PSP/LHP strategy enhances performance with a slight risk of breaching the floor.

Floor Allocator

Utilizing a floor allocator to modulate PSP allocation based on a cushion determined by the floor value and the ZCB price:

python
floor = 0.8  # Floor value

# Implementing the floor allocator with different multipliers (m = 1,3,5) to modulate PSP allocation
stock_zcb_floor_m1_rets = pok.ldi_mixer(stock_rets, zcb_rets, allocator=pok.ldi_floor_allocator, 
                                        zcb_price=zcb_price.loc[1:], floor=floor, m=1)
# Repeat for m=3 and m=5

# Comparing strategies with different multipliers
ldi_stats = pd.concat([
    ldi_stats,
    pok.summary_stats_terminal(stock_zcb_floor_m1_rets, floor=floor, periods_per_year=periods_per_year, name="Floor(0.8-1) Stocks/ZCB"),
    # Repeat for m=3 and m=5 strategies
], axis=1).round(4)
print(ldi_stats)

The strategy modulates PSP allocation, m (m=1,3,5), to maximize performance while considering the floor value. Strategies with higher multipliers, here m=5, show better performance but slightly increased risk of breaching the floor.

Drawdown Allocator

Implementing a drawdown allocator that dynamically adjusts the floor based on previous peaks:

python
# Implementing the drawdown allocator with a maximum drawdown constraint
maxdd = 0.2  # Maximum drawdown limit
stock_zcb_dd_02_rets = pok.ldi_mixer(stock_rets, zcb_rets, allocator=pok.ldi_drawdown_allocator, maxdd=maxdd)

# Comparing strategies with and without drawdown constraints
ldi_stats = pd.concat([
    ldi_stats,
    pok.summary_stats_terminal(stock_zcb_dd_02_rets, floor=1 - maxdd, periods_per_year=periods_per_year, name="DD(0.2) Stocks/ZCB"),
], axis=1).round(4)
print(ldi_stats)

This allocator responds to market downturns by reducing PSP exposure, mitigating the risk of large losses.

Considering Cash as an Alternative LHP

Exploring investment in cash as an alternative to ZCBs for the LHP:

python
ann_cashrate = 0.02  # Annual cash rate
monthly_cashrets = (1 + ann_cashrate)**(1/12) - 1  # Monthly cash returns
cash_rets = pd.DataFrame(data=monthly_cashrets, index=stock_rets.index, columns=stock_rets.columns)

# Implementing the drawdown allocator with cash as the LHP
stock_cash_dd_02_rets = pok.ldi_mixer(stock_rets, cash_rets, allocator=pok.ldi_drawdown_allocator, maxdd=0.2)

# Comparing strategies with cash as LHP
ldi_stats = pd.concat([
    ldi_stats,
    pok.summary_stats_terminal(stock_cash_dd_02_rets, floor=1 - 0.2, periods_per_year=periods_per_year, name="DD(0.2) Stocks/Cash"),
], axis=1).round(4)
print(ldi_stats)

This strategy explores the effectiveness of cash as a hedging asset in the LHP, potentially offering a simpler alternative to ZCBs.

Now Let's summurize all the above strategies defined in a diagram:

python
# Plotting histograms of the terminal wealths to understand the distribution and risk profiles of different strategies
# Compute terminal wealth for each investment strategy
tw_stock              = pok.terminal_wealth(stock_rets)
tw_stock_zcb          = pok.terminal_wealth(stock_zcb_rets)
tw_stock_zcb_floor_m1 = pok.terminal_wealth(stock_zcb_floor_m1_rets)
tw_stock_cash_dd_02   = pok.terminal_wealth(stock_cash_dd_02_rets)

# Create a figure and axis for the histogram plot
fig, ax = plt.subplots(1,1,figsize=(20,5))

# Plot histograms for terminal wealth of each strategy
sns.distplot(tw_stock, bins=40, color="red", label="Stocks only", ax=ax)
sns.distplot(tw_stock_zcb, bins=40, color="blue", label="70/30 Stocks/ZCB", ax=ax)
sns.distplot(tw_stock_zcb_floor_m1, bins=40, color="orange", label="Floor(0.8-1) Stocks/ZCB", ax=ax)
sns.distplot(tw_stock_cash_dd_02, bins=40, color="green", label="DD(0.2) Stocks/Cash", ax=ax)

# Add a title and labels
plt.suptitle("Terminal wealth histograms")

# Add vertical lines representing the mean terminal wealth for each strategy
ax.axvline(x=tw_stock.mean(), linestyle="-.", color="red", linewidth=1)
ax.axvline(x=tw_stock_zcb.mean(), linestyle="-.", color="blue", linewidth=1)
ax.axvline(x=tw_stock_zcb_floor_m1.mean(), linestyle="-.", color="orange", linewidth=1)
ax.axvline(x=tw_stock_cash_dd_02.mean(), linestyle="-.", color="green", linewidth=1)

# Add a vertical line representing the floor value
ax.axvline(x=floor, linestyle="--", color="k")

# Set the x-axis limit to focus on the relevant part of the distribution
ax.set_xlim(left=0.1)

# Add a legend and grid for better readability
ax.legend(), ax.grid()

# Display the plot
plt.show()

Real-World Application with Historical Data

Applying the PSP/LHP strategy to historical data, like total market index returns, for a more practical perspective:

python
tmi_rets = pok.get_total_market_index_returns()["1990":]  # Total market index returns

# Computing drawdown and peaks for total market index
dd_tmi = pok.drawdown(tmi_rets)

# Constructing the LHP with cash returns
ann_cashrate = 0.03
monthly_cashrets = (1 + ann_cashrate)**(1/12) - 1
cash_rets = pd.DataFrame(data=monthly_cashrets, index=tmi_rets.index, columns=[0])  # Single scenario

# PSP/LHP strategy with Total Market/Cash
tmi_cash_dd_02_rets = pok.ldi_mixer(pd.DataFrame(tmi_rets), cash_rets, allocator=pok.ldi_drawdown_allocator, maxdd=0.2)

# Computing drawdowns and peaks for the PSP/LHP strategy
dd_psp_lhp = pok.drawdown(tmi_cash_dd_02_rets[0])

# Visualizing wealth and peaks for total market and PSP/LHP strategies
fig, ax = plt.subplots(1,1,figsize=(10,6))
dd_tmi["Wealth"].plot(ax=ax, grid=True, color="red", label="Total market")
dd_tmi["Peaks"].plot(ax=ax, grid=True, ls=":", color="red", label="Total market peaks")
dd_psp_lhp["Wealth"].plot(ax=ax, grid=True, color="blue", label="PSP/LHP DD 0.2")
dd_psp_lhp["Peaks"].plot(ax=ax, grid=True, ls=":", color="blue", label="PSP/LHP DD 0.2 peaks")
plt.legend()
plt.show()

# Computing and displaying summary stats for investments
invests = pd.concat([
    tmi_rets.rename("Tot. Market (PSP)"), 
    cash_rets[0].rename("Cash (LHP)"), 
    tmi_cash_dd_02_rets[0].rename("PSP/LHP(DD0.2)")
], axis=1)
print(pok.summary_stats(invests, risk_free_rate=0, periods_per_year=12))

This approach applies the PSP/LHP strategy to real market data, offering insights into its practicality and effectiveness. The stats reveal how the strategy performs in terms of growth and maximum drawdown compared to investing solely in the market or cash.

Additional Insights: Modified Duration

Consider a bond with cash flows ci at time ti. The bond price B and its yield to maturity (YTM) with continuous compounding are linked by:

B=i=1NcieYTMti.

This equation implies that the bond price is essentially the present value of all its future payments.

This is consistent with the formula for the bond price previously introduced, specifically for coupon-bearing bonds with continuous compounding. In this context, each term cieYTMti represents the present value PVi of the i-th payment, with the final payment cN including both the coupon and principal.

Duration revisited

In this notation, the Macaulay Duration is expressed as:

MacD:=i=1NticieYTMtiB=i=1Nwitiwherewi:=cieYTMtiB,

aligning with the previously given definition of Macaulay Duration.

The focus here is on the Modified Duration, an important concept when considering small changes ΔYTM in the yield. The bond price change is approximately:

ΔB=dBdYTMΔYTM.

The derivative of B with respect to dYTM is:

dBdYTM=i=1NticieYTMti,

leading to:

ΔB=dBdYTMΔYTM=i=1NticieYTMtiΔYTM.

This illustrates the inverse relationship between bond price B and YTM. As B increases, YTM decreases, and vice versa.

Using the Macaulay Duration, this relationship can be rewritten as:

ΔB=MacDBΔYTM,i.e.ΔBB=MacDΔYTM.

This equation is an approximate relationship between percentage changes in a bond price and changes in its YTM.

This equation approximates the relationship between percentage changes in a bond price and changes in its YTM.

Consider a 3-year, 10% coupon bond with a face value of $100, a YTM=12% per annum with continuous compounding, and semi-annual coupon payments of $5.

python
principal        = 100
maturity         = 3
ytm              = 0.12
coupon_rate      = 0.10
coupons_per_year = 2

# Calculate the cash flows for each period and normalize the dates.
cf = pok.bond_cash_flows(principal=principal, maturity=maturity, coupon_rate=coupon_rate, coupons_per_year=coupons_per_year)
cf.index = cf.index / coupons_per_year
print(cf)

Manually compute the bond price and Macaulay Duration for continuous compounding:

python
# Calculate present values of cash flows.
pvs = [ (cf.iloc[t] * np.exp(-ytm * cf.index[t]))[0] for t in range(len(cf.index)) ]
# Sum of present values is the bond price.
B   = sum(pvs) 
# Weights are present values over bond price.
ww  = [ pv/B for pv in pvs]
# Calculate time-weighted weights.
tw  = [ cf.index[t] * ww[t] for t in range(len(cf.index)) ]

# Create a DataFrame for analysis.
df  = pd.DataFrame([pvs, ww, tw], index=["PVs","Weights","t x weight"], columns=cf.index).T
df.insert(loc=0, column="Cash Flows", value=cf)
print(df)

Calculate bond price and Macaulay Duration:

python
# Bond price (already computed above).
B = df["PVs"].sum()
print(B) 

# Macaulay Duration.
macD = df["t x weight"].sum()
print(macD)

And calculate B×MacD:

python
print(B * macD)

📢 Note

It's important to remember that 1 basis point (bp) corresponds to 0.01%.

With an increase in YTM by 10 basis points (i.e., 0.1%), ΔYTM=+0.001. The predicted price decrease is:

python
# \DeltaB = - B * macD * DeltaYTM
delta_B = - B * macD * 0.001
print(delta_B)

The new expected bond price is:

python
print(B + delta_B)

Valuing the bond using its YTM in the usual way, when the bond yield increases by 10 basis points to 12.1%:

python
new_ytm = 0.121

# Calculate new present values of cash flows.
pvs = [ (cf.iloc[t] * np.exp(-new_ytm * cf.index[t]))[0] for t in range(len(cf.index)) ]

# Sum of new present values is the bond price.
B = sum(pvs) 
print(B)

This confirms the accuracy of the duration relationship up to three decimal points.

After earning certification from EDHEC Business School, I translated complex financial theories into practical Python modules, openly shared under the MIT License.