Skip to content

Strategies Beyond Diversification

This document offers an in-depth exploration of advanced portfolio optimization techniques. It starts by examining the benefits and limitations of diversification—highlighting what it can and cannot achieve—while also analyzing rolling returns and calculating rolling correlations. The discussion then shifts to risk mitigation, with a particular focus on Constant Proportion Portfolio Insurance (CPPI). In this section, the principles of CPPI, asset allocation strategies, and dynamic drawdown constraints are explained in detail.

A significant portion of the guide is dedicated to simulating random walks via Geometric Brownian Motion. This part provides both a theoretical background and practical applications, complemented by interactive simulations that illustrate stock price dynamics and CPPI strategies. Throughout, Python libraries and specialized tools such as PortfolioOptimizationKit are used to demonstrate these concepts, making the guide especially valuable for readers interested in financial modeling and portfolio optimization.

What you'll learn

  • How diversification reshapes idiosyncratic versus systemic risk, including a data-driven view of when correlations spike.
  • How rolling analytics (returns and correlations) quantify the limits of diversification in turbulent markets.
  • How CPPI with static and dynamic floors rebalances risk budgets, and how to stress-test the strategy under varying drawdown tolerances.
  • How Geometric Brownian Motion underpins the stochastic simulations used throughout the toolkit, and how to interact with these models via widgets.

Below is the Python code that imports the necessary libraries to support these demonstrations:

python
import json
from datetime import datetime

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scipy.stats
from scipy.optimize import minimize
from tabulate import tabulate

import PortfolioOptimizationKit as pok

Utility Helpers

Several visualisations reuse small helpers for handling missing values and reshaping DataFrames into ECharts-friendly dictionaries. Define them once so the subsequent snippets stay focused on the financial logic:

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

  • Elements that diversification successfully addresses:

    • The approach to augment the reward for each unit of risk;
    • The method to reduce idiosyncratic or specific risk: indeed, it enables investors to diminish specific risks within their portfolios, which is how they can achieve a high reward for each unit of risk.
  • Elements that diversification fails to address:

    • Diversification is not a viable strategy to reduce systemic risk: essentially, if the entire market collapses, the degree of portfolio diversification becomes irrelevant as correlation levels typically rise and the benefits of diversification diminish.

Subsequently, the intent is to illustrate the aforementioned aspect, specifically that during significant financial downturns, portfolio diversification does not ensure reduced risk.

Proceed to load the subsequent dataset featuring 30 Industry portfolios from Kaggle:

IMPORTANT

Place the CSVs from the Kenneth French data library under assets/data/ (or the path returned by pok.path_to_data_folder()). The toolkit expects the original file names—ind30_m_ew_rets.csv, ind30_m_nfirms.csv, and ind30_m_size.csv. Run pok.path_to_data_folder() if you need to confirm the active directory before executing the notebooks.

python
nind = 30

ind_rets = pok.get_ind_file(filetype="rets", nind=nind)
ind_nfirms = pok.get_ind_file(filetype="nfirms", nind=nind)
ind_size = pok.get_ind_file(filetype="size", nind=nind)

print(ind_rets.head(3))

The ind_rets dataframe encompasses returns (spanning 1926-2018) of 30 portfolios covering various industry sectors such as food, beer, smoke, etc.

These portfolios are formulated based on weights corresponding to their market capitalizations. The market capitalization of an entity is its total valuation, calculated as the product of the company's outstanding share count and the share price (e.g., if company "A" has 100,000 shares at $20.3 each, its market cap is $2,030,000).

For instance, the Food column represents the returns of a portfolio comprising all companies within the food sector for each month from 1926 to 2019. Each company is weighted according to its market capitalization within this portfolio.

Next, the number of firms comprising each individual sector is recorded in ind_nfirms:

python
print(ind_nfirms.head(3))

indicating that in July 1926, there were 43 companies in the Food portfolio, 3 in the Beer portfolio, etc.

Lastly, the ind_size dataframe presents the average size of the companies within each portfolio:

python
print(ind_size.head(3))

illustrating that the average size of the 43 Food companies in July 1926 was 35.98, while the average size of the 3 Beer companies was 7.12, and so on (the unit of measurement is arbitrary). Here, average size refers to the mean of the market capitalizations of the companies within sectors like Food, Beer, etc.

Formulating the Index

The initial step is to calculate the market capitalization for each industry sector. This is determined by multiplying the number of firms by their average size:

python
ind_mkt_cap = ind_nfirms * ind_size
print(ind_mkt_cap.head(3))

