Fundamentals of Asset-Liability Management
The preceding chapter asked how to protect portfolio wealth. Asset–liability management (ALM) asks whether that wealth can meet specified obligations when they fall due. A pension payment, tuition bill, or debt repayment gives the investment objective an amount, date, and currency; a high portfolio return alone does not establish success.
The chapter develops that question in stages:
| Investment question | Main idea |
|---|---|
| What are future obligations worth today? | Discount factors and present value. |
| Are today's assets sufficient on that valuation basis? | Surplus and funding ratio. |
| How do interest-rate changes affect assets and liabilities? | Rate conventions, bond prices, and duration. |
| How can the portfolio support the obligations? | Cash-flow matching, liability hedging, and performance-seeking allocations. |
We begin with known nominal dollar cash flows, a flat effective annual discount rate, and no taxes or transaction costs. Later sections relax the constant-rate assumption and examine hedging. Keep the liability schedule separate from the investment strategy used to finance it.
Run these imports once, then the opening examples in order. After changing an input, rerun its calculation and the dependent examples so that each comparison uses the same assumptions.
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok Understanding the Time Value of Money
Compounding moves a known initial amount forward through returns; discounting values a future payment at an earlier date. From the returns chapter, wealth with no intervening cash flows evolves as:
If the applicable effective annual rate is a constant
Exploring Present and Future Value Concepts
At that rate, a present amount
For example, 100 today grows to 105 after one year at 5%. These are nominal currency amounts. Inflation need not be zero for the nominal compounding identity to hold; an analysis in purchasing-power units would instead pair real cash flows with a consistent real discount rate.
Worked example. Invest 1,000 for three years at 3% effective annually:
initial_amount = 1000.0
annual_rate = 0.03
years = 3
future_value = initial_amount * (1 + annual_rate) ** years
print(f"Future value after {years} years: {future_value:,.2f}") Understanding the Discount Rate
The discount rate is
For
discount_factor = pok.discount(years, annual_rate).iloc[0, 0]
payment_choices = pd.Series({
"1,000 today": 1000.0,
"1,000 in three years": 1000 * discount_factor,
"1,100 in three years": 1100 * discount_factor,
}, name="Present value")
print(f"Three-year discount factor: {discount_factor:.6f}")
print(payment_choices.to_string(float_format=lambda value: f"{value:,.2f}")) With positive rates, postponing the same certain payment reduces its present value. At a zero rate the factor is one; with a negative rate above −100%, it exceeds one. The toolkit takes decimal effective rates and numeric times: annual rates require times in years, not a count of months or calendar-date labels.
Try it: change annual_rate to 0.0, then -0.01, in the preceding future-value cell and rerun both examples. Observe that the direction of the time-value comparison depends on the rate.
A market valuation normally uses discount factors appropriate to the payment's date, currency, and risk. A flat rate is our simplifying assumption. A higher expected return on a risky portfolio is not, by itself, a justification for discounting a promised payment at that higher rate.
Grasping the Cumulative Present Value of Future Cash Flows
An obligation is a schedule of payments, not merely their sum. Let
For this liability analysis, outflows are recorded as positive obligations. A general net-present-value calculation can instead use signed inflows and outflows, but the sign convention must remain consistent.
Worked schedule. Plan A owes 8,000 at the end of year 1, 11,000 at the end of year 2, and 6,000 at the end of year 3. At a flat 5% effective annual rate:
The nominal payments sum to 25,000, but they are not all due in year 3. Discounting that total as a single three-year payment would assign the wrong timing to the first two obligations.
Enter Plan A and a second schedule, Plan B, using payment years as the row index. Apply the same 5% valuation rate to both so that the schedule comparison has a common basis:
liabilities = pd.DataFrame({
"Plan A": [8000.0, 11000.0, 6000.0],
"Plan B": [11000.0, 2000.0, 15000.0],
}, index=pd.Index([1, 2, 3], name="Payment year"))
liability_rate = 0.05
print(liabilities.to_string(float_format=lambda value: f"{value:,.0f}")) The discount-factor calculation supplies one factor for each payment date. Multiplying each row by its factor shows the contribution of each payment to present value:
liability_factors = pok.discount(liabilities.index, liability_rate).iloc[:, 0]
discounted_cashflows = liabilities.mul(liability_factors, axis=0)
liability_pv = pok.present_value(liabilities, liability_rate)
print("Discounted payments:")
print(discounted_cashflows.to_string(float_format=lambda value: f"{value:,.2f}"))
print("\nTotal present value:")
print(liability_pv.to_string(float_format=lambda value: f"{value:,.2f}")) Plan A's present value is 22,779.40; Plan B's is 25,247.81. The toolkit applies a scalar rate to every named column, rather than pairing unrelated column labels. It can also value one schedule under multiple flat-rate scenarios, which we will use for sensitivity analysis.
Try it: change liability_rate to 0.0 and rerun the schedule and valuation cells. The values become the undiscounted totals, 25,000 and 28,000. Then restore 5% for the funding comparison.
Understanding the Funding Ratio
Let
: a deficit on the stated valuation basis. : assets equal the liability present value. : a valuation surplus.
These are valuation statements, not guarantees about future asset performance. If matching default-free instruments can be purchased at the assumed discount prices, sufficient capital can finance a cash-flow-matching portfolio under the model. An existing risky portfolio can still lose value or lack cash when a payment is due.
Suppose the two plans hold current assets of 20,000 and 27,000. Keep the asset labels aligned with their liability schedules:
current_assets = pd.Series({"Plan A": 20000.0, "Plan B": 27000.0})
funding_ratios = pok.funding_ratio(current_assets, liabilities, liability_rate)
funding_summary = pd.DataFrame({
"Assets today": current_assets,
"Liability PV": liability_pv,
"Surplus / deficit": current_assets - liability_pv,
"Funding ratio": funding_ratios,
})
print(funding_summary.to_string(float_format=lambda value: f"{value:,.3f}")) Plan A has funding ratio approximately 0.878 and a 2,779.40 deficit. Plan B has ratio approximately 1.069 and a 1,752.19 surplus. The comparison uses the present values of the dated payments, not the nominal totals of 25,000 and 28,000 treated as single maturity payments.
Funding-Ratio Sensitivity
A funding ratio can change because assets change or because the liability valuation changes. Isolate those effects using Plan A, current assets of 24,000, and a selected effective annual discount rate of 2%:
liability_case = liabilities[["Plan A"]]
selected_assets = 24000.0
selected_rate = 0.02
selected_pv = pok.present_value(liability_case, selected_rate).iloc[0]
selected_funding_ratio = pok.funding_ratio(selected_assets, liability_case, selected_rate).iloc[0]
print(f"Liability PV: {selected_pv:,.2f}")
print(f"Funding ratio: {selected_funding_ratio:.4f}")
print(f"Surplus / deficit: {selected_assets - selected_pv:,.2f}") The present value is 24,069.93, so this case is slightly underfunded: ratio 0.9971, deficit 69.93. For positive future payments, increasing the discount rate lowers their present value. Holding asset value fixed therefore raises the reported funding ratio:
The first chart varies the flat annual rate while holding assets at 24,000. The second varies assets while holding the rate at 2%. The dashed Target line is
rate_grid = np.linspace(-0.02, 0.10, 121)
asset_grid = np.linspace(0, 40000, 81)
funding_by_rate = pok.funding_ratio(selected_assets, liability_case, rate_grid)
funding_by_assets = pok.funding_ratio(asset_grid, liability_case, selected_rate)
plot_data = {
"rateSensitivity": {
"type": "line", "title": "Plan A: Discount-Rate Sensitivity",
"xAxis": {"type": "value", "name": "Discount rate (%)"},
"yAxis": {"type": "value", "name": "Funding ratio", "scale": True},
"series": {
"Funding ratio": np.column_stack([rate_grid * 100, funding_by_rate]).tolist(),
"Target": np.column_stack([rate_grid * 100, np.ones(len(rate_grid))]).tolist(),
},
},
"assetSensitivity": {
"type": "line", "title": "Plan A: Current-Asset Sensitivity",
"xAxis": {"type": "value", "name": "Assets today ($)", "min": 0},
"yAxis": {"type": "value", "name": "Funding ratio", "min": 0},
"series": {
"Funding ratio": np.column_stack([asset_grid, funding_by_assets]).tolist(),
"Target": np.column_stack([asset_grid, np.ones(len(asset_grid))]).tolist(),
},
},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) This is valuation sensitivity with other inputs held fixed. Raising an assumption in a spreadsheet does not create assets or reduce the promised payments. In a market-rate shock, the value of a bond portfolio may fall alongside the liability present value; ALM must examine both sides together.
Try it: change selected_assets to 25000 in the case cell and rerun it and the chart. The rate curve passes through a funding ratio of one at a 0% rate, where Plan A's present value equals its nominal total. Then restore assets to 24,000 and change selected_rate to 0.05. Rerun the case and chart: the asset-sensitivity curve changes, while the rate sweep is unchanged because it still evaluates the same range of rates against the same asset amount.
The next section distinguishes effective annual, nominal, and continuously compounded rates. Their conventions must be consistent before we can model changing rates or compare hedging strategies.
Nominal Rate and Effective Annual Interest Rate
The present-value examples used effective annual rates. A market quote may instead specify a nominal annual rate with a compounding frequency, or a continuously compounded rate. Before discounting a liability, translate the quote into the convention used by the valuation formula.
Annual Quotes and Periodic Rates
Let
The nominal quote is an annual rate label, not the rate earned in each period. Its frequency is part of the quote. The periodic gross return must be positive:
Hand check. A nominal quote of 10% compounded semiannually means 5% per half-year. An initial 1,000 becomes
Compounding is not the same as making repayments. These balances assume that interest remains invested or is reinvested at the stated periodic rate, with no withdrawals or fees. If a loan amortizes or interest is paid out, value those dated cash flows separately instead of treating the principal as unchanged.
nominal_quote = 0.10
quote_frequency = 12
deposit_principal = 1000.0
periodic_rate = nominal_quote / quote_frequency
effective_quote = pok.compounding_rate(nominal_quote, periods_per_year=quote_frequency)
end_year_balance = deposit_principal * (1 + effective_quote)
print(f"Periodic rate: {periodic_rate:.4%}")
print(f"Effective annual rate: {effective_quote:.4%}")
print(f"Balance after one year: {end_year_balance:,.2f}") The result is 1,104.71 after one year. The toolkit's compounding_rate() converts a rate quote; it is not estimating a return from historical observations.
Continuous Compounding
Keeping a numerical nominal quote
A constant continuously compounded annual rate
At a continuously compounded rate of 10%, the effective annual rate is 10.5171%, and 1,000 becomes 1,105.17. That differs from a 10% nominal quote compounded monthly. Equal numerical quotes under different conventions do not necessarily describe equal economic returns.
Run the preceding monthly example first. Compare familiar frequencies in a table, then plot integer frequencies from 1 through 52 against the continuous limit:
compounding_frequencies = np.array([1, 2, 4, 12, 52, 365])
effective_by_frequency = np.array([
pok.compounding_rate(nominal_quote, periods_per_year=frequency)
for frequency in compounding_frequencies
])
frequency_table = pd.DataFrame({
"Periodic rate (%)": nominal_quote / compounding_frequencies * 100,
"Effective annual rate (%)": effective_by_frequency * 100,
"End-year balance": deposit_principal * (1 + effective_by_frequency),
}, index=pd.Index(compounding_frequencies, name="Compounds per year"))
continuous_limit = pok.compounding_rate(nominal_quote)
print(frequency_table.to_string(float_format=lambda value: f"{value:.4f}"))
print(f"Continuous limit: {continuous_limit:.4%}")
frequency_grid = np.arange(1, 53)
curve_rates = np.array([pok.compounding_rate(nominal_quote, periods_per_year=frequency)
for frequency in frequency_grid])
plot_data = {
"compoundingFrequency": {
"type": "line", "title": f"Fixed {nominal_quote:.0%} Quote: Changing Compounding Frequency",
"xAxis": {"type": "value", "name": "Compounds per year", "min": 1, "max": int(frequency_grid[-1])},
"yAxis": {"type": "value", "name": "Effective annual rate (%)", "scale": True},
"series": {
"Discrete": np.column_stack([frequency_grid, curve_rates * 100]).tolist(),
"Continuous limit": np.column_stack([frequency_grid, np.full(len(frequency_grid), continuous_limit * 100)]).tolist(),
},
},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) For the positive 10% quote, effective annual growth increases with frequency but the increments diminish. The comparison holds the quoted number fixed; it does not hold the effective annual return fixed.
Equivalent Quotes for the Same Annual Growth
To keep the economic outcome unchanged, start from an effective annual rate
For 10% effective annual growth, the monthly periodic rate is approximately 0.7974%, the equivalent nominal annual quote compounded monthly is 9.5690%, and the continuously compounded quote is 9.5310%. All describe the same growth from 1,000 to 1,100 over one year.
effective_annual_quote = 0.10
equivalent_nominal_quotes = np.array([
pok.compounding_rate_inv(effective_annual_quote, periods_per_year=frequency)
for frequency in compounding_frequencies
])
equivalent_monthly_quote = pok.compounding_rate_inv(effective_annual_quote, periods_per_year=12)
equivalent_continuous_quote = pok.compounding_rate_inv(effective_annual_quote)
quote_comparison = pd.DataFrame({
"Nominal annual quote (%)": equivalent_nominal_quotes * 100,
"Periodic rate (%)": equivalent_nominal_quotes / compounding_frequencies * 100,
"Effective annual check (%)": [
pok.compounding_rate(quote, periods_per_year=frequency) * 100
for quote, frequency in zip(equivalent_nominal_quotes, compounding_frequencies)
],
}, index=pd.Index(compounding_frequencies, name="Compounds per year"))
print(quote_comparison.to_string(float_format=lambda value: f"{value:.4f}"))
print(f"Equivalent continuous quote: {equivalent_continuous_quote:.4%}") The final column stays at 10%. compounding_rate_inv() returns the nominal annual quote when a frequency is supplied and the continuously compounded quote when it is omitted. The helpers use log1p and expm1 internally to retain precision near zero.
Keeping the Liability Valuation Consistent
For equivalent constant-rate quotes, the discount factors agree:
Revalue the original Plan A schedule at the selected 10% effective annual rate, expressing that same rate in three ways. Run the liability-schedule and equivalent-quote cells first:
valuation_times = liabilities.index.to_numpy(dtype=float)
plan_a_payments = liabilities["Plan A"].to_numpy()
consistent_present_values = pd.Series({
"Effective annual": pok.present_value(liabilities[["Plan A"]], effective_annual_quote).iloc[0],
"Equivalent nominal monthly": np.sum(plan_a_payments / (1 + equivalent_monthly_quote / 12) ** (12 * valuation_times)),
"Equivalent continuous": np.sum(plan_a_payments * np.exp(-equivalent_continuous_quote * valuation_times)),
}, name="Plan A present value")
print(consistent_present_values.to_string(float_format=lambda value: f"{value:,.2f}")) All three values are approximately 20,871.53. Changing the representation of a rate has not changed the liability's value. present_value() expects an effective rate per unit of its numeric time index, so do not pass a nominal or continuous quote directly when those times are years.
Try it: change effective_annual_quote to 0.03 and rerun the equivalent-quote and valuation cells. The monthly periodic rate becomes approximately 0.2466%, the same cash convention used in the CPPI chapter. Then try -0.01: the equivalent quotes are negative and Plan A's present value exceeds its 25,000 nominal total, while all three valuation methods still agree.
Connecting Rate Conventions to a Short-Rate Model
A short rate describes the instantaneous cash-accrual rate at a date; a longer-maturity yield summarizes pricing over a longer horizon. These maturity concepts are different from the choice of nominal, effective, or continuous quotation. A nominal annual quote compounded monthly is not automatically an instantaneous short rate.
If the continuously compounded short rate varies through time as
Only when that rate remains constant does this reduce to
CIR Model: Simulating Interest Rate Fluctuations
So far, discount rates have been fixed assumptions. The Cox–Ingersoll–Ross (CIR) model lets the short rate change through time while remaining nonnegative. It is a one-factor model: the current short rate is the state variable driving this model's bond prices. It does not represent every possible yield-curve movement or negative-rate environment.
For valuation, distinguish a pricing distribution from a real-world forecast, as developed through replication in Deriving and Using Black–Scholes. Here the cash rate itself is random, so valuation uses the integrated rate path rather than the constant-rate factor
For pricing, write the dynamics under a risk-neutral measure,
Here
| Parameter | Financial role |
|---|---|
| Mean-reversion speed, in inverse years. | |
| Long-run mean level of the continuous short rate. | |
| Diffusion coefficient; instantaneous rate volatility is | |
| Initial continuous short rate. |
The conditional mean is:
A larger
For
Rate Conventions and Simulation
The toolkit retains an effective-annual quotation interface: its b and r0 inputs are converted to b is the equivalent quote for
For example, an input r0=0.05 means an initial continuous rate of approximately 4.8790%, not 5%. These conventions are shown in the output so that the model coefficients and chart units can be checked.
The simulation uses the known exact transition distribution at the chosen dates. For
Then
These are pricing-measure simulations, not calibrated forecasts of actual future rates. Investment-outcome forecasts may need different real-world dynamics, with pricing parameters used separately to value the assets on those paths.
Applying the CIR Model to Price Zero-Coupon Bonds
A default-free zero-coupon bond pays one unit at maturity
For
The rates and bond values must refer to the same date: price the remaining maturity using the newly observed rate, not the previous period's rate. At maturity,
At a fixed valuation date and maturity, a higher current continuous rate reduces price because
Hand check. With
Run the chapter imports first, then verify the annual checkpoints. The time-zero row is an initial observation, not an earned return:
flat_rates, flat_bond_prices = pok.simulate_cir(
n_years=3, n_scenarios=1, a=0.5, b=0.03, sigma=0.0,
periods_per_year=12, r0=0.03, seed=42,
)
flat_check = pd.DataFrame({
"Elapsed years": flat_rates.index.to_numpy() / 12,
"Effective quote (%)": flat_rates[0] * 100,
"Continuous rate (%)": np.log1p(flat_rates[0]) * 100,
"Unit bond price": flat_bond_prices[0],
})
print(flat_check.iloc[::12].to_string(index=False, float_format=lambda value: f"{value:.6f}")) Interactive Rate and Bond-Price Experiment
The baseline has five paths over five years, monthly observations, b=0.03, r0=0.05, and
pok.show_cir_echart(
n_years=5, n_scenarios=5, a=0.5, b=0.03, sigma=0.10,
periods_per_year=12, r0=0.05, seed=42,
) The initial unit bond price is approximately 0.834177 for these inputs. The rate panel includes the model's conditional mean. Both panels include time zero, and every bond path reaches one at maturity.
Now use the controls to vary the assumptions. The seed repeats a given parameter set; changing CIR parameters can change the transition sampler's draws, so it does not guarantee identical underlying Brownian paths across different parameter choices.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_cir_echart,
n_years=widgets.IntSlider(value=5, min=1, max=10, continuous_update=False),
n_scenarios=widgets.IntSlider(value=5, min=1, max=30, continuous_update=False),
a=widgets.FloatSlider(value=0.5, min=0.05, max=1.0, step=0.05, continuous_update=False),
b=widgets.FloatSlider(value=0.03, min=0.0, max=0.10, step=0.005, readout_format=".3f", continuous_update=False),
sigma=widgets.FloatSlider(value=0.10, min=0.0, max=0.30, step=0.01, continuous_update=False),
periods_per_year=widgets.Dropdown(options=[12, 52, 252], value=12),
r0=widgets.FloatSlider(value=0.05, min=0.0, max=0.15, step=0.005, readout_format=".3f", continuous_update=False),
seed=widgets.IntSlider(value=42, min=0, max=1000, continuous_update=False),
)
widget_instance Try it: set sigma to zero and r0 equal to b; the rates become constant and the bond follows deterministic discounting. Restore volatility and raise r0: inspect the lower initial bond price at the same maturity. Then set a=0.05, b=0.005, and sigma=0.30; the Feller condition fails, but sampled rates remain nonnegative. The chart cannot establish whether zero was reached between observation dates.
Changing n_years also changes the bond maturity. The annualized effective quotes returned by the simulator are not realized cash-account returns; constructing a cash strategy requires accruing interest over the intervals. That distinction is central to the liability-hedging comparison next.
Liability Hedging
Cash can have a stable short-term value and still be risky relative to a payment due many years from now. Its future reinvestment rates are unknown. A bond with the same payment date as the liability can instead lock in a terminal amount, even though its market price fluctuates before maturity.
Consider 0.75 million dollars available today and a fixed 1 million dollar payment in ten years. Compare investing all the starting capital in:
- A default-free zero-coupon bond maturing on the liability date.
- A cash proxy that repeatedly rolls default-free short-term bonds.
Both strategies use the same starting capital and the same simulated rate paths. Amounts are nominal dollars, expressed in millions; there are no interim liability payments, contributions, withdrawals, taxes, or transaction costs. The assets and liability use the same default-free discount curve. These assumptions make the matching mechanism visible.
Value the Liability on the Same Curve
Let
The amount owed remains fixed; its present value changes with rates and remaining time. The CIR input b=0.03 represents a short-rate mean level through the quotation convention just established. It is neither interest added to the liability nor an assumed ten-year yield.
Run the chapter imports, then these cells in order. Simulate 500 monthly paths once and retain them for both strategies:
hedge_assets, hedge_liability = 0.75, 1.0 # Millions of dollars
hedge_years, hedge_scenarios, hedge_frequency = 10, 500, 12
hedge_model = {"a": 0.05, "b": 0.03, "sigma": 0.08}
hedge_rates, hedge_prices = pok.simulate_cir(
n_years=hedge_years, n_scenarios=hedge_scenarios,
periods_per_year=hedge_frequency, r0=0.03, seed=42, **hedge_model,
)
liability_values = hedge_liability * hedge_prices
initial_bond_price = float(hedge_prices.iloc[0, 0])
full_matching_cost = hedge_liability * initial_bond_price
print(f"Initial unit bond price: {initial_bond_price:.6f}")
print(f"Cost to match the full liability: {full_matching_cost:.6f} million")
print(f"Starting funding ratio: {hedge_assets / full_matching_cost:.6f}") The initial price is approximately 0.759153, so full matching costs 759,153.50 dollars. With only 750,000 dollars, the plan starts about 9,153.50 dollars short of that cost. Using
Buy the Maturity-Matched Bond
With initial capital
The funding ratio is constant across dates and scenarios. That is the hedge: asset and liability values move in the same proportion. It does not imply the ratio is at least one.
bond_face = hedge_assets / initial_bond_price # Face amount, in millions
bond_wealth = bond_face * hedge_prices
initial_funding_ratio = hedge_assets / full_matching_cost
print(f"Initial bond investment: {bond_wealth.iloc[0, 0]:.6f} million")
print(f"Bond payment at maturity: {bond_face:.6f} million")
print(f"Terminal surplus / deficit: {bond_face - hedge_liability:.6f} million") Here the bond pays approximately 987,942.49 dollars, leaving a 12,057.51 dollar terminal deficit in every scenario. Full matching requires
Roll Short-Term Bonds as a Cash Proxy
An instantaneous short-rate quote is not a promised monthly deposit return. Instead, model a cash-like investment explicitly: at each observation date
Each holding-period return is locked in at its beginning, but future rollover prices are unknown. Price those short bonds with the same CIR parameters as the long bond. This is a discrete cash proxy, approaching the instantaneous cash account as the rollover interval shrinks; it does not require pretending that rates remain fixed between simulated dates.
short_bond_prices = pok.cir_zero_coupon_price(
1 / hedge_frequency, hedge_rates.iloc[:-1].to_numpy(), **hedge_model,
)
cash_growth = np.vstack([
np.ones(hedge_scenarios), # Time zero: no interest has yet been earned
np.cumprod(1 / short_bond_prices, axis=0),
])
cash_wealth = pd.DataFrame(
hedge_assets * cash_growth, index=hedge_rates.index, columns=hedge_rates.columns,
)
print(cash_wealth.iloc[[0, 1, -1], :3].to_string(float_format=lambda value: f"{value:.6f}")) The 121 observed dates contain 120 earned monthly returns. The final short-rate observation is not used to accrue another month beyond the liability date. pok.cir_cash_wealth() performs this same rollover calculation for the interactive experiment below.
Hand check. Set sigma=0 and r0=b=0.03. A one-month price is
Compare Wealth and Funding Ratios
First inspect one shared scenario. The liability curve is its present value, rather than a fixed horizontal line at its future face amount:
hedge_times = hedge_rates.index.to_numpy() / hedge_frequency
plot_data = {"hedgingWealth": {
"type": "line", "title": "Scenario 1: Asset Values and the Liability",
"xAxis": {"type": "value", "name": "Elapsed years", "min": 0, "max": hedge_years},
"yAxis": {"type": "value", "name": "Value (million dollars)", "scale": True},
"series": {
"Cash": np.column_stack([hedge_times, cash_wealth[0]]).tolist(),
"Matching bond": np.column_stack([hedge_times, bond_wealth[0]]).tolist(),
"Liability": np.column_stack([hedge_times, liability_values[0]]).tolist(),
},
}}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) Cash wealth grows at the rollover dates in this nonnegative-rate model. Nevertheless, smoother wealth is not the same as a stable ability to meet the liability. Divide each strategy's value by the same current liability value:
cash_funding = cash_wealth / liability_values
bond_funding = bond_wealth / liability_values
funding_paths = {
f"Cash {column + 1}": np.column_stack([hedge_times, cash_funding[column]]).tolist()
for column in cash_funding.columns[:5]
}
funding_paths["Matching bond"] = [[0, initial_funding_ratio], [hedge_years, initial_funding_ratio]]
funding_paths["Target"] = [[0, 1], [hedge_years, 1]]
plot_data = {"hedgingFunding": {
"type": "line", "title": "Funding Ratios: First Five Shared Rate Scenarios",
"xAxis": {"type": "value", "name": "Elapsed years", "min": 0, "max": hedge_years},
"yAxis": {"type": "value", "name": "Funding ratio", "scale": True},
"series": funding_paths,
}}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) All scenarios start at approximately 0.987942. The cash ratios then move as reinvestment returns accumulate and the liability is repriced. The bond ratio stays fixed below the target. Its nominal wealth fluctuates, but its liability-relative risk has been removed under these matching assumptions.
Terminal Outcomes and the Interactive Experiment
At maturity,
terminal_funding = pd.DataFrame({
"Cash": cash_funding.iloc[-1], "Matching bond": bond_funding.iloc[-1],
})
terminal_summary = terminal_funding.quantile([0.05, 0.50, 0.95]).T
terminal_summary.columns = ["5th percentile", "Median", "95th percentile"]
terminal_summary["Shortfall share (%)"] = (terminal_funding < 1 - 1e-10).mean() * 100
print(terminal_summary.to_string(float_format=lambda value: f"{value:.4f}")) For the baseline, the cash median is about 0.9589, with 5th and 95th percentiles about 0.7960 and 1.4772. Cash falls short in 294 of 500 scenarios (58.8%). The bond ends at 0.987942 in every scenario: no dispersion, but a shortfall in all of them. The tiny comparison tolerance ignores floating-point noise at exactly one; it is not an economic funding buffer. These frequencies describe a finite risk-neutral simulation, not estimated real-world failure probabilities.
The controls below repeat the same calculation for the selected inputs. The first chart shows five cash paths and the constant bond ratio. The second displays empirical cumulative distributions: at each funding ratio
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_liability_hedging_echart,
n_years=widgets.IntSlider(value=10, min=1, max=15, continuous_update=False),
n_scenarios=widgets.IntSlider(value=500, min=100, max=1000, step=100, continuous_update=False),
initial_assets=widgets.FloatSlider(value=0.75, min=0.0, max=2.0, step=0.05, readout_format=".2f", continuous_update=False),
liability=widgets.FloatSlider(value=1.0, min=0.5, max=2.0, step=0.1, readout_format=".2f", continuous_update=False),
a=widgets.FloatSlider(value=0.05, min=0.05, max=1.0, step=0.05, continuous_update=False),
b=widgets.FloatSlider(value=0.03, min=0.0, max=0.10, step=0.005, readout_format=".3f", continuous_update=False),
sigma=widgets.FloatSlider(value=0.08, min=0.0, max=0.30, step=0.01, continuous_update=False),
periods_per_year=widgets.Dropdown(options=[12, 52, 252], value=12),
r0=widgets.FloatSlider(value=0.03, min=0.0, max=0.15, step=0.005, readout_format=".3f", continuous_update=False),
seed=widgets.IntSlider(value=42, min=0, max=1000, continuous_update=False),
)
widget_instance Try it: raise initial_assets to 0.80 while keeping the other inputs fixed. The same rate scenarios are reused through the seed; the bond now locks in a surplus because the starting capital exceeds the matching cost. Return to 0.75 and set sigma=0, keeping r0=b=0.03: cash and bond outcomes coincide near 1.007937. Restore volatility and change the seed: cash outcomes change, while the matching bond's terminal ratio does not. Changing the horizon also changes the liability date and matching cost; changing the frequency changes the tenor of the rolled short bonds.
The distinction is hedge effectiveness versus funding adequacy. Matching a known payment removes interest-rate uncertainty relative to that payment; it does not supply missing capital. Next, coupon-bearing bonds introduce multiple dated cash flows, making the timing of the hedge more involved.
Coupon-Bearing Bonds
A zero-coupon bond matches one future payment. A coupon-bearing bond distributes cash across several dates: regular coupons followed by the final coupon and face value. Those earlier payments matter for both liability matching and investment returns. Receiving money sooner introduces a decision about how to use or reinvest it.
Consider a default-free, fixed-rate bond purchased on a coupon date, immediately after any payment. Let
Face value is the contractual redemption amount, not necessarily the amount invested. We use full, or dirty, prices: the value of all remaining payments. On coupon dates just after payment, accrued interest is zero, so clean and dirty prices coincide. Between coupon dates they generally differ. The examples omit default, taxes, transaction costs, and irregular stub periods.
Construct the Payment Schedule
Use a three-year bond with face value 100 dollars, a 3% annual coupon, and semiannual payments. Each coupon is
Run the chapter imports, then these cells in order. The toolkit's schedule is indexed in coupon periods; the displayed table also gives elapsed years:
coupon_terms = {"principal": 100, "maturity": 3, "coupon_rate": 0.03, "coupons_per_year": 2}
coupon_flows = pok.bond_cash_flows(**coupon_terms)
coupon_times = coupon_flows.index.to_numpy() / coupon_terms["coupons_per_year"]
coupon_schedule = pd.DataFrame({
"Payment year": coupon_times, "Payment (dollars)": coupon_flows[0].to_numpy(),
}, index=coupon_flows.index)
print(coupon_schedule.to_string()) Price the Bond from Its Yield to Maturity
The yield to maturity (YTM) is the cash-flow internal rate of return implied by a bond's price and promised payments. Here
The time-zero price is:
The annuity expression applies for
coupon_ytm = 0.05
coupon_periodic_yield = coupon_ytm / coupon_terms["coupons_per_year"]
coupon_discounted = coupon_flows[0] / (1 + coupon_periodic_yield) ** coupon_flows.index
coupon_purchase_price = pok.bond_price(ytm=coupon_ytm, **coupon_terms)
print(f"Discounted payment sum: {coupon_discounted.sum():.6f}")
print(f"Toolkit price: {coupon_purchase_price:.6f}")
print(f"Undiscounted payments: {coupon_flows[0].sum():.2f}") The price is approximately 94.491875 dollars. The 109-dollar sum of payments ignores their timing; it is not an amount automatically accumulated at maturity with interest.
Distinguish YTM from the Realized Compounded Return
YTM describes the dated promised cash flows. To turn them into a single terminal wealth amount, specify what happens to the coupons. If each received payment is held or reinvested at an effective annual rate
Compare zero-interest holding with reinvestment at the YTM's effective annual equivalent:
coupon_effective_ytm = (1 + coupon_periodic_yield) ** coupon_terms["coupons_per_year"] - 1
coupon_reinvestment_rates = pd.Series({"Hold coupons at zero interest": 0.0,
"Reinvest at equivalent YTM": coupon_effective_ytm})
coupon_terminal_wealth = pd.Series({
name: (coupon_flows[0].to_numpy() * (1 + rate) ** (coupon_terms["maturity"] - coupon_times)).sum()
for name, rate in coupon_reinvestment_rates.items()
})
coupon_realized_returns = (coupon_terminal_wealth / coupon_purchase_price) ** (1 / coupon_terms["maturity"]) - 1
print(pd.DataFrame({"Terminal wealth": coupon_terminal_wealth,
"Annualized return (%)": coupon_realized_returns * 100}).to_string()) Holding coupons at zero interest gives 109 dollars and approximately 4.8763% annualized. Reinvesting at 5.0625% effective annually, equivalent to the quoted 5% nominal YTM, gives approximately 109.581605 dollars and 5.0625% annualized. Simply holding to maturity does not guarantee that reinvestment rate. The scalar-price form of bond_returns() reports the zero-interest holding result; it does not solve for YTM.
Discount, Par, and Premium
For this regular bond purchased immediately after a coupon date, compare the coupon rate and YTM using the same nominal quotation frequency:
- If
, the bond sells below face value, at a discount. - If
, the bond sells at face value, or par. - If
, the bond sells above face value, at a premium.
Higher yields reduce the present value of the same fixed payments. The following comparison keeps all bond terms unchanged:
coupon_yield_cases = pd.Series({"Premium": 0.02, "Par": 0.03, "Discount": 0.05})
coupon_price_cases = pd.DataFrame({
"Nominal YTM (%)": coupon_yield_cases * 100,
"Price": [pok.bond_price(ytm=yield_quote, **coupon_terms) for yield_quote in coupon_yield_cases],
})
print(coupon_price_cases.to_string(float_format=lambda value: f"{value:.6f}")) The prices are approximately 102.897738, 100, and 94.491875 dollars. The interactive experiment below extends this comparison across a continuous range of yields.
Price the Remaining Payments on CIR Rate Paths
Return to the short-rate model. An instantaneous short rate is not the YTM of a coupon bond. Instead, value each remaining payment using its own CIR zero-coupon discount factor:
The strict inequality establishes a post-payment convention: any cash paid at date
coupon_frequency = 12 # Monthly observations; semiannual coupon payments
coupon_cir_model = {"a": 0.5, "b": 0.03, "sigma": 0.10}
coupon_rate_paths, _ = pok.simulate_cir(
n_years=coupon_terms["maturity"], n_scenarios=5,
periods_per_year=coupon_frequency, r0=0.05, seed=42, **coupon_cir_model,
)
coupon_price_paths = pok.cir_bond_price(
coupon_rate_paths, periods_per_year=coupon_frequency, **coupon_terms, **coupon_cir_model,
)
coupon_elapsed = coupon_price_paths.index.to_numpy() / coupon_frequency
print(f"Initial CIR-curve price: {coupon_price_paths.iloc[0, 0]:.6f}")
plot_data = {"couponPrices": {
"type": "line", "title": "Coupon Bond: Post-Payment Values on CIR Paths",
"xAxis": {"type": "value", "name": "Elapsed years", "min": 0, "max": coupon_terms["maturity"]},
"yAxis": {"type": "value", "name": "Full bond price (dollars)", "min": 0},
"series": {f"Scenario {column + 1}": np.column_stack([coupon_elapsed, coupon_price_paths[column]]).tolist()
for column in coupon_price_paths.columns},
}}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) The initial price is approximately 97.259016 dollars. These are the same bond payments priced on a different curve from the flat 5% nominal YTM example. Values respond to the current rate, remaining time, and payments leaving the bond. The final drop to zero records redemption, not a loss of the principal. As before, the paths use pricing-measure dynamics, not calibrated forecasts of investment returns.
Include Distributions in Total Return
Let
Here
coupon_payments = pd.Series(0.0, index=coupon_price_paths.index)
coupon_payment_steps = (coupon_times * coupon_frequency).astype(int)
coupon_payments.loc[coupon_payment_steps] = coupon_flows[0].to_numpy()
coupon_total_returns = (coupon_price_paths + coupon_payments.to_numpy()[:, None]) / coupon_price_paths.shift(1) - 1
coupon_total_returns = coupon_total_returns.iloc[1:]
coupon_return_index = 100 * np.vstack([np.ones(coupon_total_returns.shape[1]), (1 + coupon_total_returns).cumprod().to_numpy()])
print("Annualized total returns (%):")
print((pok.annualize_rets(coupon_total_returns, coupon_frequency) * 100).round(4).to_string())
plot_data = {"couponTotalReturn": {
"type": "line", "title": "Scenario 1: Price Alone Versus Reinvested Total Return",
"xAxis": {"type": "value", "name": "Elapsed years", "min": 0, "max": coupon_terms["maturity"]},
"yAxis": {"type": "value", "name": "Index (initial value = 100)", "min": 0},
"series": {
"Price only": np.column_stack([coupon_elapsed, 100 * coupon_price_paths[0] / coupon_price_paths.iloc[0, 0]]).tolist(),
"Total return": np.column_stack([coupon_elapsed, coupon_return_index[:, 0]]).tolist(),
},
}}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) The total-return index assumes earlier coupons are reinvested in the remaining bond at each payment date; at maturity it holds the redemption proceeds. It is different from holding one bond and leaving all coupons idle. pok.bond_returns() implements the displayed payment accounting. It uses the stated maturity, or the maturity stored by the pricing helper, so a shortened observation window does not falsely trigger redemption.
Constant-yield check. With the nominal YTM fixed at 5%, full prices still change as time passes and coupons are paid. Each monthly total return should be
coupon_flat_yields = pd.DataFrame(coupon_ytm, index=coupon_price_paths.index, columns=["Flat YTM"])
coupon_flat_prices = pok.bond_price(
ytm=coupon_flat_yields, periods_per_year=coupon_frequency, **coupon_terms,
)
coupon_flat_returns = pok.bond_returns(
bond_prices=coupon_flat_prices, periods_per_year=coupon_frequency, **coupon_terms,
)
coupon_expected_monthly = (1 + coupon_periodic_yield) ** (coupon_terms["coupons_per_year"] / coupon_frequency) - 1
print(f"Expected monthly total return: {coupon_expected_monthly:.6%}")
print(f"Final monthly total return: {coupon_flat_returns.iloc[-1, 0]:.6%}")
print(f"Annualized total return: {pok.annualize_rets(coupon_flat_returns, coupon_frequency).iloc[0]:.6%}") Interactive Pricing and Reinvestment Experiment
The first panel varies nominal YTM while keeping the promised payments fixed. The second varies the effective annual coupon-reinvestment rate, holding the purchase price fixed. Diamond markers show the selected assumptions. Changing reinvestment affects terminal wealth, not today's bond price.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_coupon_bond_echart,
principal=widgets.IntSlider(value=100, min=100, max=1000, step=100, continuous_update=False),
maturity=widgets.IntSlider(value=3, min=1, max=10, continuous_update=False),
coupon_rate=widgets.FloatSlider(value=0.03, min=0.0, max=0.10, step=0.005, readout_format=".3f", continuous_update=False),
coupons_per_year=widgets.Dropdown(options=[("Annual", 1), ("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=2),
ytm=widgets.FloatSlider(value=0.05, min=-0.02, max=0.15, step=0.005, readout_format=".3f", continuous_update=False),
reinvestment_rate=widgets.FloatSlider(value=0.0, min=-0.02, max=0.15, step=0.005, readout_format=".3f", continuous_update=False),
)
widget_instance Try it: set ytm equal to coupon_rate and check the par price. Increase YTM to move below par, then change coupon frequency and inspect the payment amount and effective yield. Raise reinvestment_rate: terminal wealth rises while price stays fixed. Finally, set coupon_rate=0; terminal wealth is just face value, independent of coupon reinvestment. With zero YTM, price equals the undiscounted payment sum.
We now have the dated payments, their present values, and a return calculation that includes distributions. The next section uses those discounted payments to measure their weighted-average timing: Macaulay duration.
Macaulay Duration
The coupon-bond example showed that maturity alone does not describe when an investor receives value. Some cash arrives before the final payment. Macaulay duration summarizes that timing as a present-value-weighted average of the payment dates.
Continue with the same three-year bond: 100 dollars face value, a 3% annual coupon paid semiannually, and a 5% nominal annual YTM. Its price is approximately 94.491875 dollars. We value it at time zero, immediately after a coupon date, using the flat-YTM convention from the previous section.
Weight Each Payment by Its Present Value
For payments
Here
Run the earlier coupon cash-flow and pricing cells first. Reuse their payments, dates, and discounted values to make the weights visible:
duration_table = pd.DataFrame({
"Payment year": coupon_times,
"Payment": coupon_flows[0],
"Present value": coupon_discounted,
})
duration_table["PV weight"] = duration_table["Present value"] / coupon_purchase_price
duration_table["Contribution (years)"] = duration_table["Payment year"] * duration_table["PV weight"]
duration_years = duration_table["Contribution (years)"].sum()
duration_periods = pok.mac_duration(coupon_flows, coupon_periodic_yield)
print(duration_table.to_string(float_format=lambda value: f"{value:.6f}"))
print(f"Weight sum: {duration_table['PV weight'].sum():.6f}")
print(f"Macaulay duration: {duration_years:.6f} years")
print(f"Toolkit result: {duration_periods:.6f} coupon periods") The result is 2.887555 years, or 5.775110 half-year periods. Approximately 92.625% of the bond's present value comes from the final payment, so the weighted-average date is close to maturity. Earlier coupons pull it below three years.
Duration is not a payback date. By 2.887555 years, this bond has paid only five coupons totaling 7.50 dollars; its principal is still due at year three. The duration statistic summarizes discounted timing rather than the date on which cumulative receipts recover the purchase price.
Keep Time Units and Rate Conventions Consistent
mac_duration() returns duration in the units of the cash-flow index. There are two equivalent calculations:
| Cash-flow index | Discount rate passed to the helper | Result |
|---|---|---|
| Coupon periods | Periodic rate | Duration in coupon periods; divide by |
| Years | Effective annual rate | Duration directly in years. |
These give identical present-value weights because
duration_year_flows = coupon_flows.rename(columns={0: "Bond"}).copy()
duration_year_flows.index = coupon_times
duration_effective_rate = (1 + coupon_periodic_yield) ** coupon_terms["coupons_per_year"] - 1
duration_from_years = pok.mac_duration(duration_year_flows, duration_effective_rate)
print(f"Effective annual discount rate: {duration_effective_rate:.6%}")
print(f"Coupon-period calculation: {duration_periods / coupon_terms['coupons_per_year']:.6f} years")
print(f"Year-index calculation: {duration_from_years:.6f} years") Both calculations give 2.887555 years, using 5.0625% effective annually for the year-index calculation. Changing the units has not changed the bond. The helper supports named cash-flow columns; a single valuation returns a number, while multiple streams or rate scenarios return labeled results.
Check the Zero-Coupon Limit
A zero coupon has one positive payment at
duration_zero_flows = pd.DataFrame({"Zero coupon": [100.0]}, index=[coupon_terms["maturity"]])
duration_rate_cases = pd.Series({"Negative rate": -0.01, "Zero rate": 0.0,
"Positive rate": duration_effective_rate})
duration_zero_check = pd.DataFrame({
"Price": pok.present_value(duration_zero_flows, duration_rate_cases),
"Macaulay duration (years)": pok.mac_duration(duration_zero_flows, duration_rate_cases),
})
print(duration_zero_check.to_string(float_format=lambda value: f"{value:.6f}")) All three durations are three years, despite different prices. For a positive-coupon bond with payments before maturity, duration is shorter than maturity under the same nonnegative-payment assumptions.
Connect Timing to Price Sensitivity
Macaulay duration is a timing measure. To approximate sensitivity to the nominal annual YTM used here, convert it to modified duration:
In this equation
duration_modified = duration_years / (1 + coupon_periodic_yield)
duration_shifts_bps = np.array([-100, -50, -1, 0, 1, 50, 100])
duration_repriced = np.array([
pok.bond_price(ytm=coupon_ytm + shift / 10000, **coupon_terms)
for shift in duration_shifts_bps
])
duration_sensitivity = pd.DataFrame({
"Yield change (bp)": duration_shifts_bps,
"Exact price change (%)": (duration_repriced / coupon_purchase_price - 1) * 100,
"Duration approximation (%)": -duration_modified * duration_shifts_bps / 100,
})
print(f"Modified duration: {duration_modified:.6f} years")
print(duration_sensitivity.to_string(index=False, float_format=lambda value: f"{value:.6f}")) Modified duration is approximately 2.817127 years. For +50 bp, it predicts −1.408563%, compared with an exact −1.396769% price change. The approximation captures the local slope; the curved price–yield relationship creates a gap for finite changes. Even a zero coupon has a curved price–yield relationship, although its Macaulay duration is always its maturity.
This is sensitivity to a single YTM quote with fixed payments. It does not by itself measure every movement of a non-flat yield curve or the CIR short-rate factor. That distinction matters when duration is later used for liability hedging.
Interactive Duration Experiment
The first panel displays each payment's share of present value. The second compares exact repricing with the modified-duration line; markers identify the selected yield shift. The controls are self-contained and start from the same bond as the worked example:
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_duration_echart,
quote=widgets.fixed("nominal"),
principal=widgets.IntSlider(value=100, min=100, max=1000, step=100, continuous_update=False),
maturity=widgets.IntSlider(value=3, min=1, max=15, continuous_update=False),
coupon_rate=widgets.FloatSlider(value=0.03, min=0.0, max=0.10, step=0.005, readout_format=".3f", continuous_update=False),
coupons_per_year=widgets.Dropdown(options=[("Annual", 1), ("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=2),
ytm=widgets.FloatSlider(value=0.05, min=-0.02, max=0.15, step=0.005, readout_format=".3f", continuous_update=False),
shift_bps=widgets.IntSlider(value=50, min=-200, max=200, step=25, continuous_update=False),
)
widget_instance Try it: increase principal; price scales, but the weights, duration, and percentage sensitivity do not. Increase the coupon rate while holding maturity and YTM fixed: more present value moves to earlier dates and duration falls. Set the coupon rate to zero to recover duration equal to maturity. Finally, increase maturity and compare small and large yield shifts; the local line becomes a less accurate description as the shift grows.
Duration compresses a payment schedule into one timing measure. Next, we use it to compare assets with liabilities, keeping present-value funding and interest-rate sensitivity matching as separate requirements.
Liability Driven Investing (LDI)
Creating Duration-Matched Portfolios
The earlier zero-coupon hedge matched one liability payment exactly. With several obligations and a limited choice of bonds, we may instead match their present-value-weighted timing. This can reduce sensitivity to small parallel changes in rates, even when the individual payment dates differ.
Keep two questions separate: Are the assets sufficient at today's prices? And do asset and liability values respond similarly to a rate change? We will answer both for 130,000 dollars of initial capital and two 100,000-dollar obligations due in years 10 and 12.
Value the Liabilities on a Common Curve
Use a flat 4% effective annual discount rate, with all cash-flow dates measured in years. Assets and liabilities share this curve; payments are fixed and default-free. Fractional bond holdings are allowed, and transaction costs are omitted. Run the chapter imports before these cells.
ldi_assets, ldi_rate = 130000.0, 0.04
ldi_liabilities = pd.DataFrame({"Liability": [100000.0, 100000.0]}, index=[10, 12])
ldi_liability_pv = float(pok.present_value(ldi_liabilities, ldi_rate).iloc[0])
ldi_liability_duration = pok.mac_duration(ldi_liabilities, ldi_rate)
print(f"Liability PV: {ldi_liability_pv:,.2f}")
print(f"Liability duration: {ldi_liability_duration:.6f} years")
print(f"Initial funding ratio: {ldi_assets / ldi_liability_pv:.6f}")
print(f"PV surplus / deficit: {ldi_assets - ldi_liability_pv:,.2f}") The liability PV is 130,016.12 dollars, and its duration is 10.960799 years. The available capital is therefore 16.12 dollars short, with a funding ratio of approximately 0.999876. Rounding that ratio to 1 would hide the initial deficit.
Combine Bonds Using Market-Value Weights
The available bonds have 1,000-dollar face values, 5% annual coupons, and maturities of 10 and 20 years. Both initially pay annually, so coupon-period indices already denote years. Price their payments on the same curve used for the liabilities:
ldi_short_flows = pok.bond_cash_flows(1000, 10, 0.05, 1).rename(columns={0: "Short"})
ldi_long_flows = pok.bond_cash_flows(1000, 20, 0.05, 1).rename(columns={0: "Long"})
ldi_bond_flows = pd.concat([ldi_short_flows, ldi_long_flows], axis=1).fillna(0).sort_index()
ldi_prices = pok.present_value(ldi_bond_flows, ldi_rate)
ldi_durations = pok.mac_duration(ldi_bond_flows, ldi_rate)
print(pd.DataFrame({"Price": ldi_prices, "Duration (years)": ldi_durations}).to_string()) The short bond costs approximately 1,081.108958 dollars and has duration 8.190899 years. The long bond costs 1,135.903263 dollars and has duration 13.544718 years. The liability duration lies between them.
If
These are market-value weights, not fractions of the bond count or face value. For two distinct asset durations, a long-only match requires the liability duration to lie between them. A target outside that interval would require a short position or a different set of instruments; it cannot be obtained by clipping an invalid weight into the interval. If both asset durations are equal, changing their weights cannot change portfolio duration, and the formula does not identify a unique allocation.
ldi_weights = pd.Series(
pok.duration_match_weight(ldi_durations["Short"], ldi_durations["Long"], ldi_liability_duration),
index=ldi_prices.index,
)
ldi_units = ldi_weights * ldi_assets / ldi_prices
ldi_allocation = pd.DataFrame({"Market-value weight": ldi_weights,
"Amount invested": ldi_weights * ldi_assets,
"Bond quantity": ldi_units})
print(ldi_allocation.to_string(float_format=lambda value: f"{value:.6f}")) The allocation is approximately 48.2631% short and 51.7369% long, buying 58.034872 and 59.211011 bonds respectively. The purchase rule is
Verify the Actual Portfolio Cash Flows
Multiply each bond's payment schedule by its purchased quantity and sum payments occurring on the same date. Zero fills here mean that an instrument has no payment on the other instrument's date:
ldi_position_flows = ldi_bond_flows.mul(ldi_units, axis=1)
ldi_portfolio = ldi_position_flows.sum(axis=1).to_frame("Matched")
ldi_portfolio_pv = float(pok.present_value(ldi_portfolio, ldi_rate).iloc[0])
ldi_portfolio_duration = pok.mac_duration(ldi_portfolio, ldi_rate)
print(ldi_portfolio.loc[[1, 10, 12, 20]].to_string(float_format=lambda value: f"{value:,.2f}"))
print(f"Portfolio PV: {ldi_portfolio_pv:,.2f}")
print(f"Portfolio duration: {ldi_portfolio_duration:.6f} years")
print(f"Liability duration: {ldi_liability_duration:.6f} years") The portfolio PV is 130,000 dollars, and its duration matches 10.960799 years. It still has the same initial deficit. Adding approximately 16.12 dollars and scaling both quantities by the same factor would match the liability PV as well, without changing the weights or duration.
The portfolio's coupons and redemptions do not coincide with the two 100,000-dollar obligations. Meeting those obligations may involve reinvesting earlier receipts and selling remaining bonds; duration matching is not cash-flow matching.
Reprice Fixed Holdings Under Parallel Rate Changes
Let
Matching Macaulay durations on this common curve makes the funding ratio locally flat, even if it starts below one. For the dollar surplus
Matching both present value and duration makes the initial surplus and its first derivative zero. Duration matching alone does not satisfy both conditions.
Compare short-only, long-only, and matched portfolios purchased with the same 130,000-dollar budget. Keep their bond quantities fixed as the discount rate changes; recomputing the holdings at each rate would describe different portfolios.
ldi_comparisons = {
"Short only": ldi_short_flows * (ldi_assets / ldi_prices["Short"]),
"Long only": ldi_long_flows * (ldi_assets / ldi_prices["Long"]),
"Duration matched": ldi_portfolio,
}
ldi_rates = np.linspace(0.01, 0.07, 121)
ldi_liability_values = pok.present_value(ldi_liabilities, ldi_rates)
ldi_ratios = pd.DataFrame({
name: (pok.present_value(flows, ldi_rates) / ldi_liability_values).to_numpy()
for name, flows in ldi_comparisons.items()
}, index=ldi_rates)
ldi_series = {name: np.column_stack([ldi_rates * 100, ldi_ratios[name]]).tolist() for name in ldi_ratios}
ldi_series["Target"] = [[ldi_rates[0] * 100, 1], [ldi_rates[-1] * 100, 1]]
plot_data = {"ldiFunding": {
"type": "line", "title": "Fixed Bond Holdings: Funding-Ratio Sensitivity",
"xAxis": {"type": "value", "name": "Effective annual rate (%)",
"min": round(float(ldi_rates[0] * 100), 10), "max": round(float(ldi_rates[-1] * 100), 10)},
"yAxis": {"type": "value", "name": "Funding ratio", "scale": True}, "series": ldi_series,
}}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) The matched curve is flatter near 4%, but its base value is 0.999876, not a funding guarantee. This is a same-date valuation stress, not a simulation of future returns. We value both payment streams directly; the existing funding_ratio() helper continues to mean current asset value divided by liability PV.
Test a Yield-Curve Shape Change
Even with full initial funding, matching one duration does not hedge every curve movement. Scale the matched portfolio to the liability PV, then raise discount rates only for payments in year 15 or later by one percentage point. The liability payments in years 10 and 12 retain their original discount factors:
ldi_fully_funded = ldi_portfolio * (ldi_liability_pv / ldi_assets)
ldi_stress_flows = pd.concat([ldi_fully_funded, ldi_liabilities], axis=1).fillna(0).sort_index()
ldi_stress_years = ldi_stress_flows.index.to_numpy()
ldi_spot_rates = np.where(ldi_stress_years >= 15, ldi_rate + 0.01, ldi_rate)
ldi_stressed_values = ldi_stress_flows.mul((1 + ldi_spot_rates) ** -ldi_stress_years, axis=0).sum()
ldi_twist_ratio = ldi_stressed_values["Matched"] / ldi_stressed_values["Liability"]
print(f"Initially fully funded: {pok.present_value(ldi_fully_funded, ldi_rate).iloc[0] / ldi_liability_pv:.6f}")
print(f"Funding ratio after the curve-shape change: {ldi_twist_ratio:.6f}") The stressed funding ratio falls below one. The long bond loses value while the liability PV stays unchanged. Finite parallel moves also involve higher-order sensitivity, or convexity, beyond the matched first derivative. As time passes, payment dates shorten and durations change; maintaining a hedge generally requires reassessment and rebalancing.
Interactive Duration-Matching Experiment
The controls rebuild the allocation at the selected base rate, then hold its quantities fixed across the plotted rate scenarios. The second chart compares contractual asset and liability payments. Coupon frequencies may differ, but all prices and durations use year-based dates and the same effective annual curve.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_duration_matching_echart,
asset_value=widgets.IntSlider(value=130000, min=50000, max=250000, step=1000, description="Assets ($)", continuous_update=False),
discount_rate=widgets.FloatSlider(value=0.04, min=0.0, max=0.10, step=0.005, description="Base rate", readout_format=".1%", continuous_update=False),
short_maturity=widgets.IntSlider(value=10, min=1, max=15, description="Short years", continuous_update=False),
long_maturity=widgets.IntSlider(value=20, min=16, max=30, description="Long years", continuous_update=False),
short_frequency=widgets.Dropdown(options=[("Annual", 1), ("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=1, description="Short coupons"),
long_frequency=widgets.Dropdown(options=[("Annual", 1), ("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=1, description="Long coupons"),
liability_delay=widgets.IntSlider(value=0, min=0, max=10, description="Liability delay", continuous_update=False),
)
widget_instance Try it: lower assets to 100,000 dollars. The portfolio still matches duration but is clearly underfunded. Restore the baseline and increase assets to 131,000 dollars to create an initial surplus. Change a coupon frequency and check that the asset and liability durations still agree. Then move both liabilities ten years later: the default bonds no longer bracket their duration, so the widget reports that no long-only match exists. Restore the delay to zero to recover the allocation.
Matching Bonds with Different Coupon Frequencies
Different coupon frequencies do not prevent an exact duration match. The requirement is to express payment dates in common time units and value every payment on the same curve. Consider liabilities of 100,000, 200,000, and 300,000 dollars due in years 3, 5, and 10, valued at a flat 5% effective annual rate.
Use two 1,000-dollar-face bonds: a 15-year bond paying a 5% annual coupon semiannually, and a five-year bond paying a 6% annual coupon quarterly. Normalize each schedule to years before combining them:
mixed_rate = 0.05
mixed_liabilities = pd.DataFrame({"Liability": [100000.0, 200000.0, 300000.0]}, index=[3, 5, 10])
mixed_long = pok.bond_cash_flows(1000, 15, 0.05, 2).rename(columns={0: "Semiannual"})
mixed_short = pok.bond_cash_flows(1000, 5, 0.06, 4).rename(columns={0: "Quarterly"})
mixed_long.index = mixed_long.index / 2
mixed_short.index = mixed_short.index / 4
mixed_flows = pd.concat([mixed_long, mixed_short], axis=1).fillna(0).sort_index()
mixed_prices = pok.present_value(mixed_flows, mixed_rate)
mixed_durations = pok.mac_duration(mixed_flows, mixed_rate)
mixed_nominal_quotes = pd.Series({"Semiannual": pok.compounding_rate_inv(mixed_rate, 2),
"Quarterly": pok.compounding_rate_inv(mixed_rate, 4)})
print(pd.DataFrame({"Equivalent nominal YTM (%)": mixed_nominal_quotes * 100,
"Price": mixed_prices, "Duration (years)": mixed_durations}).to_string()) The equivalent nominal annual YTMs are approximately 4.939015% with semiannual compounding and 4.908894% with quarterly compounding. Both represent the same 5% effective annual curve. Using a nominal 5% in each bond's pricing formula would instead introduce two different effective annual rates.
The respective prices are 1,006.408161 and 1,048.115922 dollars, with durations 10.742333 and 4.374877 years. For this case, explicitly set capital equal to the liability PV, approximately 427,262.97 dollars, so that we can verify both funding and duration matching:
mixed_liability_pv = float(pok.present_value(mixed_liabilities, mixed_rate).iloc[0])
mixed_liability_duration = pok.mac_duration(mixed_liabilities, mixed_rate)
mixed_assets = mixed_liability_pv
mixed_weights = pd.Series(
pok.duration_match_weight(mixed_durations["Semiannual"], mixed_durations["Quarterly"], mixed_liability_duration),
index=mixed_prices.index,
)
mixed_units = mixed_weights * mixed_assets / mixed_prices
mixed_portfolio = mixed_flows.mul(mixed_units, axis=1).sum(axis=1).to_frame("Matched")
mixed_portfolio_pv = float(pok.present_value(mixed_portfolio, mixed_rate).iloc[0])
mixed_portfolio_duration = pok.mac_duration(mixed_portfolio, mixed_rate)
print(mixed_weights.to_string(float_format=lambda value: f"{value:.6%}"))
print(f"Funding ratio: {mixed_portfolio_pv / mixed_liability_pv:.6f}")
print(f"Asset duration: {mixed_portfolio_duration:.6f} years")
print(f"Liability duration: {mixed_liability_duration:.6f} years") Invest approximately 37.3154% in the semiannual bond and 62.6846% in the quarterly bond. The funding ratio is one, and both durations are 6.750918 years, up to floating-point precision. Different coupon frequencies create different cash-flow dates; they do not create an unavoidable duration mismatch when valuation and weights are consistent.
We have now constructed a liability-oriented bond portfolio and identified what its duration match protects against. Next, we introduce a performance-seeking portfolio and examine how allocating between the two changes the investment outcome.
Integrating Performance-Seeking Portfolio (PSP) with Liability-Hedging Portfolio (LHP)
An LDI portfolio can separate a performance-seeking portfolio (PSP), intended to earn risk premia, from a liability-hedging portfolio (LHP), designed around obligations. Combining them creates a trade-off: more growth exposure also creates more uncertainty about the resources available to pay liabilities.
The preceding section constructed a duration-matched bond portfolio. Here we isolate the mechanics of combining sleeves: an equity total-return index and an illustrative bond sleeve. The bond sleeve is a candidate LHP, not a demonstrated hedge of the earlier liability schedule. Its 60/40 weights are assumptions rather than duration-matching results. We compare wealth per unit invested and a terminal threshold; these are not funding ratios.
Naive PSP/LHP Weighting Strategy
Use a ten-year observation horizon, 1,000 scenarios, and semiannual observations and rebalancing. The bonds have 100-dollar face values: a ten-year bond with a 2.8% annual coupon and a twenty-year bond with a 3.5% annual coupon, both paid semiannually. The long bond still has ten years remaining when the experiment ends.
For this illustrative scenario model, assume zero short-rate risk premium: the CIR parameters describe both the rate scenarios and the pricing dynamics. Equity total returns follow a separate GBM with annual drift 7% and volatility 10%, with shocks independent of rates. These are explicit, uncalibrated assumptions, not estimated forecasts. Independence also omits stock–rate dependence that may matter in practice.
Run the chapter imports first. Separate reproducible random streams keep the stock and rate draws independent while allowing all allocations to use the same scenarios:
n_years, n_scenarios, periods_per_year = 10, 1000, 2
rate_seed, stock_seed = np.random.SeedSequence(42).spawn(2)
mix_rate_model = {"a": 0.05, "b": 0.03, "sigma": 0.02}
rates, _ = pok.simulate_cir(
n_years=n_years, n_scenarios=n_scenarios, periods_per_year=periods_per_year,
r0=0.03, seed=rate_seed, **mix_rate_model,
)
print(f"{len(rates)} rate observations, including time zero; {len(rates) - 1} return intervals.") There are 21 observations and 20 earned returns. Price each remaining coupon and principal payment on the CIR curve, rather than treating the short rate as the bond's YTM:
bond_pr_short = pok.cir_bond_price(rates, principal=100, maturity=10, coupon_rate=0.028,
coupons_per_year=2, periods_per_year=periods_per_year, **mix_rate_model)
bond_pr_long = pok.cir_bond_price(rates, principal=100, maturity=20, coupon_rate=0.035,
coupons_per_year=2, periods_per_year=periods_per_year, **mix_rate_model)
bond_rets_short = pok.bond_returns(100, bond_pr_short, 0.028, 2, periods_per_year, maturity=10)
bond_rets_long = pok.bond_returns(100, bond_pr_long, 0.035, 2, periods_per_year, maturity=20)
print(pd.DataFrame({"Short bond price": bond_pr_short[0],
"Long bond price": bond_pr_long[0]}).iloc[[0, -1]].to_string()) The short bond's final post-payment price is zero, with 101.40 dollars included once in its last total return. The long bond pays its 1.75-dollar coupon and retains a positive market value at year ten; it is not redeemed early. The return calculation includes that ending value, as it would when valuing or liquidating the holding.
Next generate the equity total-return index. The exact GBM helper returns log returns, so convert them before portfolio arithmetic:
stock_price, stock_log_rets = pok.simulate_gbm_from_prices(
n_years=n_years, n_scenarios=n_scenarios, mu=0.07, sigma=0.10,
periods_per_year=periods_per_year, start=100, seed=stock_seed,
)
stock_rets = np.expm1(stock_log_rets)
print("Return dates agree:", stock_rets.index.equals(bond_rets_short.index))
print("Scenario labels agree:", stock_rets.columns.equals(bond_rets_short.columns))
print(f"Scenario 1 terminal growth: {pok.terminal_wealth(stock_rets).iloc[0]:.6f}")
print(f"Same growth from prices: {stock_price.iloc[-1, 0] / stock_price.iloc[0, 0]:.6f}") Every return row now denotes the same interval in every sleeve, and corresponding columns denote the same paired scenario. Matching array shapes alone would not establish this alignment.
Implementing Fixed-Mixed Allocation in PSP/LHP Strategy
Let
Construct the bond sleeve with 60% short bonds and 40% long bonds, then allocate 70% to stocks and 30% to that sleeve. Reset both sets of weights every half-year. The resulting beginning-period exposures are 70% stocks, 18% short bonds, and 12% long bonds:
short_weight, psp_weight = 0.6, 0.7
bond_rets = pok.ldi_mixer(bond_rets_short, bond_rets_long, pok.ldi_fixed_allocator, w1=short_weight)
stock_bond_rets = pok.ldi_mixer(stock_rets, bond_rets, pok.ldi_fixed_allocator, w1=psp_weight)
mix_returns = {"Stocks": stock_rets, "Bonds": bond_rets, "Fixed mix": stock_bond_rets}
print(f"Return intervals: {len(stock_bond_rets)}; scenarios: {stock_bond_rets.shape[1]}")
print(stock_bond_rets.iloc[:3, :3].to_string(float_format=lambda value: f"{value:.6f}")) Check one rebalance. Start with 100 units in the first simulated scenario. After its first interval, the equity and bond positions generally no longer have the target weights. Rebalancing transfers value between them without changing total wealth:
mix_first_stock = 100 * psp_weight * (1 + stock_rets.iloc[0, 0])
mix_first_bond = 100 * (1 - psp_weight) * (1 + bond_rets.iloc[0, 0])
mix_first_wealth = mix_first_stock + mix_first_bond
mix_stock_transfer = psp_weight * mix_first_wealth - mix_first_stock
print(f"Ending wealth: {mix_first_wealth:.6f}")
print(f"Same wealth from mixed return: {100 * (1 + stock_bond_rets.iloc[0, 0]):.6f}")
print(f"Stock weight before rebalancing: {mix_first_stock / mix_first_wealth:.6%}")
print(f"Transfer into stocks: {mix_stock_transfer:.6f}; into bonds: {-mix_stock_transfer:.6f}") For this sample, wealth is approximately 109.436981, and the stock weight has drifted to 72.205969%. Selling approximately 2.414146 units of the stock position and buying the same amount of the bond sleeve restores 70/30. No contribution has been added. Future allocations must use information available before their interval's return.
Now compound each strategy from one unit of initial wealth. Also compare buying the two sleeves initially at 70/30 and letting their relative weights drift. The bond sleeve itself still follows its internal 60/40 policy:
mix_wealth = {
name: pd.DataFrame(np.vstack([np.ones(n_scenarios), (1 + returns).cumprod().to_numpy()]))
for name, returns in mix_returns.items()
}
mix_buy_hold = psp_weight * mix_wealth["Stocks"] + (1 - psp_weight) * mix_wealth["Bonds"]
mix_elapsed = np.arange(len(stock_bond_rets) + 1) / periods_per_year
mix_series = {name: np.column_stack([mix_elapsed, wealth.median(axis=1)]).tolist()
for name, wealth in mix_wealth.items()}
mix_series["Buy-and-hold sleeves"] = np.column_stack([mix_elapsed, mix_buy_hold.median(axis=1)]).tolist()
plot_data = {"fixedMixWealth": {
"type": "line", "title": "Fixed Mix and Buy-and-Hold: Pointwise Median Wealth",
"xAxis": {"type": "value", "name": "Elapsed years", "min": 0, "max": n_years},
"yAxis": {"type": "value", "name": "Wealth per initial 1", "min": 0}, "series": mix_series,
}}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) A weighted average of the two sleeves' cumulative wealth describes that buy-and-hold allocation. Compounding their weighted period returns describes the rebalanced mix. They generally differ; neither must dominate on every path. These median curves summarize each date's scenario distribution and are not individual realized paths.
Evaluate Terminal Outcomes
Use a terminal threshold
floor = 0.8
mix_terminal_stats = pd.concat([
pok.summary_stats_terminal(returns, floor=floor, periods_per_year=periods_per_year, name=name)
for name, returns in mix_returns.items()
], axis=1).rename(index={
"Mean ann. ret.": "Mean scenario CAGR (%)", "Mean wealth std": "Terminal wealth std",
"Prob breach": "Terminal shortfalls (%)", "Exp shortfall": "Conditional shortfall",
})
mix_terminal_stats.loc[["Mean scenario CAGR (%)", "Terminal shortfalls (%)"]] *= 100
print(mix_terminal_stats.to_string(float_format=lambda value: f"{value:.6f}")) The mean terminal wealth is approximately 2.010856 for stocks, 1.341793 for bonds, and 1.783945 for the fixed mix. Their mean scenario CAGRs are approximately 6.7781%, 2.9822%, and 5.7355%. Averaging individual CAGRs is different from annualizing mean terminal wealth. The reported standard deviation measures dispersion across terminal outcomes, not time-series return volatility.
In this sample, stocks have one terminal miss out of 1,000, and the other two strategies have none. That does not establish zero risk. The helper reports conditional shortfall as zero when no misses are observed, and uses a tiny numerical tolerance at the threshold. All shortfall amounts are wealth per initial unit; multiply by starting capital to express them in dollars. Only terminal wealth is tested here, not interim breaches or drawdowns.
Interactive Fixed-Mix Experiment
The controls repeat these calculations on paired scenarios. Changing only an allocation keeps the component samples fixed. The first chart shows three medians and the fixed mix's pointwise 5th/95th percentiles. The second shows terminal empirical CDFs on one percentage scale. A CDF counts outcomes at or below its horizontal value; equality with the threshold is not a shortfall.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_psp_lhp_echart,
n_years=widgets.IntSlider(value=10, min=1, max=10, description="Years", continuous_update=False),
n_scenarios=widgets.IntSlider(value=1000, min=100, max=2000, step=100, description="Scenarios", continuous_update=False),
periods_per_year=widgets.Dropdown(options=[("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=2, description="Rebalance"),
mu=widgets.FloatSlider(value=0.07, min=0.0, max=0.15, step=0.01, description="Stock drift", readout_format=".1%", continuous_update=False),
sigma=widgets.FloatSlider(value=0.10, min=0.0, max=0.30, step=0.01, description="Stock vol", readout_format=".1%", continuous_update=False),
rate_sigma=widgets.FloatSlider(value=0.02, min=0.0, max=0.10, step=0.01, description="Rate diffusion", readout_format=".2f", continuous_update=False),
short_weight=widgets.FloatSlider(value=0.6, min=0.0, max=1.0, step=0.1, description="Short share", readout_format=".0%", continuous_update=False),
psp_weight=widgets.FloatSlider(value=0.7, min=0.0, max=1.0, step=0.1, description="Stock share", readout_format=".0%", continuous_update=False),
floor=widgets.FloatSlider(value=0.8, min=0.5, max=1.5, step=0.1, description="Threshold", readout_format=".1f", continuous_update=False),
seed=widgets.IntSlider(value=42, min=0, max=1000, description="Seed", continuous_update=False),
)
widget_instance Try it: set the stock share to zero or one; the mixed portfolio must coincide with the corresponding sleeve. Restore 70/30 and raise the threshold: the wealth paths stay unchanged, but more terminal outcomes fail the test. Change the seed to see Monte Carlo variability. The displayed Wilson interval measures sampling uncertainty under these assumptions, not uncertainty about the model itself. Changing rebalancing frequency also changes the sampling grid and trading policy; the seed does not identify the same continuous paths across different grids.
A fixed mix specifies an allocation policy, not a floor guarantee. Next, a glide path makes the target allocation change with time.
Glide Path Weight Allocation Strategy
A glide path changes the target allocation according to a schedule. Here, the stock share declines from 80% to 20%, with the remainder in the same illustrative bond sleeve. The strategy still rebalances periodically, but its target changes with time rather than staying at 70/30. It does not respond to gains, losses, or the funding ratio.
Specify When Each Weight Applies
For
This convention applies the end target to the last return interval. In the ten-year semiannual example there are 20 decisions: 80% is set at year zero and 20% at year 9.5, before the return ending at year ten. There is no additional earned return or required rebalance at the horizon. With only one interval, the helper uses the start target alone.
Run the preceding fixed-mix examples first. Reuse their aligned simple-return frames without drawing new scenarios:
glide_start, glide_end = 0.8, 0.2
glide_weights = pok.ldi_glidepath_allocator(stock_rets, bond_rets, start=glide_start, end=glide_end)
glide_schedule = pd.DataFrame({
"Decision year": np.arange(len(stock_rets)) / periods_per_year,
"Return ending year": np.arange(1, len(stock_rets) + 1) / periods_per_year,
"Stock target (%)": glide_weights.iloc[:, 0].to_numpy() * 100,
})
print(glide_schedule.iloc[[0, 1, -2, -1]].to_string(index=False, float_format=lambda value: f"{value:.6f}")) The second decision sets approximately 76.8421% in stocks. Every scenario receives the same targets; no future return is used to choose a weight. Actual weights can drift within an interval, just as in the fixed-mix example. At the next decision, trades reset them to the next scheduled target, with no external contribution or transaction cost in this exercise.
Separate Timing from Average Stock Exposure
An 80%-to-20% linear schedule has an unweighted mean target of 50%, whereas the preceding fixed mix holds 70%. A comparison only with 70/30 combines a timing change with a lower average stock allocation. Include a constant allocation at the glide path's mean target as another benchmark:
stock_bond_rets_glide = pok.ldi_mixer(stock_rets, bond_rets, pok.ldi_glidepath_allocator,
start=glide_start, end=glide_end)
glide_mean_target = float(glide_weights.iloc[:, 0].mean())
glide_constant_rets = pok.ldi_mixer(stock_rets, bond_rets, pok.ldi_fixed_allocator, w1=glide_mean_target)
glide_comparisons = {"Glide path": stock_bond_rets_glide,
"Constant mean target": glide_constant_rets, "Fixed 70/30": stock_bond_rets}
glide_terminal = pd.DataFrame({name: pok.terminal_wealth(returns) for name, returns in glide_comparisons.items()})
glide_difference = glide_terminal["Glide path"] - glide_terminal["Constant mean target"]
print(f"Mean stock target: {glide_mean_target:.2%}")
print(f"Mean paired terminal difference: {glide_difference.mean():+.6f}")
print(f"Glide finishes higher in {(glide_difference > 1e-10).mean():.1%} of these scenarios") Each column comparison uses the same stock and bond scenario. For the baseline, the glide path finishes higher than the constant-50% policy in 50.4% of scenarios, but its mean terminal difference is approximately −0.001985 per initial unit. Neither policy dominates every path.
Equal mean targets do not imply equal risk or equal dollar exposure: wealth and the timing of returns also matter. A stock loss early in the declining schedule receives a larger portfolio weight than the same loss late in it. With changing weights, the order of paired sleeve returns can therefore change terminal wealth; with constant weights and no cash flows or costs, permuting those paired interval returns leaves their compounded product unchanged.
Compare Terminal Outcomes
Use the same terminal threshold of 0.8 per unit initially invested and the same statistics as before:
glide_stats = pd.concat([
pok.summary_stats_terminal(returns, floor=floor, periods_per_year=periods_per_year, name=name)
for name, returns in glide_comparisons.items()
], axis=1).rename(index={
"Mean ann. ret.": "Mean scenario CAGR (%)", "Mean wealth std": "Terminal wealth std",
"Prob breach": "Terminal shortfalls (%)", "Exp shortfall": "Conditional shortfall",
})
glide_stats.loc[["Mean scenario CAGR (%)", "Terminal shortfalls (%)"]] *= 100
print(glide_stats.to_string(float_format=lambda value: f"{value:.6f}")) Mean terminal wealth is approximately 1.643873 for the glide path, 1.645857 for constant 50/50, and 1.783945 for fixed 70/30. In this sample, the glide path has lower terminal dispersion than 70/30 but slightly higher dispersion than constant 50/50. Declining stock exposure does not establish superior risk-adjusted performance. The chart below plots medians, which need not rank the strategies in the same order as their mean terminal wealth.
None of these three samples finishes below 0.8, which is not a protection guarantee or evidence of zero risk. The inherited stock/CIR assumptions are uncalibrated, and the bond sleeve is not a demonstrated hedge of a specified liability. A calendar rule cannot restore wealth already lost, and it does not react when a floor is threatened.
Interactive Glide-Path Experiment
These self-contained controls use the preceding experiment's baseline asset model. The staircase shows beginning-period targets; its final segment extends the last applied target through the last return interval. The second panel shows pointwise median wealth, not individual paths. Endpoint changes reuse the same component scenarios for the chosen seed and observation grid.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_glidepath_echart,
start=widgets.FloatSlider(value=0.8, min=0.0, max=1.0, step=0.1, description="Start stocks", readout_format=".0%", continuous_update=False),
end=widgets.FloatSlider(value=0.2, min=0.0, max=1.0, step=0.1, description="End stocks", readout_format=".0%", continuous_update=False),
n_years=widgets.IntSlider(value=10, min=1, max=10, description="Years", continuous_update=False),
n_scenarios=widgets.IntSlider(value=1000, min=100, max=2000, step=100, description="Scenarios", continuous_update=False),
periods_per_year=widgets.Dropdown(options=[("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=2, description="Rebalance"),
floor=widgets.FloatSlider(value=0.8, min=0.5, max=1.5, step=0.1, description="Threshold", readout_format=".1f", continuous_update=False),
seed=widgets.IntSlider(value=42, min=0, max=1000, description="Seed", continuous_update=False),
)
widget_instance Try it: make the endpoints equal; the glide path and its constant-mean benchmark must coincide. Reverse the endpoints to compare increasing and decreasing exposure. Change the threshold: the allocation and wealth paths stay unchanged, while terminal shortfall statistics may change. Increasing the observation frequency creates more allocation decisions and a different sampling grid; it does not guarantee a better outcome.
Next, we move from calendar-based allocation to a rule that explicitly considers a floor.
Integrating Floor Considerations with Performance-Seeking and Liability-Hedging Portfolios
A glide path responds to the calendar. A floor-aware allocator responds to the resources available above a specified obligation. Return to a default-free zero-coupon bond that matures on the liability date: its value tracks the current cost of meeting that terminal obligation.
Normalize initial wealth to
The amount due is fixed, but its current value moves with rates and time. A terminal promise of 0.8 is therefore different from requiring wealth to stay above 0.8 at every earlier date.
Use a fresh ten-year, monthly experiment with 1,000 paired scenarios. Retain the preceding assumption that CIR scenario dynamics equal pricing dynamics, with zero modeled short-rate risk premium and independent equity shocks. The stock total-return GBM now has drift 7% and volatility 15%. These remain uncalibrated assumptions. Run the chapter imports first:
n_years, n_scenarios, periods_per_year = 10, 1000, 12
floor_rate_seed, floor_stock_seed = np.random.SeedSequence(42).spawn(2)
rates, zcb_price = pok.simulate_cir(
n_years=n_years, n_scenarios=n_scenarios, a=0.05, b=0.03, sigma=0.02,
periods_per_year=periods_per_year, r0=0.03, seed=floor_rate_seed,
)
stock_price, floor_stock_logs = 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, start=100, seed=floor_stock_seed,
)
stock_rets = np.expm1(floor_stock_logs)
zcb_rets = zcb_price.pct_change(fill_method=None).iloc[1:]
print(f"Price observations: {len(zcb_price)}; earned return intervals: {len(zcb_rets)}") The bond pays no coupons. Its holding-period return is
Check the Initial Funding Requirement
Buying only the bond with one unit of capital purchases
floor = 0.8
floor_values = floor * zcb_price
stock_zcb_rets = pok.ldi_mixer(stock_rets, zcb_rets, pok.ldi_fixed_allocator, w1=0.7)
floor_benchmarks = {"ZCB only": zcb_rets, "Stocks": stock_rets, "Fixed 70/30": stock_zcb_rets}
print(f"Initial unit bond price: {zcb_price.iloc[0, 0]:.6f}")
print(f"Initial cost of the floor: {floor_values.iloc[0, 0]:.6f}")
print(f"Initial cushion: {1 - floor_values.iloc[0, 0]:.6f}")
print(f"All-bond terminal wealth: {1 / zcb_price.iloc[0, 0]:.6f}") Here
Floor Allocator
At each decision date, calculate the cushion
where
Run multipliers 1, 3, and 5 on exactly the same paths. ldi_floor_backtest() records wealth and floor values at all observation dates, with weights and cushions indexed by the return interval they precede:
floor_runs = {multiplier: pok.ldi_floor_backtest(stock_rets, zcb_rets, zcb_price, floor=floor, m=multiplier)
for multiplier in (1, 3, 5)}
stock_zcb_floor_m1_rets = floor_runs[1]["Returns"]
floor_trace = pd.DataFrame({
"Decision year": np.arange(len(stock_rets)) / periods_per_year,
"Beginning wealth": floor_runs[1]["Wealth"].iloc[:-1, 0].to_numpy(),
"Floor PV": floor_values.iloc[:-1, 0].to_numpy(),
"Cushion": floor_runs[1]["Cushions"].iloc[:, 0].to_numpy(),
"Stock weight (%)": floor_runs[1]["Risky allocation"].iloc[:, 0].to_numpy() * 100,
}, index=stock_rets.index)
print(floor_trace.iloc[:3].to_string(float_format=lambda value: f"{value:.6f}"))
print("Initial stock weights:", {multiplier: result["Risky allocation"].iloc[0, 0]
for multiplier, result in floor_runs.items()}) The initial stock weights are approximately 40.3910%, 100%, and 100% respectively. For multipliers 3 and 5, the initial raw exposure exceeds wealth and is capped. The compatible ldi_floor_allocator() returns just the weights for ldi_mixer(); it also requires the full zcb_price frame, not zcb_price.loc[1:].
What the Reserve Argument Establishes
If the promise is initially funded,
This relies on nonnegative stock values, a default-free bond matching the payment date, and no costs or withdrawals. In fact, with
That is a conditional funding argument, not a conclusion drawn from observing zero failures in a simulation. If
For
The cushion becomes negative if the stock's return relative to the hedging bond is below
Hand-Check a Gap Loss
Consider a separate one-period accounting stress, not a draw from the baseline model: a unit bond costs 0.95 and pays one, while stocks lose 50%. With
floor_shock_prices = pd.DataFrame({"Shock": [0.95, 1.0]}, index=[0, 1])
floor_shock_bonds = floor_shock_prices.pct_change(fill_method=None).iloc[1:]
floor_shock_stocks = pd.DataFrame({"Shock": [-0.50]}, index=[1])
floor_shock_runs = {multiplier: pok.ldi_floor_backtest(floor_shock_stocks, floor_shock_bonds,
floor_shock_prices, floor=0.8, m=multiplier)
for multiplier in (1, 3, 5)}
floor_shock_table = pd.DataFrame({multiplier: {
"Initial stock weight (%)": result["Risky allocation"].iloc[0, 0] * 100,
"Terminal wealth": result["Wealth"].iloc[-1, 0],
"Terminal shortfall": max(0.8 - result["Wealth"].iloc[-1, 0], 0),
} for multiplier, result in floor_shock_runs.items()}).T
print(floor_shock_table.to_string(float_format=lambda value: f"{value:.6f}")) With
Compare the Full Scenario Sample
Keep terminal shortfalls distinct from sampled-date PV breaches. The latter compare wealth with the contemporaneous
floor_all_returns = {**floor_benchmarks, **{f"Floor m={multiplier}": result["Returns"]
for multiplier, result in floor_runs.items()}}
ldi_stats = pd.concat([pok.summary_stats_terminal(returns, floor=floor,
periods_per_year=periods_per_year, name=name)
for name, returns in floor_all_returns.items()], axis=1)
floor_display_stats = ldi_stats.rename(index={
"Mean ann. ret.": "Mean scenario CAGR (%)", "Mean wealth std": "Terminal wealth std",
"Prob breach": "Terminal shortfalls (%)", "Exp shortfall": "Conditional shortfall",
})
floor_display_stats.loc[["Mean scenario CAGR (%)", "Terminal shortfalls (%)"]] *= 100
floor_comparison_wealth = {name: np.vstack([np.ones(n_scenarios), (1 + returns).cumprod().to_numpy()])
for name, returns in floor_all_returns.items()}
floor_display_stats.loc["Sampled PV breaches (%)"] = pd.Series({
name: (wealth < floor_values.to_numpy() - 1e-10).any(axis=0).mean() * 100
for name, wealth in floor_comparison_wealth.items()
})
print(floor_display_stats.T.to_string(float_format=lambda value: f"{value:.6f}")) For the baseline, mean terminal wealth is approximately 1.621178, 1.996878, and 2.015875 for multipliers 1, 3, and 5. None of those sampled policies breaches the floor here. The gap stress shows why that observation cannot establish protection for multipliers above one. These are scenario outcomes under the stated assumptions, not forecasts or evidence that the highest multiplier is optimal.
Interactive Floor Experiment
The first chart pairs one portfolio path with its own floor PV. The second shows the stock allocation chosen before each return interval. Statistics cover all scenarios. Changing the displayed path does not change those statistics; changing the multiplier reuses the same market paths. The horizon also sets the zero coupon's maturity.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_floor_echart,
floor=widgets.FloatSlider(value=0.8, min=0.0, max=1.6, step=0.1, description="Terminal floor", readout_format=".1f", continuous_update=False),
m=widgets.IntSlider(value=1, min=0, max=6, description="Multiplier", continuous_update=False),
n_years=widgets.IntSlider(value=10, min=1, max=10, description="Years", continuous_update=False),
n_scenarios=widgets.IntSlider(value=1000, min=100, max=2000, step=100, description="Scenarios", continuous_update=False),
periods_per_year=widgets.Dropdown(options=[("Semiannual", 2), ("Monthly", 12), ("Weekly", 52)], value=12, description="Rebalance"),
sigma=widgets.FloatSlider(value=0.15, min=0.0, max=0.40, step=0.01, description="Stock vol", readout_format=".1%", continuous_update=False),
scenario=widgets.IntSlider(value=1, min=1, max=10, description="Path", continuous_update=False),
seed=widgets.IntSlider(value=42, min=0, max=1000, description="Seed", continuous_update=False),
)
widget_instance Try it: set the multiplier to zero; the floor policy becomes the all-bond strategy. Restore one and compare the wealth and floor curves. Raise the promised floor to 1.6 with the baseline horizon and rate model: it is initially underfunded, and the policy cannot create the missing reserve. Restore 0.8, then raise both multiplier and volatility to examine gap losses. More frequent observations change both rebalancing and the sampling grid; the same seed does not identify identical continuous paths across grids.
This floor is a fixed terminal promise. It does not rise after portfolio gains or impose a maximum drawdown from a previous peak. That distinction motivates the next allocator.
Drawdown Allocator
The preceding floor protected a fixed amount due at a future date. A drawdown rule instead refers to losses from the portfolio's own previous wealth peak. Those objectives differ: an investor can finish above the starting capital after losing a substantial fraction of an earlier gain.
For a requested drawdown limit
Include initial wealth in the peak. Drawdowns are zero at a high-water mark and negative below it; a breach of the requested limit means
The toolkit retains the rule
Choose this weight before earning the next return. Only afterward update wealth, the high-water mark, and the floor for the following decision. Each strategy uses its own wealth peak, not the stock index's peak.
At a peak, maxdd is a policy parameter, not a guarantee.
Hand-Check a Gain, a Gap Loss, and the Defensive Sleeve
Consider three observation intervals before a defensive bond's maturity. Stocks gain 50%, lose 30%, then recover 20%. Compare zero-return cash with a constructed bond-like defensive series that loses 10% in the third interval. This is an accounting stress, not a simulated CIR path:
maxdd = 0.2
drawdown_shock_stocks = pd.DataFrame({"Shock": [0.50, -0.30, 0.20]}, index=[1, 2, 3])
drawdown_shock_cash = drawdown_shock_stocks * 0
drawdown_shock_bond = pd.DataFrame({"Shock": [0.0, 0.0, -0.10]}, index=[1, 2, 3])
drawdown_shock_runs = {
"Cash": pok.ldi_drawdown_backtest(drawdown_shock_stocks, drawdown_shock_cash, maxdd),
"Bond-like": pok.ldi_drawdown_backtest(drawdown_shock_stocks, drawdown_shock_bond, maxdd),
}
print(pd.DataFrame({name: {
"Ending wealth": result["Wealth"].iloc[-1, 0],
"Worst drawdown (%)": result["Drawdown"].iloc[:, 0].min() * 100,
"Terminal below 0.8": result["Wealth"].iloc[-1, 0] < 0.8,
} for name, result in drawdown_shock_runs.items()}).T.to_string()) Wealth rises from 1 to 1.50, lifting the next floor from 0.80 to 1.20. The subsequent stock loss takes wealth to 1.05, a 30% drawdown, despite wealth remaining above both its starting level and 0.8. The rule then assigns zero to stocks and misses their rebound. With zero-return cash, wealth remains 1.05; with the bond-like loss, it falls to 0.945, a 37% drawdown. Both terminal values still pass the 0.8 test.
A long zero-coupon bond matches a dated terminal payment, but its market price can fall before maturity. Moving into it therefore does not freeze a nominal high-water floor. Even a stable cash sleeve cannot undo the stock loss that occurred before the allocation was reduced.
Apply the Rule to the Paired Market Scenarios
Run the preceding floor experiment first to obtain its aligned monthly stock and ZCB returns. Keep the same scenarios and request a 20% drawdown limit. The backtest reuses the tested CPPI accounting engine and records both the floor used for a decision and the updated floor afterward:
drawdown_zcb = pok.ldi_drawdown_backtest(stock_rets, zcb_rets, maxdd=maxdd)
stock_zcb_dd_02_rets = drawdown_zcb["Returns"]
drawdown_trace = pd.DataFrame({
"Prior peak": drawdown_zcb["Prior peaks"].iloc[:, 0],
"Floor used": drawdown_zcb["Floor value"].iloc[:, 0],
"Stock allocation (%)": drawdown_zcb["Risky allocation"].iloc[:, 0] * 100,
"Ending wealth": drawdown_zcb["Wealth"].iloc[:, 0],
"Next floor": drawdown_zcb["Next floor"].iloc[:, 0],
})
print(f"Rule multiplier: {drawdown_zcb['m']:g}")
print(drawdown_trace.iloc[:3].to_string(float_format=lambda value: f"{value:.6f}")) The multiplier is 5. Floor value and Risky allocation are beginning-period quantities; Wealth, Peaks, Next floor, and Drawdown describe the interval's ending state. All these frames retain the return-endpoint labels. The compatible ldi_drawdown_allocator() supplies just the weights to ldi_mixer(); the backtest supplies the path diagnostics needed to assess the limit.
Considering Cash as an Alternative LHP
For a nominal drawdown objective, compare the matching bond with the rolling one-period bond cash proxy introduced earlier. Use the same CIR curve and rate paths, rather than adding an unrelated fixed cash rate. Each short bond is held to its next observation-date maturity, then rolled. Its holding-period return is known at purchase, although future reinvestment rates are not.
drawdown_cash_wealth = pok.cir_cash_wealth(rates, initial_assets=1, a=0.05, b=0.03,
sigma=0.02, periods_per_year=periods_per_year)
cash_rets = drawdown_cash_wealth.pct_change(fill_method=None).iloc[1:]
drawdown_cash = pok.ldi_drawdown_backtest(stock_rets, cash_rets, maxdd=maxdd)
stock_cash_dd_02_rets = drawdown_cash["Returns"]
print("Cash returns are nonnegative at roll dates:", (cash_rets >= 0).to_numpy().all())
print("Negative long-bond return observations:", int((zcb_rets < 0).to_numpy().sum())) In this nonnegative-rate model, the cash proxy does not add a loss at a roll date, while the long bond can have a negative holding-period return. Each policy now follows its own wealth and peak history on the same stock scenarios. Cash can reduce this source of defensive-sleeve risk; it does not eliminate stock gap risk. After a breach, zero-return cash leaves the account below its floor, positive cash returns may gradually restore the cushion, and a falling bond can worsen the deficit.
Measure the Objective Along the Path
A terminal-wealth histogram cannot identify the worst loss from an earlier peak. Calculate each strategy's observed drawdowns, including initial wealth, and report the frequency of paths exceeding the requested limit. Keep the independent terminal benchmark of 0.8 as a separate column:
drawdown_wealth_paths = {
"Stocks": stock_price.to_numpy() / stock_price.iloc[0].to_numpy(),
"Terminal floor m=1": floor_runs[1]["Wealth"].to_numpy(),
"Drawdown + ZCB": np.vstack([np.ones(n_scenarios), drawdown_zcb["Wealth"].to_numpy()]),
"Drawdown + cash": np.vstack([np.ones(n_scenarios), drawdown_cash["Wealth"].to_numpy()]),
}
drawdown_summary = {}
for name, wealth in drawdown_wealth_paths.items():
own_peaks = np.maximum.accumulate(wealth, axis=0)
worst_drawdown = (wealth / own_peaks - 1).min(axis=0)
drawdown_summary[name] = {
"Mean terminal wealth": wealth[-1].mean(),
"Mean worst drawdown (%)": worst_drawdown.mean() * 100,
"Paths beyond limit (%)": (worst_drawdown < -maxdd - 1e-10).mean() * 100,
"Terminal below 0.8 (%)": (wealth[-1] < 0.8 - 1e-10).mean() * 100,
}
drawdown_summary = pd.DataFrame(drawdown_summary).T
print(drawdown_summary.to_string(float_format=lambda value: f"{value:.6f}")) For the baseline, the ZCB policy exceeds the requested 20% drawdown on 6 of 1,000 paths, yet none of its terminal values is below 0.8. The cash policy has no observed limit breaches in this sample. Its mean worst drawdown is about −16.2759%, versus −16.4008% for the ZCB policy. These are sample outcomes, not guarantees. The earlier stress demonstrates a breach even with zero-return cash.
The terminal-floor strategy and the drawdown strategies protect different reference quantities. Satisfying a fixed terminal promise does not prevent giving back a large part of a prior gain. Conversely, a nominal drawdown policy does not automatically match a dated liability. Observations also leave open what happened between sampling dates.
Interactive Drawdown Comparison
The first panel displays one selected policy's wealth, updated high-water mark, and floor for the next decision. That updated floor was not used retroactively to choose the return just earned. The second panel compares stock-only and both drawdown-policy paths, each relative to its own peak. The selected path is illustrative; the table summarizes all scenarios.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_drawdown_echart,
maxdd=widgets.FloatSlider(value=0.2, min=0.05, max=0.5, step=0.05, description="DD request", readout_format=".0%", continuous_update=False),
hedge=widgets.Dropdown(options=[("Rolled cash", "cash"), ("Matching ZCB", "zcb")], value="cash", description="Display hedge"),
n_years=widgets.IntSlider(value=10, min=1, max=10, description="Years", continuous_update=False),
n_scenarios=widgets.IntSlider(value=1000, min=100, max=2000, step=100, description="Scenarios", continuous_update=False),
periods_per_year=widgets.Dropdown(options=[("Semiannual", 2), ("Monthly", 12), ("Weekly", 52)], value=12, description="Rebalance"),
sigma=widgets.FloatSlider(value=0.15, min=0.0, max=0.4, step=0.01, description="Stock vol", readout_format=".1%", continuous_update=False),
scenario=widgets.IntSlider(value=1, min=1, max=10, description="Path", continuous_update=False),
seed=widgets.IntSlider(value=42, min=0, max=1000, description="Seed", continuous_update=False),
)
widget_instance Try it: switch the displayed hedge; both policies still use the same underlying scenarios. Tighten the drawdown request and inspect the higher multiplier, remembering that the initial stock allocation remains 100%. Increase stock volatility and use less frequent rebalancing to examine gap losses. Changing the displayed path leaves the aggregate statistics unchanged. Next, we apply the rule to historical returns rather than simulated paths.
Real-World Application with Historical Data
Now apply the rule to one observed monthly history, rather than a collection of simulated paths. Use the bundled Kenneth French 30-industry snapshot and the same one-month-lagged industry-market proxy constructed in the diversification chapter. The default window is January 1990–December 2018, comprising 348 monthly returns.
This is not an official total-market index or a live data feed. The extracted files do not establish the capitalization observations' precise within-month timing or publication availability. Lagging makes the information convention explicit; it does not establish a point-in-time investable backtest. Cash below is a hypothetical constant return, and the illustration excludes trading costs, taxes, and contributions.
Construct the Proxy Before Selecting the Window
Let
Run the chapter imports first. Load the three bundled inputs once and apply the lag on the full history. Only then select the backtest window, so January 1990 uses December 1989 weights rather than losing its first return:
hist_industry_returns = pok.get_ind_file(filetype="rets", nind=30)
hist_capitalizations = pok.get_ind_market_caps(nind=30)
hist_begin_weights = hist_capitalizations.div(hist_capitalizations.sum(axis=1), axis=0).shift(1)
historical_proxy = (hist_begin_weights * hist_industry_returns).sum(
axis=1, min_count=hist_industry_returns.shape[1],
).iloc[1:].rename("Market proxy")
historical_start, historical_end = 1990, 2018
historical_market = historical_proxy.loc[f"{historical_start}-01":f"{historical_end}-12"].to_frame()
print(f"Proxy coverage: {historical_proxy.index[0]} to {historical_proxy.index[-1]}")
print(f"Selected returns: {historical_market.index[0]} to {historical_market.index[-1]}, {len(historical_market)} months")
print(f"First selected return uses weights from {historical_market.index[0] - 1}") The raw files begin in July 1926; the proxy starts in August 1926, after discarding the one month without prior weights. get_total_market_index_returns() now implements this same lagged construction. The name is retained for compatibility, but the result is the defined proxy.
Apply a Common Cash Assumption and Starting Capital
Assume cash earns 3% effective annually, equivalent to approximately 0.246627% per month. This is not a historical Treasury-bill series. Compare the market proxy, the drawdown rule with a requested 20% limit, a pre-specified fixed 70/30 market/cash allocation, and cash alone. All begin with wealth one, and the two mixed strategies rebalance monthly:
historical_cash_rate, historical_limit = 0.03, 0.2
historical_monthly_cash = np.expm1(np.log1p(historical_cash_rate) / 12)
historical_cash = pd.DataFrame(historical_monthly_cash, index=historical_market.index,
columns=historical_market.columns)
historical_policy = pok.ldi_drawdown_backtest(historical_market, historical_cash, maxdd=historical_limit)
historical_fixed = pok.ldi_mixer(historical_market, historical_cash, pok.ldi_fixed_allocator, w1=0.7)
historical_returns = pd.concat([
historical_market.iloc[:, 0], historical_policy["Returns"].iloc[:, 0].rename("Drawdown policy"),
historical_fixed.iloc[:, 0].rename("Fixed 70/30"), historical_cash.iloc[:, 0].rename("Assumed cash"),
], axis=1)
historical_dates = historical_returns.index.insert(0, historical_returns.index[0] - 1)
historical_wealth = pd.DataFrame(
np.vstack([np.ones(historical_returns.shape[1]), (1 + historical_returns).cumprod().to_numpy()]),
index=historical_dates, columns=historical_returns.columns,
)
historical_drawdowns = historical_wealth / historical_wealth.cummax() - 1
print(f"Monthly cash assumption: {historical_monthly_cash:.6%}")
print(historical_wealth.iloc[:2].to_string(float_format=lambda value: f"{value:.6f}")) The December 1989 row is the initial wealth marker, not an additional earned return. January 1990's approximately −7.2410% proxy return is included. The drawdown policy also begins fully invested in the proxy under the reciprocal-multiplier rule, so its initial peak must remain one after that loss.
Read the Performance Measures on Consistent Bases
Report geometric growth, conventional annualized volatility, and drawdown from each portfolio's own peak. Use the same assumed cash rate for Sharpe ratios. Volatility uses monthly sample standard deviation times
historical_metrics = pd.DataFrame({
"CAGR (%)": pok.annualize_rets(historical_returns, 12) * 100,
"Annualized vol (%)": pok.annualize_vol(historical_returns, 12) * 100,
"Sharpe vs assumed cash": pok.sharpe_ratio(historical_returns, historical_cash_rate, 12),
"Worst drawdown (%)": historical_drawdowns.min() * 100,
"Months beyond limit": (historical_drawdowns < -historical_limit - 1e-10).sum(),
})
print(historical_metrics.to_string(float_format=lambda value: f"{value:.4f}", na_rep="n/a")) For this window, the proxy has approximately 9.6274% CAGR and a −50.0449% worst observed drawdown. The drawdown policy has 8.3058% CAGR and a −19.4357% worst drawdown. Its Sharpe ratio is approximately 0.5511 against the assumed cash benchmark. These are descriptive results for this construction and period, not evidence of future performance or a guaranteed limit.
The fixed 70/30 portfolio's Sharpe ratio equals the proxy's under constant cash and zero costs: its excess returns and excess-return volatility are both scaled by 0.7. Their wealth and drawdown paths are nevertheless different. Cash's own Sharpe ratio is undefined because its excess-return volatility is zero, shown as a dash.
“Months beyond limit” counts observations in this single history. It is not a Monte Carlo failure probability, and month-end observations do not reveal the worst intramonth drawdown.
Check the Worst Drawdown Episode
Verify the policy's reported drawdown directly from a preceding peak and its trough:
historical_policy_wealth = historical_wealth["Drawdown policy"]
historical_trough = historical_drawdowns["Drawdown policy"].idxmin()
historical_peak = historical_policy_wealth.loc[:historical_trough].idxmax()
historical_peak_value = historical_policy_wealth.loc[historical_peak]
historical_trough_value = historical_policy_wealth.loc[historical_trough]
print(f"Recorded peak: {historical_peak}, wealth {historical_peak_value:.6f}")
print(f"Trough: {historical_trough}, wealth {historical_trough_value:.6f}")
print(f"Peak-to-trough change: {historical_trough_value / historical_peak_value - 1:.6%}") The recorded peak is October 2007, at approximately 5.149189, followed by the trough in February 2009, at 4.148408. Their ratio gives the reported −19.4357% drawdown. Although this sample stays within the requested 20% limit, the earlier gap examples show why the rule cannot guarantee it.
Interactive Historical Comparison
The controls load the same bundled snapshot, ending in December 2018. Changing the start year restarts each account and its peak at one; it does not merely zoom a portfolio that had been running earlier. The wealth panel uses a logarithmic scale to compare proportional growth, while the drawdown panel shows percentage losses from each portfolio's own peak.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_historical_drawdown_echart,
start_year=widgets.IntSlider(value=1990, min=1927, max=2018, description="Start year", continuous_update=False),
end_year=widgets.fixed(2018),
maxdd=widgets.FloatSlider(value=0.2, min=0.05, max=0.5, step=0.05, description="DD request", readout_format=".0%", continuous_update=False),
cash_rate=widgets.FloatSlider(value=0.03, min=0.0, max=0.06, step=0.005, description="Cash rate", readout_format=".1%", continuous_update=False),
)
widget_instance Try it: keep the 1990 start and request a 5% drawdown limit. The first month's loss already exceeds it, because the policy starts fully in the market proxy. Restore 20% and change the starting year to examine window sensitivity. Then change the cash assumption: the proxy's history stays fixed, while mixed-portfolio outcomes and the Sharpe benchmark change. The next section returns to bond sensitivity and makes the yield-quotation convention explicit.
Additional Insights: Modified Duration
The earlier duration example used a nominal annual YTM compounded at coupon frequency. Close the chapter by repeating the sensitivity calculation with a continuously compounded annual yield, denoted
Hold the valuation date and promised cash flows fixed. For payments
Duration Revisited
Differentiate with respect to the annual continuous quote, not a coupon-period rate:
These derivative identities are exact. For a finite yield change, however, the duration estimate is only first order:
A Continuous-Yield Worked Example
Use a three-year bond with face value 100 dollars and a 10% annual coupon paid semiannually. It pays five coupons of 5 dollars and a final payment of 105 dollars. The discount yield is 12% continuously compounded annually; the coupon rate still determines the contractual payment amounts.
Run the chapter imports, then price the dated payments directly:
modified_terms = {"principal": 100, "maturity": 3, "coupon_rate": 0.10, "coupons_per_year": 2}
continuous_yield = 0.12
modified_flows = pok.bond_cash_flows(**modified_terms)
modified_times = modified_flows.index.to_numpy() / modified_terms["coupons_per_year"]
modified_pvs = modified_flows[0].to_numpy() * np.exp(-continuous_yield * modified_times)
modified_price = modified_pvs.sum()
modified_weights = modified_pvs / modified_price
modified_mac = (modified_times * modified_weights).sum()
print(pd.DataFrame({"Payment year": modified_times, "Payment": modified_flows[0].to_numpy(),
"PV": modified_pvs, "PV weight": modified_weights,
"Duration contribution": modified_times * modified_weights}).to_string(index=False))
print(f"Price: {modified_price:.6f}; Macaulay duration: {modified_mac:.6f} years") The price is approximately 94.213021 dollars, and Macaulay duration is 2.653010 years. Under this continuous quotation, the same 2.653010 is the relative price sensitivity to a unit change in the annual yield.
Dollar Sensitivity and Basis Points
Here dollar duration means
One bp is 0.0001, or 0.01 percentage point. A move from 12% to 12.1% is +10 bp, so
modified_slope = -modified_price * modified_mac
modified_dv01 = -modified_slope * 1e-4
modified_measures = pok.bond_sensitivity(ytm=continuous_yield, quote="continuous", **modified_terms)
print(f"Price derivative per unit of continuous yield: {modified_slope:.6f}")
print(f"DV01: {modified_dv01:.6f} dollars per 100 face value")
print(pd.Series(modified_measures).to_string(float_format=lambda value: f"{value:.6f}")) The derivative is approximately −249.948089 dollars per unit yield change, and DV01 is 0.024995 dollars. These are local sensitivities; DV01 is not an exact finite price difference. Changing face value scales price and dollar sensitivity, while duration is unchanged.
Compare Exact Repricing with the Tangent
Revalue the same cash flows at the changed yield. Keep the original price and duration as the approximation's base rather than overwriting them:
modified_shifts_bps = np.array([-100, -10, -1, 0, 1, 10, 100])
modified_yield_changes = modified_shifts_bps / 10000
modified_exact_prices = (modified_flows[0].to_numpy() * np.exp(
-(continuous_yield + modified_yield_changes[:, None]) * modified_times,
)).sum(axis=1)
modified_linear_prices = modified_price + modified_slope * modified_yield_changes
modified_repricing = pd.DataFrame({
"Exact price": modified_exact_prices, "Duration estimate": modified_linear_prices,
"Exact minus estimate": modified_exact_prices - modified_linear_prices,
}, index=pd.Index(modified_shifts_bps, name="Continuous yield change (bp)"))
print(modified_repricing.to_string(float_format=lambda value: f"{value:.6f}")) For +10 bp, duration predicts a price change of −0.249948 dollars and a new price of 93.963072. Exact repricing gives 93.963429, an error of approximately +0.000356 dollars. Agreement to three decimal places in this example is not a general accuracy guarantee.
For positive future cash flows,
Reconcile Continuous and Nominal Quotes
The equivalent nominal annual yield compounded
Thus the earlier nominal-yield formula and the continuous-yield result agree; they differentiate with respect to different quote variables. Macaulay duration remains the same for equivalent valuations.
modified_frequency = modified_terms["coupons_per_year"]
modified_nominal_quote = modified_frequency * np.expm1(continuous_yield / modified_frequency)
modified_effective_quote = np.expm1(continuous_yield)
modified_year_flows = modified_flows.copy()
modified_year_flows.index = modified_times
modified_quote_prices = pd.Series({
"Continuous discounting": modified_price,
"Equivalent nominal quote": pok.bond_price(ytm=modified_nominal_quote, **modified_terms),
"Equivalent effective quote": pok.present_value(modified_year_flows, modified_effective_quote).iloc[0],
})
modified_nominal_measures = pok.bond_sensitivity(ytm=modified_nominal_quote, quote="nominal", **modified_terms)
modified_shifted_nominal = modified_frequency * np.expm1((continuous_yield + 0.001) / modified_frequency)
print(modified_quote_prices.to_string(float_format=lambda value: f"{value:.6f}"))
print(f"Equivalent nominal annual quote: {modified_nominal_quote:.6%}")
print(f"Nominal-quote modified duration: {modified_nominal_measures['Modified duration']:.6f}")
print(f"A continuous +10 bp move maps to {(modified_shifted_nominal - modified_nominal_quote) * 10000:.6f} nominal bp") All three prices are 94.213021. The equivalent semiannual nominal quote is approximately 12.367309%, not 12%. A continuous +10 bp move corresponds to approximately +10.621020 nominal bp here. Applying the same numeric 10-bp change to both quotation systems would describe different repricings.
Interactive Quote-Aware Sensitivity
The existing duration experiment now supports both quote conventions. The first panel shows present-value timing weights; the second compares exact repricing with the local duration line. The output states the quote basis, DV01, and selected price error. Both calculations hold payment dates fixed: this is an instantaneous valuation sensitivity, not a coupon-inclusive holding-period return or a general yield-curve hedge.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_duration_echart,
principal=widgets.IntSlider(value=100, min=100, max=1000, step=100, description="Face value", continuous_update=False),
maturity=widgets.IntSlider(value=3, min=1, max=15, description="Years", continuous_update=False),
coupon_rate=widgets.FloatSlider(value=0.10, min=0.0, max=0.15, step=0.01, description="Coupon rate", readout_format=".1%", continuous_update=False),
coupons_per_year=widgets.Dropdown(options=[("Annual", 1), ("Semiannual", 2), ("Quarterly", 4), ("Monthly", 12)], value=2, description="Coupons"),
ytm=widgets.FloatSlider(value=0.12, min=-0.02, max=0.20, step=0.005, description="Yield quote", readout_format=".1%", continuous_update=False),
shift_bps=widgets.IntSlider(value=10, min=-300, max=300, step=10, description="Yield shift bp", continuous_update=False),
quote=widgets.Dropdown(options=[("Continuous annual", "continuous"), ("Nominal annual", "nominal")], value="continuous", description="Convention"),
)
widget_instance Try it: keep continuous quoting and compare +10 bp with +300 bp, then reverse the sign. Inspect the positive exact-minus-linear error. Increase face value and confirm that the price and DV01 scale while durations stay fixed. Set the coupon rate to zero: Macaulay duration equals maturity, but finite repricing remains nonlinear. Switching the convention without converting the displayed yield changes the discount curve; use the preceding conversion when comparing equivalent quotes.
Across asset–liability management, payment timing and rate conventions determine valuation, while allocation rules determine how exposures evolve. Funding adequacy, terminal promises, and drawdown objectives are separate questions; a local sensitivity measure or a successful sample path does not resolve all of them.
Continue with Forward Contracts to apply financing and discounting to a future purchase agreement. Derivatives, Options, and Structured Products develops this into options and structured products. For the estimation questions behind portfolio allocation, continue with Advanced Portfolio Construction and Analysis.
