Strategies Beyond Diversification
The preceding chapter chose portfolio weights using estimated means and covariances. We now ask what diversification can achieve when many assets share the same market exposure, and how that opportunity changes over time.
We begin with 30 industry portfolios, construct an explicitly timed market proxy, and compare rolling growth with rolling correlations. Later sections introduce dynamic risk budgeting through Constant Proportion Portfolio Insurance (CPPI) and use Geometric Brownian Motion to explore possible outcomes. Diversification and dynamic allocation address different investment questions; neither, by itself, guarantees protection from losses.
The chapter follows four connected questions:
| Investment question | What we use to answer it |
|---|---|
| Why can a diversified portfolio still suffer a large loss? | A correlation model and historical industry returns. |
| How can exposure respond as wealth approaches a capital floor? | Fixed-floor CPPI and a loss-and-recovery example. |
| How can the floor reflect gains already earned? | A high-water-mark floor, compared at the same multiplier. |
| Would the policy behave differently on other paths? | Exact GBM simulations and Monte Carlo breach diagnostics. |
What you'll learn
- Distinguish asset-specific risk from shared systematic exposure.
- Build a portfolio return series using weights available before each return period.
- Interpret rolling growth and correlations without treating an association as a trading rule.
- Explore dynamic floors, risk budgets, and simulated investment paths in the later sections.
Run these imports and the short chart helpers once, then execute the examples in order.
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok Chart Preparation
Incomplete rolling windows have no estimate. These chart helpers represent them as JSON null, preserving a gap rather than drawing a zero return or zero correlation.
The calculation cells expose the investment inputs and equations. The chart dictionaries only describe their display; you do not need to modify that configuration to perform the suggested experiments.
def convert_nan_to_none(sequence):
"""Replace NaN with None so JSON payloads serialise cleanly."""
return [None if pd.isna(value) else value for value in sequence]
def df_to_series_dict(df):
"""Map each column of a DataFrame to a list ready for charting."""
return {col: convert_nan_to_none(df[col].values.tolist()) for col in df.columns} Constraints of Portfolio Diversification
Diversification can reduce exposure to an individual company's or industry's shocks. It does not automatically remove a common exposure held by all constituents. This shared exposure is systematic risk; systemic risk refers to disruption of the financial system itself. A broad equity portfolio can therefore suffer a substantial market loss even when its asset-specific risks are well diversified.
To see the mechanism, assume
The second term shrinks as the number of assets grows, but the common-covariance term remains. In this model, volatility approaches
Hand check. With 25 assets, each at 20% volatility, correlation 0.25 gives
asset_counts = np.array([1, 5, 10, 25, 100])
asset_volatility = 0.20
diversification_risk = pd.DataFrame({
f"Correlation {rho:.2f}": asset_volatility * np.sqrt(rho + (1 - rho) / asset_counts) * 100
for rho in (0.0, 0.25, 0.80, 1.0)
}, index=asset_counts)
diversification_risk.index.name = "Number of assets"
print("Portfolio volatility (%):")
print(diversification_risk.to_string(float_format=lambda value: f"{value:.2f}")) Try it: compare the 25-asset and 100-asset rows. At zero correlation, volatility falls with
Industry Data and Measurement
Use the bundled snapshot of Kenneth French's 30 Industry Portfolios. It contains 1,110 monthly observations, July 1926–December 2018. The source assigns stocks to industries using industry classification codes; its current online dataset extends beyond this stored snapshot.
The examples load ind30_m_vw_rets.csv, ind30_m_nfirms.csv, and ind30_m_size.csv through the toolkit's data directory. ew=False selects value-weighted returns within each industry, not equal-weighted returns. Return percentages are converted to decimals, while firm counts and reported average sizes retain their original units. The loader treats the source's missing-value sentinels as missing observations.
nind = 30
ind_rets = pok.get_ind_file(filetype="rets", nind=nind, ew=False)
ind_nfirms = pok.get_ind_file(filetype="nfirms", nind=nind)
ind_size = pok.get_ind_file(filetype="size", nind=nind)
print(f"Monthly observations: {len(ind_rets)}; industries: {ind_rets.shape[1]}")
print(f"Coverage: {ind_rets.index[0]} to {ind_rets.index[-1]}")
preview_industries = ["Food", "Beer", "Steel", "Fin"]
print(ind_rets[preview_industries].head(3)) Each column is an industry portfolio's monthly return, not an individual stock's return. Market capitalization is equity market value: shares outstanding multiplied by share price. For example, 100,000 shares at $20.30 give $2,030,000 of market capitalization. It is not the firm's enterprise value.
ind_nfirms records the number of companies in each industry:
print(ind_nfirms[preview_industries].head(3)) The July 1926 row contains 43 Food firms and 3 Beer firms. Industry breadth varies considerably; an industry portfolio is not necessarily a broadly diversified market portfolio.
ind_size contains average firm market capitalization within each industry:
print(ind_size[preview_industries].head(3)) The corresponding averages are 35.98 for Food and 7.12 for Beer. The extracted size CSV does not include a currency-scale header, so we display reported size units rather than attach a dollar sign. A common unit conversion would not change the capitalization weights below. All three bundled tables have matching monthly dates and industry columns and contain no missing observations in this snapshot.
Formulating the Index
Let
For Food in July 1926,
ind_mkt_cap = ind_nfirms * ind_size
print(ind_mkt_cap[preview_industries].head(3)) Sum across industries to obtain the universe's implied total capitalization,
total_mkt_cap = ind_mkt_cap.sum(axis=1, min_count=nind)
print(total_mkt_cap.head()) The capitalization fraction is
ind_cap_weights = ind_mkt_cap.divide(total_mkt_cap, axis=0)
print(ind_cap_weights[preview_industries].head(3)) July 1926's total is 26,657.94 reported units. Food accounts for approximately 5.80% and Beer for 0.0801%. Distinguish weighting stocks within an industry from weighting industry portfolios within the combined portfolio: they are two separate allocation levels.
Plot the capitalization totals and selected industry shares. Convert the shares to percentages for their chart:
import json
x_data = total_mkt_cap.index.strftime('%Y-%m').tolist()
total_market_cap_values = convert_nan_to_none(total_mkt_cap.tolist())
selected_sectors = ["Steel", "Fin", "Telcm"]
sector_series = df_to_series_dict(ind_cap_weights[selected_sectors] * 100)
plot_data = {
"dates": x_data,
"totalMarketCap": {
"title": "Implied Capitalization of the Industry Universe",
"series": {"Implied capitalization": total_market_cap_values},
"type": "line",
"yAxis": {"type": "log", "name": "Capitalization (reported units)"}
},
"sectorWeights": {
"title": "Selected Industries: Shares of Total Capitalization",
"series": sector_series,
"type": "line",
"yAxisName": "Weight (%)"
}
}
output = "\n<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)
print(output) The charts span July 1926–December 2018. Total capitalization uses a logarithmic scale to show proportional changes across a long history. For a concrete comparison, Finance's share rises from approximately 1.54% in January 1929 to 16.31% in December 2018, while Steel's falls from 8.45% to 0.21%. Changing shares can reflect relative performance, issuance, listings, and changes in industry membership.
From Capitalization Weights to Portfolio Returns
Portfolio return must use weights chosen before the return is earned. In this exercise, make each dated capitalization row available after that month and use it to set next month's industry weights:
This is an explicit one-month-lagged industry-market proxy, not a reproduction of an official market index. The extracted files do not establish the size observations' precise within-month timing or historical release availability. Lagging makes the exercise's information convention explicit; it does not establish a point-in-time investable backtest.
Hand check. Beginning weights of 60% and 40%, followed by industry returns of 10% and −5%, give
import json
beginning_weights = ind_cap_weights.shift(1)
total_market_return = (beginning_weights * ind_rets).sum(axis=1, min_count=nind).iloc[1:]
capital = 1000
total_market_index = capital * (1 + total_market_return).cumprod(skipna=False)
initial_month = total_market_return.index[0] - 1
x_data = [str(initial_month)] + total_market_index.index.astype(str).tolist()
total_market_index_values = [capital] + convert_nan_to_none(total_market_index.tolist())
total_market_return_values = [None] + convert_nan_to_none((total_market_return * 100).tolist())
print(f"First proxy return ({total_market_return.index[0]}): {total_market_return.iloc[0]:.4%}")
plot_data = {
"dates": x_data,
"totalMarketIndex": {
"title": "Wealth of the Lagged Industry-Market Proxy",
"series": {"Lagged industry proxy": total_market_index_values},
"type": "line",
"yAxis": {"type": "log", "name": "Wealth (initial 1,000)"}
},
"totalMarketReturn": {
"title": "Monthly Returns of the Industry-Market Proxy",
"series": {"Monthly proxy return": total_market_return_values},
"type": "line",
"yAxisName": "Monthly return (%)"
}
}
output = "\n<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)
print(output) July 1926 supplies the first allocation, so the first proxy return is August 1926, approximately 2.9000%. The wealth chart includes the initial 1,000 at July's end; the return chart leaves that starting observation blank. Only the first row is removed from the return calculation because its prior weights are unavailable. Any later missing component remains missing, and unknown returns prevent continuing the compounded wealth path.
Capitalization growth and investment growth are different quantities. New share issuance, changing membership, and distributions can make the capitalization series diverge from a compounded investor wealth series. Next, use returns—not capitalization changes—to study rolling investment outcomes.
Analyzing Rolling Returns
A moving average of wealth levels smooths the path; it is not a portfolio return. For a window of
Longer windows react more slowly to recent changes. Calculate the moving averages using the full available history, then display the period from 1990 onward. Cutting the history first would unnecessarily discard the observations needed for the first displayed averages.
import json
moving_averages = pd.DataFrame({"Industry proxy": total_market_index})
for months in (60, 36, 12):
moving_averages[f"{months}-month average"] = total_market_index.rolling(months, min_periods=months).mean()
display_averages = moving_averages.loc["1990":]
x_data = display_averages.index.astype(str).tolist()
plot_data = {
"dates": x_data,
"totalMarketIndexMA": {
"title": "Wealth Levels and Their Moving Averages",
"yAxis": {"type": "log", "name": "Wealth (initial 1,000)"},
"series": df_to_series_dict(display_averages),
"type": "line",
},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) For investment growth, compound the returns within each trailing window. Distinguish the cumulative return over the window from its annualized geometric growth rate:
For example, 1% each month for 36 months gives cumulative growth
rolling_months = 36
rolling_growth = (1 + total_market_return).rolling(rolling_months, min_periods=rolling_months).apply(np.prod, raw=True)
trailing_cumulative = rolling_growth - 1
tmi_trail_36_rets = rolling_growth ** (12 / rolling_months) - 1
growth_comparison = pd.DataFrame({
"Window cumulative return (%)": trailing_cumulative * 100,
"Annualized growth (%)": tmi_trail_36_rets * 100,
})
print(growth_comparison.tail().to_string(float_format=lambda value: f"{value:.2f}"))
plot_data = {
"dates": tmi_trail_36_rets.index.astype(str).tolist(),
"rollingGrowth": {
"type": "line",
"title": f"Trailing {rolling_months}-Month Annualized Growth",
"yAxisName": "Annualized geometric return (%)",
"series": {"Industry proxy": convert_nan_to_none((tmi_trail_36_rets * 100).tolist())},
},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) The first 35 proxy observations cannot supply a 36-month growth estimate. At December 2018, the trailing cumulative return is approximately 31.39%, versus 9.53% annualized. The chart shows only the annualized measure, keeping its horizon and units consistent.
Calculating Rolling Correlations
At each month-end, estimate correlations using the same trailing number of monthly industry returns. These are correlations among monthly observations inside the window, not correlations of already compounded 36-month returns.
Run the rolling-growth cell first. It sets rolling_months and the annualized growth series used below. Keep one window length for both calculations, then run the following three cells in order: correlation matrices, average-pair charts, and the relationship statistic.
rets_trail_36_corr = ind_rets.rolling(rolling_months, min_periods=rolling_months).corr()
rets_trail_36_corr.index.names = ["date", "industry"]
print(rets_trail_36_corr.loc[pd.Period("2008-12", freq="M"), ["Food", "Steel", "Fin"]]
.loc[["Food", "Steel", "Fin"]].round(3)) The displayed entries belong to December 2008's trailing correlation matrix. Its diagonal is one for nonconstant observed series. To measure cross-industry co-movement, average only the distinct pairs:
For 30 industries there are
Select the matrix's upper triangle, excluding its diagonal, and align the resulting series with rolling growth. Require complete windows and defined correlations for every pair; a constant industry return series or missing pair leaves that window unavailable. The average is unweighted across industry pairs; it does not by itself determine a cap-weighted portfolio's variance, which also depends on volatilities and weights.
import json
import pandas as pd
pair_mask = np.triu(np.ones((nind, nind), dtype=bool), k=1)
ind_trail_36_corr = rets_trail_36_corr.groupby(level="date").apply(
lambda matrix: matrix.to_numpy()[pair_mask].mean()
)
rolling_comparison = pd.concat({
"Annualized growth": tmi_trail_36_rets,
"Average correlation": ind_trail_36_corr,
}, axis=1).dropna()
x_data = rolling_comparison.index.astype(str).tolist()
plot_data = {
"dates": x_data,
"rollingReturn": {
"type": "line",
"title": f"Trailing {rolling_months}-Month Annualized Growth",
"yAxisName": "Annualized geometric return (%)",
"series": {"Industry proxy": (rolling_comparison["Annualized growth"] * 100).tolist()},
},
"rollingCorrelation": {
"type": "line",
"title": f"Trailing {rolling_months}-Month Average Pair Correlation",
"yAxis": {"type": "value", "name": "Correlation", "min": -1, "max": 1},
"series": {"Industry pairs": rolling_comparison["Average correlation"].tolist()},
},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) The two panels share dates but retain separate units: percentage growth and dimensionless correlation. Finally, calculate the sample Pearson correlation between these two rolling summaries:
growth_correlation_relationship = rolling_comparison["Annualized growth"].corr(
rolling_comparison["Average correlation"]
)
print(f"Paired window endpoints: {len(rolling_comparison)}")
print(f"Coverage: {rolling_comparison.index[0]} to {rolling_comparison.index[-1]}")
print(f"Correlation between rolling growth and average pair correlation: {growth_correlation_relationship:.3f}") With the default 36-month window and lagged proxy, there are 1,074 paired endpoints, July 1929–December 2018, and the sample correlation is approximately −0.286. This negative association is a result for this construction and sample, not a mathematical requirement or a prediction of the next month's return.
Interpretation: Rolling Returns vs. Correlations
- Shared exposure remains important. At December 2008, trailing annualized proxy growth is approximately −8.24%, while average industry-pair correlation is 0.631. This is substantial co-movement, but not perfect correlation. Diversification can still help while failing to prevent an absolute loss.
- A rolling estimate is backward-looking. With a 36-month window, adjacent estimates share 35 months. The 1,074 endpoints are therefore not 1,074 independent observations, and the displayed association does not establish causality or validate a sector-rotation strategy.
- Risk depends on more than average correlation. Volatility, concentration, factor exposure, and the holding horizon also matter. Test portfolio outcomes under different covariance assumptions rather than assume that historical diversification benefits will persist unchanged.
Try it: change rolling_months to 12, then 60, in the growth cell and rerun the correlation cells. Shorter windows respond faster but are noisier; longer windows mix more market conditions. Neither choice converts these descriptive statistics into a guaranteed loss limit. The next section studies dynamic risk budgets through CPPI.
Strategies for Risk Mitigation
Diversification changes which risks a portfolio holds. Dynamic allocation also changes how much risky exposure it holds over time. CPPI uses the distance between current wealth and a specified floor to set that exposure. The important questions are how the rule reacts to gains and losses, and under what conditions the floor can fail.
Constant Proportion Portfolio Insurance (CPPI)
CPPI allocates between a risky asset and a reserve asset, modeled here as cash with a known periodic return. A bond fund is not automatically an equivalent reserve: its market value can fall. CPPI seeks option-like downside protection through rebalancing rather than purchasing a contractual guarantee.
Let
We first use a fixed nominal floor,
Risky Asset Allocation
For positive account value, distinguish the dollar cushion from the cushion fraction,
Here
The weights are chosen before those returns are known. Recompute the cushion and rebalance at the next date. With a fixed floor, gains generally permit more risky investment, while losses reduce it. If wealth reaches or falls below the floor, the rule sets risky exposure to zero; it does not restore lost capital.
Worked path. Start with 1,000, floor 900, multiplier 4, and zero cash interest:
- The initial cushion is 100, so invest 400 in the risky asset and 600 in cash.
- A 20% risky loss leaves
. The new cushion is 20, so the next risky investment is only . - A subsequent 25% risky gain leaves
. The risky asset itself has recovered to its starting value, but CPPI has not, because it reduced exposure after the loss.
scenario_returns = pd.Series([-0.20, 0.25], name="Risky")
worked_cppi = pok.cppi(
scenario_returns, start_value=1000, floor=0.9, m=4, risk_free_rate=0.0
)
worked_path = pd.DataFrame({
"Risky return (%)": scenario_returns * 100,
"Beginning risky weight (%)": worked_cppi["Risky allocation"]["Risky"] * 100,
"CPPI return (%)": worked_cppi["CPPI returns"]["Risky"] * 100,
"Ending wealth": worked_cppi["CPPI wealth"]["Risky"],
"Floor breached": worked_cppi["Floor breaches"]["Risky"],
})
print(worked_path.to_string(float_format=lambda value: f"{value:.4f}")) The Floor-Breach Condition
Suppose the cushion is positive, the risky allocation is uncapped at
Thus the floor is maintained over that interval exactly when
The bound is conditional on the interval's loss. The risky asset is not guaranteed to respect it. Capping risky investment at the account value can reduce exposure below
Try it: replace the first worked return with -0.30. Wealth falls to 880, below the 900 floor. The next risky weight is zero, and the subsequent risky recovery does not repair the loss when cash earns zero. Even landing exactly on the floor can leave the investor in cash and missing a recovery.
Executing CPPI with a Fixed Floor
Run the chapter imports and industry-loading cell first. The market proxy above summarized the industry's shared environment; we now use individual industry return paths to see how the same allocation rule responds to different experiences. Steel, Finance, and Beer are illustrations, not an optimized selection. Each column represents an independent account holding one risky industry portfolio plus cash, not one portfolio combining all three industries.
cppi_industries = ["Steel", "Fin", "Beer"]
cppi_start = "2000-01" Use Steel, Finance, and Beer from January 2000 through the snapshot's December 2018 endpoint: 228 monthly returns per account.
risky_rets = ind_rets.loc[cppi_start:, cppi_industries]
print(f"{len(risky_rets)} monthly returns: {risky_rets.index[0]} to {risky_rets.index[-1]}") Model cash at a constant 3% effective annual rate, converted to a monthly return
risk_free_rate = 0.03
periods_per_year = 12
safe_monthly_rate = np.expm1(np.log1p(risk_free_rate) / periods_per_year)
safe_rets = pd.DataFrame(safe_monthly_rate, index=risky_rets.index, columns=risky_rets.columns) Start each account at 1,000 with a fixed floor of 800 and multiplier 3. Initial risky exposure is
start_value = 1000
floor = 0.8
floor_value = floor * start_value
m = 3 The comparison benchmark invests the same initial capital entirely in each risky industry:
risky_wealth = start_value * (1 + risky_rets).cumprod()
print(risky_wealth.head()) The toolkit repeats the cushion, allocation, and wealth-update equations for every month. Its return history includes January 2000, the first investment period, so both strategies use identical dates and starting capital. Calling it again restarts the backtest from the stated initial wealth.
fixed_result = pok.cppi(risky_rets, safe_rets=safe_rets, start_value=start_value, floor=floor, m=m)
account_history = fixed_result["CPPI wealth"]
cppi_rets = fixed_result["CPPI returns"]
cushion_history = fixed_result["Cushions"]
risky_w_history = fixed_result["Risky allocation"] Compare compounded annual growth, annualized volatility, arithmetic-excess-return Sharpe, and maximum drawdown. Keep the industry labels and use the same cash benchmark and monthly annualization for both strategies:
comparison = pd.concat({
"100% risky": pok.summary_stats(risky_rets, risk_free_rate=risk_free_rate, periods_per_year=periods_per_year),
"Fixed-floor CPPI": pok.summary_stats(cppi_rets, risk_free_rate=risk_free_rate, periods_per_year=periods_per_year),
}, names=["Strategy", "Industry"])
comparison_display = pd.DataFrame({
"CAGR (%)": comparison["Ann. return"] * 100,
"Annualized volatility (%)": comparison["Ann. vol"] * 100,
"Sharpe": comparison["Sharpe ratio"],
"Maximum drawdown (%)": -comparison["Max Drawdown"] * 100,
})
print(comparison_display.to_string(float_format=lambda value: f"{value:.3f}")) Maximum drawdown is displayed as a positive loss magnitude from a prior peak, including initial capital. It measures something different from a breach of the fixed 800 floor. The table describes these historical paths; it does not establish an expected future advantage for CPPI.
Plot the three independent CPPI accounts with their starting capital and shared fixed floor:
import json
cppi_dates = [str(risky_rets.index[0] - 1)] + risky_rets.index.astype(str).tolist()
series_data = {
col: [start_value] + account_history[col].tolist()
for col in account_history.columns
}
series_data["Fixed floor"] = [floor_value] * len(cppi_dates)
plot_data = {
"dates": cppi_dates,
"accountHistory": {
"title": "Independent Fixed-Floor CPPI Accounts",
"yAxis": {"type": "value", "name": "Wealth ($)"},
"series": series_data,
"type": "line",
},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) For each industry, compare CPPI wealth with the fully risky benchmark and inspect the risky weight used during each month. The first chart observation is December 1999's initial capital. The weight series starts in January 2000; its values are chosen at the start of the labeled month, while wealth is measured at month-end.
import json
charts = {"dates": cppi_dates}
for sector in risky_rets.columns:
charts[f"{sector}_Cppi"] = {
"type": "line",
"title": f"{sector}: CPPI and Fully Risky Wealth",
"yAxisName": "Wealth ($)",
"series": {
"CPPI": [start_value] + account_history[sector].tolist(),
"100% risky": [start_value] + risky_wealth[sector].tolist(),
"Fixed floor": [floor_value] * len(cppi_dates),
},
}
charts[f"{sector}_Weight"] = {
"type": "line",
"title": f"{sector}: Risky Weight Used During Each Month",
"yAxis": {"type": "value", "name": "Risky allocation (%)", "min": 0, "max": 100},
"series": {"Risky weight": [None] + (risky_w_history[sector] * 100).tolist()},
}
print("<ECHARTS_DATA>" + json.dumps(charts, allow_nan=False)) For Beer, the sample CAGR is approximately 8.06% fully risky versus 7.44% with CPPI. Holding cash reduces participation in gains when risky returns exceed cash returns, and selling after losses can delay recovery. The rule is path-dependent: two risky paths with similar terminal wealth need not produce similar CPPI outcomes.
Check the floor directly rather than infer protection from the chart's appearance:
floor_diagnostics = pd.DataFrame({
"Minimum wealth": account_history.min().clip(upper=start_value),
"Fixed floor": floor_value,
"Month-ends below floor": fixed_result["Floor breaches"].sum(),
"Maximum drawdown (%)": comparison_display.loc["Fixed-floor CPPI", "Maximum drawdown (%)"],
})
print(floor_diagnostics.to_string(float_format=lambda value: f"{value:.2f}")) With these inputs, none of the three accounts has an observed month-end floor breach. Steel's minimum wealth is approximately 807.69, yet its maximum drawdown is approximately 65.41%. A fixed 800 floor can permit a large decline from a much higher prior peak. It is therefore not evidence of a 20% drawdown cap or a guarantee about losses between observations.
Try it: change m to 2, then 5, in the parameter cell and rerun the backtest and comparison cells. Initial risky weights become 40% and 100%, respectively. Compare terminal wealth, drawdown, and floor breaches; a larger multiplier increases initial participation but also exposure to a sudden loss. The next section replaces the fixed floor with a floor linked to the account's high-water mark.
Implementing the Dynamic Drawdown Constraint
The fixed floor protects a fraction of initial capital. To target losses relative to accumulated wealth instead, let the floor rise with the account's high-water mark. At rebalancing date
where
At a new peak,
The drawdown target does not mathematically determine the multiplier. The toolkit adopts the particular policy drawdown is supplied, replacing both the supplied m and the fixed-floor rule. Consequently, all these reciprocal-multiplier strategies begin at 100% risky exposure, including after each new account peak. Use the returned "m" value when describing the strategy.
Worked path. Start at 1,000 with target
- The initial floor is 800 and risky investment is 1,000. A 10% risky gain takes wealth to 1,100; the next floor rises to 880.
- The account is at a new peak, so it again holds 100% risky. A 10% loss reduces wealth to 990, a 10% drawdown. The peak stays at 1,100 and the floor stays at 880.
- The next risky investment is
, or 55.56% of the account. A 10% risky gain leaves , a 5% drawdown from the peak.
ratchet_returns = pd.Series([0.10, -0.10, 0.10], index=[1, 2, 3], name="Risky")
ratchet_example = pok.cppi(ratchet_returns, start_value=1000, drawdown=0.20, risk_free_rate=0.0)
ratchet_wealth = ratchet_example["CPPI wealth"]["Risky"]
ratchet_peaks = ratchet_wealth.cummax().clip(lower=1000)
ratchet_path = pd.DataFrame({
"Floor used": ratchet_example["Floor value"]["Risky"],
"Risky weight (%)": ratchet_example["Risky allocation"]["Risky"] * 100,
"Ending wealth": ratchet_wealth,
"Next floor": 0.80 * ratchet_peaks,
"Drawdown (%)": (1 - ratchet_wealth / ratchet_peaks) * 100,
})
print(ratchet_path.to_string(float_format=lambda value: f"{value:.2f}")) The floor is fixed within each rebalancing interval. If risky exposure is
Try it: replace the second risky return with -0.25. Wealth falls from 1,100 to 825, below the raised 880 floor, with a 25% drawdown. Wealth still exceeds the original 800 floor, illustrating why fixed-floor and drawdown protection are different. The next risky allocation is zero, but the breach has already occurred.
Now reuse the historical risky-return and cash inputs from the fixed-floor section. Finance's large historical drawdown makes the distinction between the two floor rules visible; it remains an illustration, not an asset-selection recommendation. Focus on Finance and rebalance monthly with a 20% drawdown target:
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok
sector = "Fin"
drawdown_limit = 0.20
res = pok.cppi(
risky_rets[[sector]], safe_rets=safe_rets[[sector]], start_value=start_value,
drawdown=drawdown_limit, periods_per_year=periods_per_year,
)
dynamic_wealth = res["CPPI wealth"][sector]
ending_peaks = dynamic_wealth.cummax().clip(lower=start_value)
ending_floor = (1 - drawdown_limit) * ending_peaks
observed_drawdown = 1 - dynamic_wealth / ending_peaks
dynamic_dates = [str(dynamic_wealth.index[0] - 1)] + dynamic_wealth.index.astype(str).tolist()
print(f"Drawdown target: {drawdown_limit:.0%}; effective multiplier: {res['m']:.2f}")
dynamic_plot = {
"dates": dynamic_dates,
"wealthComparison": {
"type": "line",
"title": f"{sector}: High-Water-Mark CPPI",
"yAxisName": "Wealth ($)",
"series": {
"CPPI": [start_value] + dynamic_wealth.tolist(),
"100% risky": [start_value] + res["Risky wealth"][sector].tolist(),
"High-water mark": [start_value] + ending_peaks.tolist(),
"Floor for next allocation": [(1 - drawdown_limit) * start_value] + ending_floor.tolist(),
},
},
"drawdownHistory": {
"type": "line",
"title": "Observed Drawdown and Policy Target",
"yAxis": {"type": "value", "name": "Drawdown from peak (%)", "min": 0},
"series": {
"Observed drawdown": [0.0] + (observed_drawdown * 100).tolist(),
"Target": [drawdown_limit * 100] * len(dynamic_dates),
},
},
"riskyAllocation": {
"type": "line",
"title": "Risky Weight Used During Each Month",
"yAxis": {"type": "value", "name": "Risky allocation (%)", "min": 0, "max": 100},
"series": {"Risky weight": [None] + (res["Risky allocation"][sector] * 100).tolist()},
},
}
print("<ECHARTS_DATA>" + json.dumps(dynamic_plot, allow_nan=False)) The wealth chart shows the month-end peak and the floor set for the next allocation. The toolkit's "Peaks" and "Floor value" histories instead record information used at the beginning of each return period. Keeping this timing explicit prevents a new month-end high from influencing the allocation that earned that month's return.
To isolate the floor rule, compare the high-water strategy with a fixed-floor strategy using the same multiplier and initial floor. The previous section used
matched_fixed = pok.cppi(
risky_rets[[sector]], safe_rets=safe_rets[[sector]], start_value=start_value,
floor=1 - drawdown_limit, m=res["m"], periods_per_year=periods_per_year,
)
comparison_returns = pd.DataFrame({
"100% risky": risky_rets[sector],
"Fixed floor": matched_fixed["CPPI returns"][sector],
"High-water floor": res["CPPI returns"][sector],
})
dynamic_stats = pok.summary_stats(comparison_returns, risk_free_rate=risk_free_rate, periods_per_year=periods_per_year)
dynamic_summary = pd.DataFrame({
"CAGR (%)": dynamic_stats["Ann. return"] * 100,
"Annualized volatility (%)": dynamic_stats["Ann. vol"] * 100,
"Sharpe": dynamic_stats["Sharpe ratio"],
"Maximum drawdown (%)": -dynamic_stats["Max Drawdown"] * 100,
})
print(f"Both CPPI strategies use multiplier {res['m']:.2f}.")
print(dynamic_summary.to_string(float_format=lambda value: f"{value:.3f}"))
print(f"\nHigh-water strategy: month-ends below the applied floor = {res['Floor breaches'][sector].sum()}") For Finance over January 2000–December 2018, the high-water strategy has approximately 5.79% CAGR and 19.83% maximum drawdown, with no observed month-end floor breaches. The matched fixed-floor strategy has approximately 3.54% CAGR and 55.70% maximum drawdown. These are realized sample results, not promised outcomes; the worked jump example shows that the 20% target can be exceeded.
Finally, compare targets of 20%, 40%, and 60% using the toolkit's reciprocal-multiplier policy. This varies a linked pair of choices—floor fraction and multiplier—not just the floor while holding the multiplier fixed. Store each result separately so the comparison does not replace the baseline res used above:
drawdowns = [0.20, 0.40, 0.60]
drawdown_results = {}
sensitivity_wealth = {"100% risky": [start_value] + res["Risky wealth"][sector].tolist()}
sensitivity_weights = {}
summary_rows = []
for limit in drawdowns:
scenario_result = pok.cppi(
risky_rets[[sector]], safe_rets=safe_rets[[sector]], start_value=start_value,
drawdown=limit, periods_per_year=periods_per_year,
)
drawdown_results[limit] = scenario_result
label = f"Target {limit:.0%}, m={scenario_result['m']:.2f}"
scenario_returns = scenario_result["CPPI returns"][sector]
sensitivity_wealth[label] = [start_value] + scenario_result["CPPI wealth"][sector].tolist()
sensitivity_weights[label] = [None] + (scenario_result["Risky allocation"][sector] * 100).tolist()
summary_rows.append({
"Target drawdown (%)": limit * 100,
"Multiplier": scenario_result["m"],
"CAGR (%)": pok.annualize_rets(scenario_returns, periods_per_year) * 100,
"Maximum drawdown (%)": -pok.drawdown(scenario_returns)["Drawdown"].min() * 100,
"Month-ends below floor": scenario_result["Floor breaches"][sector].sum(),
})
summary_df = pd.DataFrame(summary_rows).set_index("Target drawdown (%)")
print(summary_df.to_string(float_format=lambda value: f"{value:.3f}"))
sensitivity_plot = {
"dates": dynamic_dates,
"wealthByTarget": {"type": "line", "title": f"{sector}: Drawdown-Target Policies",
"yAxisName": "Wealth ($)", "series": sensitivity_wealth},
"weightsByTarget": {"type": "line", "title": "Risky Weight Used During Each Month",
"yAxis": {"type": "value", "name": "Risky allocation (%)", "min": 0, "max": 100},
"series": sensitivity_weights},
}
print("<ECHARTS_DATA>" + json.dumps(sensitivity_plot, allow_nan=False)) The three policies have effective multipliers 5, 2.5, and 1.67, but all start 100% risky because
Try it: switch sector to "Steel" in the first historical dynamic-floor cell and rerun the three historical examples. Then change drawdown_limit to 0.10 and inspect the reported multiplier, initial allocation, and breach count. A tighter target also raises the multiplier under this policy, making a large loss immediately after a new peak especially important. Monthly results cannot establish that the floor held throughout each month.
High-water CPPI changes exposure according to the path already realized. To explore how it behaves on other possible paths, the next section introduces stochastic return models and simulation.
This is a change of question, not a forecast fitted to the three industries: the historical examples describe one realized record, while the simulations test a chosen model across alternative paths. The CPPI allocation rule remains the same.
Generating Random Walks with Geometric Brownian Motion
Wiener Process Overview
A standard Wiener process, or Brownian motion, models cumulative random shocks. It starts at
Its paths are continuous but almost surely nowhere differentiable: continuity does not mean smoothness. Consequently,
For
Geometric Brownian Motion (GBM)
With time measured in years and
Let
Here
Returns
Over a finite interval, holding the diffusion coefficients at the interval's start gives the Euler approximation:
This approximating simple return is Gaussian, with mean
Log-Returns and Price Evolution
For an exact transition, apply Itô's lemma to
Integrating over an interval gives the exact log return under constant-parameter GBM:
The corresponding simple return is
These prices are strictly positive in the mathematical model. Exact sampling at monthly endpoints does not reveal the path's movements between those endpoints, which matters when checking CPPI floor breaches.
A One-Period Calculation
Take annual drift 7%, volatility 15%, starting price 100, and a monthly interval. For a standardized shock
The ending prices are approximately 104.9135 with Euler and 104.9377 with exact GBM. The log return is not the simple return and must not be passed directly to a function that compounds
gbm_mu = 0.07
gbm_sigma = 0.15
gbm_frequency = 12
gbm_start = 100.0
standardized_shock = 1.0
dt = 1 / gbm_frequency
euler_step_return = gbm_mu * dt + gbm_sigma * np.sqrt(dt) * standardized_shock
exact_step_log_return = (gbm_mu - 0.5 * gbm_sigma ** 2) * dt + gbm_sigma * np.sqrt(dt) * standardized_shock
exact_step_return = np.expm1(exact_step_log_return)
print(f"Euler simple return: {euler_step_return:.4%}")
print(f"Exact log return: {exact_step_log_return:.6f}; exact simple return: {exact_step_return:.4%}")
print(f"Ending prices: Euler {gbm_start * (1 + euler_step_return):.4f}; exact {gbm_start * np.exp(exact_step_log_return):.4f}") Try it: set gbm_frequency = 1, gbm_sigma = 0.50, and standardized_shock = -3. Euler gives a price of −43, while exact GBM gives approximately 21.12. The negative Euler value is an approximation failure, not an admissible price. The toolkit's Euler helper rejects such paths rather than silently altering them. Restore the monthly default inputs before continuing.
Drift, Expected Wealth, and Typical Growth
The lognormal distribution implies:
For a simple return over an interval of length
Thus
At the default parameters and a ten-year horizon, the model's mean terminal price is 201.38, while its median is 179.95. A small sample of paths need not resemble either population statistic closely.
Comparing Euler and Exact Paths
The toolkit provides two deliberately different interfaces:
simulate_gbm_from_returns()compounds Euler simple returns and returns prices plus those simple returns.simulate_gbm_from_prices()uses exact GBM transitions and returns prices plus log returns. Convert the latter withnp.expm1()before using simple-return portfolio analytics.
Run the imports and one-period cell first. Use the same seed, grid, and scenario count for both methods so each paired path receives identical standard-normal shocks. These are ten alternative paths for one model, not ten correlated assets. The initial price is a time-zero observation, not an additional return period.
gbm_years = 10
gbm_scenarios = 10
gbm_seed = 42
gbm_inputs = {
"n_years": gbm_years, "n_scenarios": gbm_scenarios,
"mu": gbm_mu, "sigma": gbm_sigma, "periods_per_year": gbm_frequency,
"start": gbm_start, "seed": gbm_seed,
}
prices_1, rets_1 = pok.simulate_gbm_from_returns(**gbm_inputs)
prices_2, log_rets_2 = pok.simulate_gbm_from_prices(**gbm_inputs)
exact_simple_returns = np.expm1(log_rets_2)
elapsed_years = prices_1.index.to_numpy() / gbm_frequency
model_terminal_mean = gbm_start * np.exp(gbm_mu * gbm_years)
model_terminal_median = gbm_start * np.exp((gbm_mu - 0.5 * gbm_sigma ** 2) * gbm_years)
print(f"Exact model terminal mean: {model_terminal_mean:.2f}; median: {model_terminal_median:.2f}")
print(pd.DataFrame({"Euler terminal price": prices_1.iloc[-1],
"Exact terminal price": prices_2.iloc[-1]})
.to_string(float_format=lambda value: f"{value:.2f}"))
plot_data = {}
for key, title, prices in (
("pricesCompounding", "Euler Approximation: Shared Shocks", prices_1),
("pricesEquation", "Exact GBM: Shared Shocks", prices_2),
):
plot_data[key] = {
"type": "line", "title": title,
"legend": {"type": "scroll"},
"xAxis": {"type": "value", "name": "Elapsed years", "min": 0},
"yAxis": {"type": "value", "name": "Price", "min": 0},
"series": {f"Scenario {column + 1}": np.column_stack([elapsed_years, prices[column]]).tolist()
for column in prices.columns},
}
print("<ECHARTS_DATA>" + json.dumps(plot_data, allow_nan=False)) Each default path has 120 monthly returns and 121 prices, spanning exactly ten years. Compare the same scenario across panels: its differences arise from the discretization, not independent random draws. Exact sampling means exact transitions at the grid points under the model; it does not make GBM an exact description of markets.
The legacy helpers label Euler return rows from 0 and exact log-return rows from 1; both describe the same 120 intervals. The price grids align directly, as used in the charts. Align return observations explicitly before combining the two return DataFrames by label.
Try it: set gbm_sigma = 0.0 in the one-period cell and rerun both examples. Exact paths become
Expressing Drift as a Risk Premium
For
where
For a non-dividend-paying asset, risk-neutral option pricing uses drift
Interactive Simulation of Geometric Brownian Motion
Use the controls to change a model assumption while holding the random experiment fixed. Both chart helpers below use exact GBM transitions, not the Euler approximation. An explicit seed makes repeated runs reproducible; retain the same horizon, observation frequency, scenario count, and seed when comparing drift, volatility, or CPPI policies.
Run the chapter imports first, then load the widget library:
import ipywidgets as widgets Start with a reproducible baseline: ten paths over three years, annual drift 7%, volatility 15%, and monthly observations. The chart includes the initial price of 100 and 36 subsequent observations:
pok.show_gbm_echart(
n_years=3, n_scenarios=10, mu=0.07, sigma=0.15,
periods_per_year=12, start=100, seed=42,
) The following controls start from those same inputs. Sliders update after release rather than on every intermediate movement. The code exposes the financial assumptions directly; widget_instance is the object the site's editor displays.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_gbm_echart,
n_years=widgets.IntSlider(value=3, min=1, max=10, continuous_update=False),
n_scenarios=widgets.IntSlider(value=10, min=1, max=100, continuous_update=False),
mu=widgets.FloatSlider(value=0.07, min=-0.30, max=0.30, step=0.01, continuous_update=False),
sigma=widgets.FloatSlider(value=0.15, min=0.0, max=0.50, step=0.01, continuous_update=False),
periods_per_year=widgets.Dropdown(options=[12, 52, 252], value=12),
start=widgets.fixed(100),
seed=widgets.IntSlider(value=42, min=0, max=1000, continuous_update=False),
)
widget_instance Try it: set sigma to zero. All paths coincide with deterministic exponential growth. Restore 0.15 and raise mu while retaining the seed: each corresponding path rises because the shocks have not changed. Then vary sigma or the seed separately to distinguish a changed volatility assumption from a new random draw.
Changing the number of scenarios or the time grid also changes the arrangement of draws, even with the same seed. Do not treat such comparisons as paired-path experiments. GBM remains a constant-parameter, continuous-path model: more simulations improve numerical precision under its assumptions, but do not introduce jumps, volatility clustering, or parameter uncertainty.
Interactive CPPI Simulation - Monte Carlo
Now apply fixed-floor CPPI to many simulated risky-return paths. The helper converts exact GBM log returns to simple returns before running the allocation rule. Cash compounds at the specified effective annual rate. Each path is an independent account, rebalanced at the observation frequency; the fully risky comparator uses the same underlying paths.
The default experiment starts at 100 with floor 80, multiplier 3, and 300 paths over three years. It assumes annual GBM drift 7%, volatility 20%, and a 3% effective annual cash rate; these are chosen model inputs, not estimates fitted to the industry snapshot. Initial risky exposure is 60%. This is the fixed-floor policy, not the high-water-mark policy from the preceding section. Compare CPPI with its matched risky benchmark within this experiment rather than compare terminal values from simulations with different horizons or volatility assumptions.
Two different failure events are reported. For path
Estimate each probability by the fraction of simulated paths experiencing that event,
Terminal floor shortfall is the positive amount n/a; it is not evidence that losses conditional on failure would be zero. This floor-based measure is different from expected shortfall beyond a VaR quantile.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_cppi_echart,
n_years=widgets.IntSlider(value=3, min=1, max=5, continuous_update=False),
n_scenarios=widgets.IntSlider(value=300, min=50, max=1000, step=50, continuous_update=False),
m=widgets.FloatSlider(value=3.0, min=1.0, max=6.0, step=0.5, continuous_update=False),
floor=widgets.FloatSlider(value=0.80, min=0.0, max=1.0, step=0.05, continuous_update=False),
mu=widgets.FloatSlider(value=0.07, min=-0.20, max=0.40, step=0.01, continuous_update=False),
sigma=widgets.FloatSlider(value=0.20, min=0.0, max=0.50, step=0.01, continuous_update=False),
risk_free_rate=widgets.FloatSlider(value=0.03, min=0.0, max=0.05, step=0.01, continuous_update=False),
periods_per_year=widgets.Dropdown(options=[12, 52, 252], value=12),
start=widgets.fixed(100),
ymax=widgets.fixed(100),
seed=widgets.IntSlider(value=42, min=0, max=1000, continuous_update=False),
)
widget_instance The wealth curves are the 5th, 50th, and 95th percentiles at each displayed date. They are not three individual paths, and the region between P05 and P95 is not a 90% confidence band for an entire trajectory. A P05 curve above the floor does not rule out breaches among the worst few percent of paths. Use the breach counts as well as the chart.
The weight panel reports percentages used during the interval ending at each plotted observation. Time zero shows initial wealth, while its weight entry is blank because no interval has elapsed.
The summary includes approximate 95% Wilson intervals for the two event probabilities. These describe Monte Carlo sampling uncertainty conditional on the chosen model, not uncertainty about whether GBM is a good market model. For example, zero breaches among 300 paths gives a Wilson upper endpoint of approximately 1.26%, not proof of zero failure probability. Increasing the number of paths can narrow sampling uncertainty; it cannot repair an unsuitable return model.
Try it: retain the seed and grid, raise sigma to 0.50, and compare multipliers 3 and 6. Inspect both path and terminal breaches and the shortfall amounts. Then vary the seed to see Monte Carlo variability. Changing periods_per_year also changes the rebalancing schedule: exact GBM endpoints do not eliminate discrete CPPI gap risk. Finally, set floor to 1; with positive cash interest, the account starts entirely in cash, then may build a cushion and take risk later.
Key Takeaways
- Diversification can reduce asset-specific risk while leaving substantial shared systematic exposure. Historical rolling relationships are descriptive, not guaranteed trading signals.
- CPPI chooses risky exposure from the available cushion. Fixed and high-water floors protect different reference levels, and either can be breached between rebalances.
- Exact GBM simulation distinguishes simple returns from log returns and drift from compounded growth. Its mathematical convenience does not establish realism for market tails.
- Controlled seeds support like-for-like policy comparisons. Pointwise quantiles, sampled-path breaches, terminal failures, and conditional shortfalls answer different investment questions.
- An investment conclusion depends on the modeled return process, observation and trading frequency, financing assumptions, and constraints—not just the appearance of simulated paths.