The next objective is to ascertain the total market capitalization to derive the proportion of the total market capitalization attributed to each industry. The total market capitalization is a singular time series indicating the aggregate market value at each month. This is achieved by summing the market capitalization of each sector for every month, i.e., by horizontally summing the ind_mkt_cap:

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

Then, the proportion of each industry's market cap to the total can be calculated:

python
ind_cap_weights = ind_mkt_cap.divide(total_mkt_cap, axis=0)
print(ind_cap_weights.head(3))

For instance, in July 1926, the total market capitalization was $26,657.94, with the Food sector comprising approximately 5.8%, the Beer sector approximately 0.08%, and so forth.

Visualization of these components is as follows:

python
import json

x_data = total_mkt_cap.index.strftime('%Y-%m').tolist()
total_market_cap_values = total_mkt_cap.values.tolist()
selected_sectors = ["Steel", "Fin", "Telcm"]
sector_series = {sector: ind_cap_weights[sector].values.tolist() for sector in selected_sectors}

plot_data = {
    "dates": x_data,
    "totalMarketCap": {
         "series": {"Total Market Cap": total_market_cap_values},
         "type": "line",
         "yAxisName": "Market Cap"
    },
    "sectorWeights": {
         "series": sector_series,
         "type": "line",
         "yAxisName": "Weight (%)"
    }
}

output = "\n<ECHARTS_DATA>" + json.dumps(plot_data)
print(output)

The first plot displays the total market capitalization from 1929 to 2018. The second plot shows the Steel, Finance, and Telecommunication Market caps from 1929 to 2018 as a percentage of the total market capitalization.

For instance, observe the change in the Finance sector from about 3% of the total market cap in 1929 to over 15% in 2018. Conversely, the Steel sector decreased from around 9% in 1929 to 0.2% in 2018.

Now, the focus shifts to calculating the total market return, i.e., the return time series for the total market. This is the sum of the weighted returns of each sector:

python
import json

total_market_return = (ind_cap_weights * ind_rets).sum(axis=1)

capital = 1000
total_market_index = capital * (1 + total_market_return).cumprod()

x_data = total_market_index.index.strftime('%Y-%m').tolist()

total_market_index_values = total_market_index.values.tolist()
total_market_return_values = total_market_return.values.tolist()

plot_data = {
    "dates": x_data,
    "totalMarketIndex": {
         "series": {"Total Market Index": total_market_index_values},
         "type": "line",
         "yAxisName": "Market Index"
    },
    "totalMarketReturn": {
         "series": {"Total Market Return": total_market_return_values},
         "type": "line",
         "yAxisName": "Return"
    }
}

output = "\n<ECHARTS_DATA>" + json.dumps(plot_data)
print(output)

Note that the plot of the (cap-weighted) index essentially mirrors the plot of the total market capitalization, albeit with different values.

Finally, the aim is to explore the relationship between returns and market correlations.

Analyzing Rolling Returns

This section plots the total market index from 1990 onwards, alongside several moving average (MA) series corresponding to 60, 36, and 12 months, respectively. This aims to demonstrate how the .rolling method functions in Python.

python
import json

filtered_index = total_market_index["1990":]
x_data = filtered_index.index.strftime('%Y-%m').tolist()

ma_60 = convert_nan_to_none(filtered_index.rolling(window=60).mean().values.tolist())
ma_36 = convert_nan_to_none(filtered_index.rolling(window=36).mean().values.tolist())
ma_12 = convert_nan_to_none(filtered_index.rolling(window=12).mean().values.tolist())

plot_data = {
    "dates": x_data,
    "totalMarketIndexMA": {
        "tooltip": {"trigger": "axis"},
        "legend": {"orient": "vertical", "right": 10, "top": 10},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Market Index"},
        "series": {
            "Total Market Index": filtered_index.values.tolist(),
            "60 Months MA": ma_60,
            "36 Months MA": ma_36,
            "12 Months MA": ma_12,
        },
        "type": "line",
    },
}

print("<ECHARTS_DATA>" + json.dumps(plot_data))

Next, the trailing 36 months compound returns of the total market return are computed. This is done by opening a rolling window for 36 months and, for each window, compounding the returns using .aggregate.

python
tmi_trail_36_rets = total_market_return.rolling(window=36).aggregate(pok.annualize_rets, periods_per_year=12)

total_market_return.plot(grid=True, figsize=(12, 5), label="Total market (monthly) return")
tmi_trail_36_rets.plot(grid=True, figsize=(12, 5), label="Trailing 36 months total market compound return")
plt.legend()
plt.show()

