Skip to content

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 questionWhat 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.

python
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.

python
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 N equally weighted assets, each with volatility σ, and the same pairwise correlation ρ≥0. There are N own-variance terms and N(N−1) ordered covariance terms:

σp2=Nσ2+N(N−1)ρσ2N2=σ2(ρ+1−ρN).

The second term shrinks as the number of assets grows, but the common-covariance term remains. In this model, volatility approaches σρ as N grows. The result concerns risk, not expected return: adding assets does not itself create higher expected returns.

Hand check. With 25 assets, each at 20% volatility, correlation 0.25 gives 0.200.25+0.75/25≈10.58% portfolio volatility. Raising correlation to 0.80 gives approximately 17.98%. Diversification still reduces volatility relative to a single asset, but by much less.

python
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 1/N; at correlation one, adding identical-risk assets gives no volatility reduction. Actual portfolios have unequal weights, volatilities, and correlations, so this is an explanatory model rather than an empirical forecast.

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.

python
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:

python
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:

python
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 ni,t be the firm count and s¯i,t the average firm size in industry i at date t. The implied industry capitalization is:

Mi,t=ni,ts¯i,t.

For Food in July 1926, 43×35.98=1,547.14 reported units. These are approximate capitalization totals because the supplied average sizes are rounded.

python
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, Mt=∑iMi,t. Requiring all industry observations prevents a missing component from silently reducing the total:

python
total_mkt_cap = ind_mkt_cap.sum(axis=1, min_count=nind)
print(total_mkt_cap.head())

The capitalization fraction is ui,t=Mi,t/Mt, with ∑iui,t=1 when all inputs are available:

python
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:

python
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:

wi,tbegin=ui,t−1,rp,t=∑iui,t−1ri,t,Vt=Vt−1(1+rp,t).

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 0.6(0.10)+0.4(−0.05)=4%. Reweighting that same month's returns using weights affected by its price changes would describe a different calculation.

python
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 h monthly observations:

V―t,h=1h∑j=0h−1Vt−j.

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.

python
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:

Gt,h=∏j=0h−1(1+rp,t−j),Rt,h=Gt,h−1,gt,h=Gt,h12/h−1.

For example, 1% each month for 36 months gives cumulative growth (1.01)36−1≈43.08%, but annualized growth (1.01)12−1≈12.68%. These are two descriptions of the same path, not interchangeable return measures.

python
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.

python
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:

ρ¯t,h=2N(N−1)∑i<jρ^ij,t,h.

For 30 industries there are 30×29/2=435 pairs. Excluding the diagonal avoids mechanically inflating the average with self-correlations. For three industries with pair correlations 0.2, 0.4, and 0.6, the correct pair average is 0.4; averaging all nine matrix entries instead gives 0.6.

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.

python
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:

python
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 At be account value at rebalancing date t, before the next period's return, and Ft the floor used at that allocation date. The dollar cushion is Ct=At−Ft. A multiplier m sets desired risky investment to mCt. It can be a positive real number; it need not be an integer.

We first use a fixed nominal floor, Ft=F=fA0, where f is a fraction of initial capital. For example, an 80% floor on an initial 1,000 remains 800 even if wealth subsequently rises to 1,600. It is not reset to 80% of current wealth and is not a 20% maximum-drawdown limit.

Risky Asset Allocation ​

For positive account value, distinguish the dollar cushion from the cushion fraction, ct=(At−Ft)/At. With no borrowing or short positions in this example, clip the risky allocation to the available account value:

Et=min(At,max(0,m(At−Ft))),wt=EtAt=min(1,max(0,mct)),Bt=At−Et.

Here Et is risky investment, Bt is the cash balance, and wt is the risky weight. After risky and cash returns rt+1 and st+1 occur:

At+1=Et(1+rt+1)+Bt(1+st+1),rt+1CPPI=wtrt+1+(1−wt)st+1.

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:

  1. The initial cushion is 100, so invest 400 in the risky asset and 600 in cash.
  2. A 20% risky loss leaves 400(0.80)+600=920. The new cushion is 20, so the next risky investment is only 4(20)=80.
  3. A subsequent 25% risky gain leaves 80(1.25)+840=940. The risky asset itself has recovered to its starting value, but CPPI has not, because it reduced exposure after the loss.
