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:
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 compound return over that period is defined as:
This equation represents the compounded growth from one period to the next. When returns are constant over each interval, say at a rate
This simplification assumes a consistent return rate across each time period, compounding the growth.
Assuming
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, future value,
Conversely, present value,
To illustrate, consider an investment of
In scenarios where returns
- To determine the
future value,, from the present value,, the formula is:
This equation compounds the present value over
- Conversely, to calculate the
present value,, from a known future value,, the formula becomes:
This formula 'discounts' the future value back to the present, considering the interest rate over
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
is known as the discount factor. It represents the present value of
Scenario Analysis: Consider being presented with two options: receiving
The logical choice is to take the money now. Here's why: if the present value,
Opting to receive
This indicates that accepting
Another Scenario: What if the choice is between
This decision is more complex. If the hypothetical investment still yields a
In this case, receiving
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,
Consider a scenario where Company A has an obligation to pay Company B a sum of
The present value of
Similarly, for
and for
Summing these values gives the present value of the total obligation:
In a general sense, if future cash flows are denoted as
where discount factor.
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:
L = pd.DataFrame([[8000, 11000], [11000, 2000], [6000, 15000]], index=[1, 2, 3])
print(L)Assuming the first liability has an annual rate of
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
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,
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,
# 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
The following function show_funding_ratio is crafted to visually represent the funding ratio against various rates and asset values:
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
Take, for instance, the act of borrowing an amount
Now, consider the scenario where
resulting in a total repayment of
If repayments were instead monthly, the total would be:
leading to a total repayment of
This illustrates that with more frequent compounding, the total compound return increases, necessitating a higher repayment amount.
Generally, for a nominal interest rate instantaneous interest rate) and number of periods (or payment intervals for investments, loans, etc.), the total return is:
Here, annualized return or effective annual interest rate, derived from discrete compounding.
Additionally, the following relationship can be derived from the above formula:
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.
# 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
# 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
resulting in the approximation for continuously compounded returns:
Continuous compounding is a theoretical model, but it does have practical applications in certain scenarios.
# 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.
# 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:
where, the (long-term) mean interest rate, and
The fluctuations in interest rates are therefore contingent on the long-term mean rate (
Especially when the rate
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
where
Future discussions might delve deeper into this topic.
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:
# 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 = 12Here, 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
Simulating Interest Rates and Zero-Coupon Bond Prices
Interest rates for the next 10 years are simulated starting from the mean rate:
# 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:
# 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:
# 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
# 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:
# 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:
# 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:
# 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:
# 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:
# 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:
where
Cash Flow from a Bond
Consider a bond's cash flow:
# 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
Bond Price Calculation
The bond's price is calculated as:
# 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:
# 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:
# 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:
# 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:
# 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
In contrast, bonds regularly disburse coupons. To accurately calculate total returns, consider these payments:
# 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
In cases where the bond price and interest rate are fixed, the total return is as follows:
# 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:
The Macaulay duration is then calculated as:
where
Calculating Macaulay Duration for a Bond
# 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
# 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
# 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:
# Initial asset value
asset_value = 130000This 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.
# 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:
# 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_rateFirst, examine the durations of these bonds:
# 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:
where
# 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
Before proceeding, determine the bond prices:
# 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:
# 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:
# 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:
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:
# 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:
def duration_match_weight(d1, d2, d_liab):
w1 = (d_liab - d2) / (d1 - d2)
w2 = 1 - w1
return w1, w2This function calculates the proportions to invest in two bonds to match a target duration.
Consider the following flat interest rate and liabilities:
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:
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 = 4Calculate the cash flows for these bonds and normalize the dates:
# 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_b2This normalization ensures that the cash flows are comparable despite the different coupon frequencies.
Next, calculate the durations of these bonds:
# 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:
# 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:
# 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:
# 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:
# 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:
# 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 = 2Next, generate interest rates and zero-coupon bond prices for a number of scenarios:
# 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):
# 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:
# 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 (
# 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:
# 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 diversified portfolio of stocks (PSP) and portfolio of bonds (LHP):
# 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:
# 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:
# 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):
. This is the average annualized return of the portfolio. It indicates that, on average, the portfolio yields a return per year. Annualized Volatility (Ann. vol):
. This represents the annualized volatility of the portfolio, a measure of the variation in returns. A annual volatility indicates moderate fluctuations in the portfolio's value. Sharpe Ratio:
. This measures the excess return (return over the risk-free rate) per unit of risk (volatility). A ratio of suggests the portfolio offers a decent return for the taken risk, with higher values generally indicating better risk-adjusted returns. Skewness:
. 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:
. 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):
. This represents the average loss over a specified period, assuming that loss is beyond the Value at Risk (VaR) threshold. A CVaR indicates that, in the worst of cases, the average loss would be . Cornish-Fisher Value at Risk (C-F Var):
. This is a measure that adjusts the VaR to account for skewness and kurtosis. A C-F Var implies that, considering the actual distribution of returns, the portfolio's VaR is . Maximum Drawdown:
. This is the largest peak-to-trough decline in the portfolio's value. Here, the portfolio has experienced a maximum loss of from a peak during its lifetime.
To further analyze the strategy's effectiveness, especially in terms of risk management, the terminal statistics are considered:
# 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' (
), 'Stocks only' ( ), and '70/30 Stocks/Bonds' ( ), 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
probability of breaching, reflecting higher risk. Expected Shortfall: Represents the average shortfall when the floor is breached. For stocks, the expected shortfall is about
, 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:
# 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:
# 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:
# 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:
# 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.
# 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:
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:
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,
Drawdown Allocator
Implementing a drawdown allocator that dynamically adjusts the floor based on previous peaks:
# 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:
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:
# 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:
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
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
Duration revisited
In this notation, the Macaulay Duration is expressed as:
aligning with the previously given definition of Macaulay Duration.
The focus here is on the Modified Duration, an important concept when considering small changes
The derivative of
leading to:
This illustrates the inverse relationship between bond price
Using the Macaulay Duration, this relationship can be rewritten as:
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,
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:
# 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:
# Bond price (already computed above).
B = df["PVs"].sum()
print(B)
# Macaulay Duration.
macD = df["t x weight"].sum()
print(macD)And calculate
print(B * macD)📢 Note
It's important to remember that
basis point (bp) corresponds to .
With an increase in YTM by
# \DeltaB = - B * macD * DeltaYTM
delta_B = - B * macD * 0.001
print(delta_B)The new expected bond price is:
print(B + delta_B)Valuing the bond using its YTM in the usual way, when the bond yield increases by 10 basis points to
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.