Calculating Rolling Correlations: Multi-Indices and Groupby

The rolling correlations across industries are computed similarly to the trailing 36 months compound returns, using the .corr() method for pairwise correlation between columns of the dataframe.

python
rets_trail_36_corr = ind_rets.rolling(window=36).corr()
rets_trail_36_corr.index.names = ["date", "industry"]

print(rets_trail_36_corr.tail())

The resulting dataframe is a time series of correlation matrices. Each matrix represents the trailing 36 months correlation matrix of compounded returns of the industries for each available data. The structure of the dataframe includes a double index: for each index date, there is a set of index industries.

To observe the averages of all these correlation matrices for each date, group by date and then take the average.

Plotting the trailing 36 months total market compound return alongside the trailing 36 months total market return correlations provides insights into the relationship between returns and correlations over time.

python
import json
import pandas as pd

ind_trail_36_corr = rets_trail_36_corr.groupby(level="date").apply(lambda corrmat: corrmat.values.mean())
x_data = tmi_trail_36_rets.index.strftime('%Y-%m').tolist()

plot_data = {
    "dates": x_data,
    "ef_plot": {
        "title": "Trailing 36mo Compound Return & Correlations",
        "tooltip": {"trigger": "axis"},
        "legend": {
            "data": ["Trailing 36mo Compound Return", "Trailing 36mo Correlations"],
            "orient": "horizontal",
            "top": "10%",
        },
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": [
            {"type": "value", "name": "Trail 36mo returns"},
            {"type": "value", "name": "Trail 36mo corrs", "position": "right", "min": 0, "max": 1},
        ],
        "series": {
            "Trailing 36mo Compound Return": convert_nan_to_none(tmi_trail_36_rets.values.tolist()),
            "Trailing 36mo Correlations": convert_nan_to_none(ind_trail_36_corr.values.tolist()),
        },
    },
}

print("<ECHARTS_DATA>" + json.dumps(plot_data))

Finally, calculate the correlation between the series of (trailing 36 months) compounded returns and the series of average correlations across industries.

python
print(tmi_trail_36_rets.corr(ind_trail_36_corr))

This correlation is expected to be negative, indicating that as returns fall (especially during market downturns), the average correlation between industries tends to increase, highlighting the limits of diversification during market crashes. Conversely, during periods of market recovery or stability, correlations may decrease, potentially offering more benefits from diversification.

Interpretation: Rolling Returns vs. Correlations

  • Correlation spikes when markets stress: trailing 36‑month correlations jumped toward 1.0 during crises, eroding the diversification benefit precisely when it is most needed.
  • Momentum and dispersion travel together: rising rolling returns typically coincide with falling average correlations, leaving scope for sector rotation and idiosyncratic bets.
  • Risk policy implication: relying solely on static correlation assumptions understates crash risk; periodic re-estimation (or stress overlays) is essential when building strategic asset allocations.

Strategies for Risk Mitigation

Recent market downturns have underscored the importance of strategies designed for risk control, notably those providing protection against downside risks. This document discusses a fundamental risk insurance strategy before proceeding to more advanced strategies aimed at maximizing upside potential while imposing strict limits on portfolio drawdown.

Constant Proportion Portfolio Insurance (CPPI)

The CPPI method enables the realization of option-like (convex) payoffs without the actual use of options.

Two asset classes are typically involved: a risky asset, such as equities or mutual funds, and a conservative asset, typically cash or treasury bonds.

The strategy revolves around dynamic allocation between the risky and the safe asset, with the allocation percentage for each dependent on the so-called cushion value, which is the current portfolio value minus a predetermined floor value. Essentially, the cushion is a buffer representing a portion of the current portfolio value that one aims to protect from losses.

In detail: the amount allocated to a risky asset is a positive integer m times the cushion, while the remainder is allocated to the safe asset. Notably, if the cushion diminishes to zero over time, meaning the portfolio is nearing the floor value, the allocation to the risky asset also drops to zero, effectively shifting the entire capital to the safe asset.

Example: Consider an investment in a risky asset with m=4 and a floor at 90 of the portfolio. The allocation would then be 4(10.9)%=40% of the total capital to the risky asset, with the remaining 60% to the safe asset.

Risky Asset Allocation

Let A represent the portfolio's value, termed the account value. The allocation to the risky asset is then given by:

E:=m(AF)=mA(1f),

where F:=fA denotes the floor value, with f representing the proportion of wealth one wishes to preserve (e.g., 0.9 in the prior example).