python
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 E=m(A−F), the floor is fixed, and cash earns zero over one rebalancing interval. A risky loss of fraction d>0 leaves:

A′−F=A−dE−F=(A−F)(1−md).

Thus the floor is maintained over that interval exactly when md≤1, or d≤1/m. At equality the cushion is exhausted. For m=4, the threshold is a 25% risky-asset loss between rebalances; for m=6, it is approximately 16.67%, so a 20% loss can already breach the floor.

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 mC; nonnegative cash interest adds a buffer. Trading costs, losses in the reserve asset, and price jumps can weaken protection. A discrete CPPI strategy therefore has gap risk, rather than an unconditional guarantee.

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.

python
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.

python
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 s=(1.03)1/12−1≈0.2466%. This makes twelve months of cash compound to exactly 3%; dividing 3% by twelve would be a different rate convention.

python
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 3(1−0.8)=60%. Ignore trading costs and assume rebalancing at each month boundary; a monthly history cannot reveal intramonth floor breaches.

python
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:

python
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.

python
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:

python
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:

python
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.

python
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:

python
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 t, define:

Ht=max(A0,A1,…,At),Ft=(1−δ)Ht,

where 0<δ<1 is the desired maximum drawdown fraction. The peak includes initial capital and only wealth observed by date t. The floor rises after new highs and does not fall after losses. For positive wealth, define the current drawdown as Dt=1−At/Ht. Then:

At−FtAt=δ−Dt1−Dt,wt=min(1,max(0,mδ−Dt1−Dt)).

At a new peak, Dt=0 and the risky weight is min(1,mδ). As drawdown approaches the target, the cushion shrinks; at or beyond the target, the rule sets risky exposure to zero. A rising floor ratchets up the capital-protection target after gains, while potentially reducing participation in a recovery.

The drawdown target does not mathematically determine the multiplier. The toolkit adopts the particular policy m=1/δ whenever 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 δ=20%, multiplier 5, and zero cash interest:

  1. 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.
  2. 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.
  3. The next risky investment is 5(990−880)=550, or 55.56% of the account. A 10% risky gain leaves 550(1.10)+440=1,045, a 5% drawdown from the peak.
python
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 Et=m(At−Ft) and cash earns zero, the previous gap condition still applies: At+1−Ft=(At−Ft)(1−md). Choosing m=1/δ makes the corresponding risky-loss threshold d≤δ; it does not ensure that market losses satisfy it.

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:

python
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 m=3; here the 20% target implies m=5, so reusing that earlier performance table would change both the floor policy and the multiplier.

python
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:

python
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 mδ=1. For this sample, their observed maximum drawdowns are approximately 19.83%, 38.65%, and 53.40%, respectively. A looser target permits more exposure after a given decline; it does not guarantee a higher realized return. Compare both columns rather than interpreting the target as the realized drawdown.

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 W0=0, has independent increments over disjoint intervals, and satisfies:

Wt+Δt−Wt∼N(0,Δt).

Its paths are continuous but almost surely nowhere differentiable: continuity does not mean smoothness. Consequently, dWt/dt is not an ordinary derivative that can be manipulated as a usual rate of change.

For t>0, Wt∼N(0,t), with mean zero, variance t, and density:

fWt(x)=12πtexp(−x22t).

Geometric Brownian Motion (GBM) ​

With time measured in years and p observations per year, set Δt=1/p. Simulate each Brownian increment as ΔWk=ΔtZk, where the Zk are independent standard normals, then accumulate the increments. Brownian levels are not independent: Cov(Ws,Wt)=min(s,t). Drawing a fresh independent tZ at every date would match individual marginal distributions but would not generate a Brownian path.

Let St>0 represent an asset price, or a reinvested wealth index when modeling total returns. Geometric Brownian Motion assumes:

dStSt=μdt+σdWt.