The multiplier m is typically selected based on the acceptable drop in value. For instance, if F is the floor value and E is the amount invested in the risky asset, consider a scenario where the risky asset value decreases by d. What should be the value of $m$ to ensure the floor isn't breached?

One seeks to satisfy:

accountloss=AdEF=floor value,

where dE is the loss from the risky asset investment and F is the floor value. Substituting, one gets:

AdmA(1f)fA

leading to:

1dm(1f)fm1d.

This indicates that if m is chosen to be less or equal to 1/d, the floor will not be breached.

If m=1/d, the account value after the loss equals the floor value, suggesting the entire loss has been absorbed, and the floor has been reached, following:

accountloss=AdE=Ad1dA(1f)=fA=floor value,

Selecting m=6, for instance, would mean a loss greater than 20% could breach the floor with such a drop.

Example: For a 20% drop,

m10.2=5,

meaning the multiplier should ideally be no more than 5 to avoid breaching the floor.

Generally, if m is the maximum acceptable loss, then the multiplier should be (at most) 1/m%.

Executing CPPI with Drawdown Limitation

The CPPI strategy, an algorithmic approach, is predicated on three fundamental steps:

  • Cushion Calculation: Derive the cushion by subtracting the Floor value from the Account value.

  • Asset Allocation: Determine the distribution between the risky and safe assets.

  • Account Value Update: Adjust the account value in response to returns.

This segment details the establishment of this investment strategy utilizing industry returns alongside the total market index returns.

python
ind_return = pok.get_ind_file(filetype="rets", nind=nind)
tmi_return = pok.get_total_market_index_returns(nind=nind)

The focus is narrowed to industry returns post-2000, concentrating on three industries as the risky assets:

python
risky_rets = ind_return["2000":][["Steel","Fin","Beer"]]

For the safe asset, an artificial asset is crafted, guaranteeing a 3% annual return:

python
safe_rets    = pd.DataFrame().reindex_like(risky_rets)
safe_rets[:] = 0.03 / 12

Initial account value (investment), floor value, and multiplier are then established:

python
start_value = 1000
account_value = start_value
floor = 0.8
floor_value = floor * account_value
m = 3

account_history = pd.DataFrame().reindex_like(risky_rets)
cushion_history = pd.DataFrame().reindex_like(risky_rets)
risky_w_history = pd.DataFrame().reindex_like(risky_rets)

Prior to initiating the CPPI strategy, the growth of wealth exclusively invested in risky assets is computed for comparison:

python
risky_wealth = start_value * (1 + risky_rets).cumprod()
print(risky_wealth.head())

The CPPI strategy is now executed:

python
for step in range(len(risky_rets.index)):
    cushion = (account_value - floor_value) / account_value
    risky_w = np.minimum(np.maximum(m * cushion, 0), 1)
    safe_w = 1 - risky_w
    risky_allocation = risky_w * account_value
    safe_allocation = safe_w * account_value
    account_value = risky_allocation * (1 + risky_rets.iloc[step]) + safe_allocation * (1 + safe_rets.iloc[step])
    account_history.iloc[step] = account_value
    cushion_history.iloc[step] = cushion
    risky_w_history.iloc[step] = risky_w

cppi_rets = (account_history / account_history.shift(1) - 1).dropna()

With the return series in hand, it is useful to benchmark the raw risky allocation against the CPPI overlay before plotting:

python
comparison = pd.concat(
    {
        "100% Risky": pok.summary_stats(risky_rets).assign(Strategy="Risky"),
        "CPPI": pok.summary_stats(cppi_rets).assign(Strategy="CPPI")
    }
).reset_index(drop=True)

print(tabulate(comparison, headers='keys', tablefmt='github'))

The table surfaces the trade-off between the strategies (CAGR, volatility, max drawdown) before diving into the time-series visualisations that follow.

The account history resultant from the CPPI strategy is then examined:

python
import json

account_history_filtered = account_history["2000":]
x_data = account_history_filtered.index.strftime('%Y-%m').tolist()
series_data = {
    col: convert_nan_to_none(account_history_filtered[col].values.tolist())
    for col in account_history_filtered.columns
}

plot_data = {
    "dates": x_data,
    "accountHistory": {
        "title": "Wealth Evolution Over Time (CPPI Strategy)",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": list(account_history_filtered.columns), "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Wealth ($)"},
        "series": series_data,
        "type": "line",
    },
}

print("<ECHARTS_DATA>" + json.dumps(plot_data))

A comparative analysis between the CPPI strategies and a full investment in risky assets is conducted:

python
import json

x_data = account_history.index.strftime('%Y-%m').tolist()

series = lambda df, col: convert_nan_to_none(df[col].values.tolist())

charts = {
    "Beer_Cppi": {
        "title": "Comparative Analysis for Beer",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": ["CPPI Beer", "Beer", "Fixed floor value"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Wealth ($)"},
        "series": {
            "CPPI Beer": series(account_history, "Beer"),
            "Beer": series(risky_wealth, "Beer"),
            "Fixed floor value": [floor_value] * len(x_data),
        },
        "type": "line",
    },
    "Beer_Weight": {
        "title": "Risky Weight in Beer",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": ["Risky weight in Beer"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Risky Weight"},
        "series": {"Risky weight in Beer": series(risky_w_history, "Beer")},
        "type": "line",
    },
    "Fin_Cppi": {
        "title": "Comparative Analysis for Fin",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": ["CPPI Fin", "Fin", "Fixed floor value"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Wealth ($)"},
        "series": {
            "CPPI Fin": series(account_history, "Fin"),
            "Fin": series(risky_wealth, "Fin"),
            "Fixed floor value": [floor_value] * len(x_data),
        },
        "type": "line",
    },
    "Fin_Weight": {
        "title": "Risky Weight in Fin",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": ["Risky weight in Fin"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Risky Weight"},
        "series": {"Risky weight in Fin": series(risky_w_history, "Fin")},
        "type": "line",
    },
    "Steel_Cppi": {
        "title": "Comparative Analysis for Steel",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": ["CPPI Steel", "Steel", "Fixed floor value"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Wealth ($)"},
        "series": {
            "CPPI Steel": series(account_history, "Steel"),
            "Steel": series(risky_wealth, "Steel"),
            "Fixed floor value": [floor_value] * len(x_data),
        },
        "type": "line",
    },
    "Steel_Weight": {
        "title": "Risky Weight in Steel",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": ["Risky weight in Steel"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Risky Weight"},
        "series": {"Risky weight in Steel": series(risky_w_history, "Steel")},
        "type": "line",
    },
}

print("<ECHARTS_DATA>" + json.dumps(charts))

As anticipated, during periods of market growth, the CPPI strategy (blue line) typically underperforms a full investment in the risky asset (dotted line). However, the CPPI strategy demonstrates its value during downturns, such as the Lehman Brothers crisis, where it protects the investment from breaching the floor.

Lastly, the statistics for both a pure investment in risky assets and the CPPI strategy are compared:

python
print(pok.summary_stats(risky_rets))
print(pok.summary_stats(cppi_rets))

Drawdown analysis reveals that while a full investment in risky assets might lead to greater losses, it may also yield higher returns. For instance, investing entirely in Beer might have returned approximately 8% annually, compared to around 7% from the CPPI strategy.

Implementing the Dynamic Drawdown Constraint

In the initial CPPI strategy, the floor value was static.

However, investors typically prefer a strategy where the floor value is dynamically updated based on the portfolio's wealth growth, specifically relative to the previous peak. This approach aims to recalibrate the floor value as the portfolio value increases.

The CPPI strategy has been encapsulated into a method within the toolkit. This version incorporates a dynamic drawdown constraint, where the multiplier m is adjusted based on the specified drawdown limit.

Consider an example with a drawdown limit of 20%:

python
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok

res = pok.cppi(risky_rets, start_value=1000, floor=0.8, drawdown=0.2, risk_free_rate=0.03, periods_per_year=12)
sector = "Fin"

to_series = lambda key: convert_nan_to_none(res[key][sector].values.tolist())
x_data = res["CPPI wealth"][sector].index.strftime('%Y-%m').tolist()

plot_data = {
    "dates": x_data,
    "wealthComparison": {
        "title": f"Comparative Analysis for {sector}",
        "tooltip": {"trigger": "axis"},
        "legend": {
            "data": [f"CPPI {sector}", sector, "Dynamic floor value"],
            "orient": "horizontal",
            "top": "10%",
        },
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Wealth ($)"},
        "series": {
            f"CPPI {sector}": to_series("CPPI wealth"),
            sector: to_series("Risky wealth"),
            "Dynamic floor value": to_series("Floor value"),
        },
        "type": "line",
    },
    "riskyAllocation": {
        "title": f"Risky Allocation in {sector}",
        "tooltip": {"trigger": "axis"},
        "legend": {"data": [f"Risky weight in {sector}"], "orient": "horizontal", "top": "10%"},
        "xAxis": {"type": "category", "data": x_data},
        "yAxis": {"type": "value", "name": "Risky Weight"},
        "series": {f"Risky weight in {sector}": to_series("Risky allocation")},
        "type": "line",
    },
}