Here μ is the constant annual instantaneous arithmetic drift, and σ≥0 is the annual diffusion-volatility parameter. Proportional changes, rather than currency changes, follow the same model at different price levels. The model assumes independent Gaussian log-return increments and constant parameters; these are modeling choices, not properties established for market returns.

Returns ​

Over a finite interval, holding the diffusion coefficients at the interval's start gives the Euler approximation:

Rk+1Euler=μΔt+σΔtZk,Sk+1Euler=SkEuler(1+Rk+1Euler).

This approximating simple return is Gaussian, with mean μΔt and variance σ2Δt. It is not the exact finite-interval GBM return. In particular, a sufficiently negative Gaussian shock can give REuler≤−1, creating a zero or negative price. Clipping such outcomes or redrawing them would change the model's distribution.

Log-Returns and Price Evolution ​

For an exact transition, apply Itô's lemma to f(S)=log⁡S, whose derivatives are f′(S)=1/S and f″(S)=−1/S2. Brownian quadratic variation contributes a second-order term:

dlog⁡St=f′(St)μStdt+12f″(St)σ2St2dt+f′(St)σStdWt=(μ−12σ2)dt+σdWt.

Integrating over an interval gives the exact log return under constant-parameter GBM:

ℓk+1=log⁡(Sk+1Sk)=(μ−12σ2)Δt+σΔtZk.

The corresponding simple return is Rk+1=eℓk+1−1, and:

Sk+1=Skeℓk+1,ST=S0exp((μ−12σ2)T+σWT).

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 Z=1:

REuler=0.0712+0.1512≈0.049135,ℓ=0.07−0.152/212+0.1512≈0.048197,Rexact=eℓ−1≈0.049377.

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 1+R.

python
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:

E[ST]=S0eμT,median(ST)=S0e(μ−σ2/2)T.

For a simple return over an interval of length Δt:

E[R]=eμΔt−1,SD(R)=eμΔteσ2Δt−1.

Thus μΔt and σΔt are short-interval approximations to the exact simple-return moments. The drift μ is neither a guaranteed annual return nor a realized CAGR; μ−σ2/2 is the expected log-growth rate. Holding μ fixed while increasing σ leaves expected wealth unchanged but lowers median wealth and increases dispersion.

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 with np.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.

python
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 St=S0eμt, while Euler compounds (1+μ/p) each period. Then restore volatility to 0.15 and try 0.30 with the same seed and grid. Higher volatility increases model dispersion and lowers median terminal wealth, but individual paths need not all move in the same direction. Changing the seed redraws the experiment; changing the observation frequency also changes the Brownian grid, so the same seed alone does not couple coarse and fine paths.

Expressing Drift as a Risk Premium ​

For σ>0, a continuous-time risk-premium parameter can be written as:

λ=μ−rσ,μ=r+σλ,

where r is the continuously compounded cash rate. For an effective annual cash return qf, use r=log⁡(1+qf). This gives:

dStSt=(r+σλ)dt+σdWt.

λ is an instantaneous excess-drift-to-volatility ratio. It is the short-horizon limit of an appropriately annualized simple-return Sharpe ratio, not generally the exact one-year Sharpe ratio from historical observations. Under the model, the latter population ratio is:

SR1y=eμ−ereμeσ2−1.

For a non-dividend-paying asset, risk-neutral option pricing uses drift r under its pricing measure; that does not establish r as the asset's real-world expected drift. In portfolio simulations, the chosen μ represents the investment model's assumed 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:

python
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:

python
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.

python
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 j, with wealth observed at allocation-grid endpoints tk and fixed floor F:

Ijpath=1{minkAj,tk<F},Ijterminal=1{Aj,T<F}.

Estimate each probability by the fraction of simulated paths experiencing that event, p^=N−1∑jIj. A breached account can recover through cash interest, so its terminal outcome need not remain below the floor. Neither measure detects an unobserved intraperiod breach.

Terminal floor shortfall is the positive amount Lj=(F−Aj,T)+. The output shows its mean across all paths and its conditional mean among terminal breaches. If none occur, the conditional sample mean is unavailable, displayed as 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.

python
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.

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