print("<ECHARTS_DATA>" + json.dumps(plot_data))

This code executes the CPPI strategy with a dynamic floor that adapts based on the portfolio's performance and imposes a drawdown constraint.

Next, compare the statistics of the sector's pure risky returns with the returns from the CPPI strategy:

python

print("="*60)
print(f"Summary Statistics for sector's Pure Risky Returns ({sector}):")
print("="*60)
risky_stats = pok.summary_stats(risky_rets[sector])
print(tabulate(risky_stats, headers='keys', tablefmt='github'))

print("\n" + "="*60)
print(f"Summary Statistics for CPPI Returns ({sector}):")
print("="*60)
cppi_stats = pok.summary_stats(res["CPPI returns"][sector])
print(tabulate(cppi_stats, headers='keys', tablefmt='github'))

The maximum drawdown in the CPPI strategy is expected to be around 19.8% with an annual return of 6%. The dynamic adjustment of the multiplier m based on the drawdown constraint ensures that the investment floor is protected during market downturns.

Now, observe the behavior of the CPPI strategy when varying the maximum accepted drawdown:

python
sector = "Fin"
drawdowns = [0.2, 0.4, 0.6]

fig, ax = plt.subplots(1, 2, figsize=(18, 4))
ax = ax.flatten()
res["Risky wealth"][sector].plot(ax=ax[0], grid=True, style="k:", label=sector)
summary = []

for drawdown in drawdowns:
    res = pok.cppi(risky_rets, start_value=1000, floor=0.8, drawdown=drawdown, risk_free_rate=0.03, periods_per_year=12)
    label = f"CPPI dd={drawdown*100:.0f}%, m={res['m']:.1f}"
    res["CPPI wealth"][sector].plot(ax=ax[0], grid=True, label=label)
    res["Risky allocation"][sector].plot(ax=ax[1], grid=True, label=label)
    stats = pok.summary_stats(res["CPPI returns"][sector])
    stats.insert(0, "Scenario", f"DD{drawdown*100:.0f}%")
    summary.append(stats)

ax[0].legend()
ax[1].legend(fontsize=11)
ax[1].set_title("Risky weight", fontsize=11)
plt.show()

summary_df = pd.concat(summary, axis=0).reset_index(drop=True)
print(tabulate(summary_df, headers='keys', tablefmt='github'))

This code compares the performance and allocation strategy of the CPPI strategy under different drawdown constraints, illustrating how the strategy adapts to various levels of risk tolerance. The summary statistics provide insight into the trade-offs between risk and return for each scenario.

Generating Random Walks with Geometric Brownian Motion

Wiener Process Overview

A Wiener process Wt is a continuous-time stochastic process defined by four key characteristics:

  1. The process starts at zero: W0=0;
  2. It has independent increments, meaning future changes Wt+uWt, for u0, are independent of past values Ws, for s<t;
  3. Has Gaussian increments, meaning increments following a normal distribution with mean 0 and variance u , i.e., Wt+uWtN(0,u);
  4. The process has continuous paths, implying Wt varies smoothly over time.

Therefore, E[Wt]=0, Var(Wt)=t for a fixed time t and the unconditional probability density function of Wt is:

fWt(x)=12πtex22t.

Geometric Brownian Motion (GBM)

A stochastic process St follows a Geometric Brownian Motion if it adheres to the following stochastic differential equation (SDE):

dSt=μStdt+σStdWt

where Wt is a Brownian motion (i.e. a Wiener process), μ represents the drift (trend), and σ represents the volatility. Both are constants.

Returns

By dividing the equation by St and dt, one can derive a formula for generating percentage returns from a GBM. The returns generated this way have a mean of μdt and volatility of σdt.

TIP

In the given equation, dSt=St+dtSt, and similarly dWt=Wt+dtWt for dt>0. When this is normalized by St the formula becomes:

St+dtStSt=μdt+σ(Wt+dtWt).

Here, the left side represents the percentage return.

Considering that Wt has Gaussian increments with zero mean and variance dt, the increment can be replaced with a normally distributed random variable dtξt, where ξtN(0,1) for all t.

This substitution is valid as E[dtξt]=0 and Var(dtξt)=dtVar(ξt)=dt.

The formula thus transforms into:

St+dtStSt=μdt+σdtξt,ξtN(0,1),t,

This expression provides a method to generate (percentage) returns from a geometric Brownian motion. The expected value and variance of these returns are calculated as:

E[μdt+σdtξt]=μdt+σdtE[ξt]=μdt,Var[μdt+σdtξt]=σ2dtVar(ξt)=σ2dt,

indicating that the generated returns have a mean of μdt and volatility of σdt.

Log-Returns and Price Evolution

For log-returns, approximations using Taylor series expansions lead to:

log(St+dtSt)(μσ22)dt+σdtξt,

where ξtN(0,1). This simplifies the calculation of prices and returns over time.

The price evolution of a stock, for example, can be modeled using the GBM formula:

St=S0exp((μσ22)t+σWt).

TIP

Considering the stochastic differential equation and dividing by dt, one obtains:

dStdt=St(μ+σdWtdt),

which delineates the process's evolution over time. For instance, the process St might represent a stock price. This equation can be resolved through the subsequent reasoning.

Initially, it is recognized that:

log(1+x)xx22,

particularly when x is adequately small. Thus:

log(St+dtSt)=log(1+St+dtStSt)μdt+σdtξt12(μdt+σdtξt)2μdt+σdtξt12σ2dt=(μσ22)dt+σdtξt.

The term on the left-hand side of the above equation is termed the log-return and it adheres to a dynamic akin to the classic percentage return. The key distinction lies in the scaled drift (μσ2/2) featured in the log-return equation.

Exponentiating both sides yields:

St+dtStexp((μσ22)dt+σdtξt).

To deduce the solution, it is noted that for each unit t=ndt, where nN, it follows that:

St=S0+ndtS0exp((μσ22)(ndt)+σndtξt)=S0exp((μσ22)t+σtξt)=S0exp((μσ22)t+σWt).

This equation describes the evolution of, for example, a stock price starting from an initial price S0.

Within the toolkit, there are two functions that generate stock prices based on the above principles:

  • one by compounding percentage returns following a GBM
  • another by resolving the GBM equation for log-returns:
python
prices_1, rets_1 = pok.simulate_gbm_from_returns(
    n_years=10, n_scenarios=10, mu=0.07, sigma=0.15, periods_per_year=12, start=100.0)
prices_2, rets_2 = pok.simulate_gbm_from_prices(
    n_years=10, n_scenarios=10, mu=0.07, sigma=0.15, periods_per_year=12, start=100.0)

if isinstance(prices_1.index, pd.DatetimeIndex):
    x_data = prices_1.index.strftime('%Y-%m').tolist()
else:
    x_data = list(prices_1.index.astype(str))


series_1 = df_to_series_dict(prices_1)
series_2 = df_to_series_dict(prices_2)

chart1 = {
    "title": "Prices generated by compounding returns which follow a GBM",
    "tooltip": {"trigger": "axis"},
    "legend": {"data": list(prices_1.columns), "orient": "horizontal", "top": "10%"},
    "xAxis": {"type": "category", "data": x_data},
    "yAxis": {"type": "value", "name": "Price"},
    "series": series_1,
    "type": "line"
}

chart2 = {
    "title": "Prices generated by solving the GBM equation satisfied by log-returns",
    "tooltip": {"trigger": "axis"},
    "legend": {"data": list(prices_2.columns), "orient": "horizontal", "top": "10%"},
    "xAxis": {"type": "category", "data": x_data},
    "yAxis": {"type": "value", "name": "Price"},
    "series": series_2,
    "type": "line"
}

plot_data = {
    "dates": x_data,
    "pricesCompounding": chart1,
    "pricesEquation": chart2
}

output = "<ECHARTS_DATA>" + json.dumps(plot_data)
print(output)

Occasionally, the drift μ in the geometric Brownian motion is decomposed to accentuate the risk-free rate, the volatility, and the Sharpe ratio. Specifically, the Sharpe ratio of a stock index is defined as:

SR:=λ=RsRfσRs=Rf+σλ,

where σ denotes the stock index's volatility, Rf the risk-free rate, and Rs the stock index's annualized return.

This reveals that μ, representing the expected return of the stock index being modeled, is essentially the risk-free rate Rf plus a risk premium, composed of the unit of risk, i.e., volatility σ, and the reward per unit of risk, i.e., the Sharpe ratio λ.

Consequently, the geometric Brownian motion for (percentage) returns with μ replaced is:

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

Interactive Simulation of Geometric Brownian Motion

Interactive plots can be created using ipywidgets, which provides a way to generate dynamic plots. Below is a demonstration of creating interactive plots for simulating random walks:

First, the necessary library is imported:

python
import ipywidgets as widgets

The function show_gbm is designed to generate random walks by invoking the simulate_gbm_from_returns method from the pok (PortfolioOptimizationKit) toolkit. It then plots these random prices with Matplotlib. For the interactive chart embedded on this site we expose a companion helper, show_gbm_echart, which streams the simulated paths as an ECharts configuration consumed by the front-end:

python
gbm_controls = widgets.interact(
    pok.show_gbm_echart,
    n_years=(1, 10, 1),
    n_scenarios=(1, 100, 1),
    mu=(-0.30, 0.30, 0.01),
    sigma=(0.0, 0.50, 0.01),
    periods_per_year=[12, 52, 252],
    start=[100],
)

TIP

Quick widget checklist:

  • pip install ipywidgets and enable the extension (jupyter nbextension enable --py widgetsnbextension).
  • Trust the notebook (or VS Code interactive window) so the sliders can execute safely.
  • The published VitePress site already wires these controls to show_gbm_echart, so you can experiment in the browser and replicate the same behaviour locally with the cell above.
python
import ipywidgets as widgets
import PortfolioOptimizationKit as pok

widget_instance = widgets.interact(
    pok.show_gbm_echart,
    n_years=(1, 10, 1),
    n_scenarios=(1, 100, 1),
    mu=(-0.30, 0.30, 0.01),
    sigma=(0.0, 0.50, 0.01),
    periods_per_year=[12, 52, 252],
    start=[100],
)

NOTE

To use ipywidgets effectively, it's generally recommended to run this code within a Jupyter notebook environment. Jupyter provides the necessary interactive frontend to render and manipulate the widgets effectively.

Geometric Brownian Motion (GBM) is a widely adopted model for stock price behavior, especially in the Black–Scholes model for options pricing.

Arguments in favor of using GBM for stock price modeling include:

  • GBM's expected returns are independent of the value of the process (stock price), aligning with real-world expectations.
  • GBM only assumes positive values, similar to real stock prices.
  • GBM paths show a level of 'roughness' akin to actual stock price movements.
  • GBM processes are relatively straightforward to compute.

However, GBM does not fully encapsulate the complexities of real stock prices:

  • Real stock prices exhibit volatility that changes over time, sometimes stochastically, whereas GBM assumes constant volatility.
  • Real-life stock prices show jumps due to sudden news or events, but GBM assumes a continuous path.

Interactive CPPI Simulation - Monte Carlo

This segment introduces the show_cppi method from the pok toolkit, which simulates a Constant Proportion Portfolio Insurance (CPPI) investment strategy based on returns generated by Geometric Brownian Motion. It focuses on a fixed-floor scenario, specifically without a drawdown constraint. For the interactive charts embedded here, the companion helper show_cppi_echart emits the results as ECharts-ready JSON.

The interactive plot is facilitated using the interact widget:

python
import ipywidgets as widgets
import PortfolioOptimizationKit as pok

widget_instance = widgets.interact(
    pok.show_cppi_echart,
    n_years=(1, 10, 1),
    n_scenarios=(1, 300, 1),
    m=(1.0, 6.0, 0.5),
    floor=(0.0, 1.0, 0.05),
    mu=(-0.20, 0.40, 0.01),
    sigma=(0.0, 0.50, 0.01),
    risk_free_rate=(0.01, 0.05, 0.01),
    periods_per_year=[12, 52, 252],
    start=[100],
    ymax=widgets.IntSlider(value=100, min=20, max=400, step=10, description='Zoom Y axis'),
)

CPPI is a methodology ensuring a certain investment floor is preserved while allowing exposure to potential upside. The provided sliders and inputs allow users to interactively experiment with various parameters and observe their effects on the CPPI strategy's outcomes.

It is important to note that to see the interactive elements in action, running the script in a Jupyter notebook is necessary as Jupyter provides the interactive interface required for ipywidgets. Running these widgets in a standard script or terminal won't produce the interactive visualizations. For those using environments like VSCode, Jupyter notebook support is generally integrated, allowing for the execution and interaction with these widgets within the editor itself.

Key Takeaways

  • Diversification trims idiosyncratic noise but cannot tame systemic shocks—rolling statistics show correlations surging exactly when returns slump.
  • CPPI, both with static and dynamic floors, is a practical overlay for investors who must defend capital thresholds, and the toolkit makes it easy to stress those guardrails across drawdown tolerances.
  • GBM simulations bridge theory and practice: the same stochastic engine powers the deterministic charts, the interactive sliders, and the CPPI Monte Carlo experiments.
  • Reusable helpers and consistent chart scaffolding simplify extending the analysis—swap industries, add regions, or feed in alternative scenarios without rewriting the core workflow.

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