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:
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:
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.
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
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:
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:
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:
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:
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:
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
Visualization of these components is as follows:
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
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:
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.
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.
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.
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.
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.
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 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
Risky Asset Allocation
Let
where floor value, with
The multiplier is typically selected based on the acceptable drop in value. For instance, if What should be the value of $m$ to ensure the floor isn't breached?
One seeks to satisfy:
where
leading to:
This indicates that if
If
Selecting
Example: For a
meaning the multiplier should ideally be no more than
Generally, if
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.
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:
risky_rets = ind_return["2000":][["Steel","Fin","Beer"]] For the safe asset, an artificial asset is crafted, guaranteeing a
safe_rets = pd.DataFrame().reindex_like(risky_rets)
safe_rets[:] = 0.03 / 12 Initial account value (investment), floor value, and multiplier are then established:
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:
risky_wealth = start_value * (1 + risky_rets).cumprod()
print(risky_wealth.head()) The CPPI strategy is now executed:
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:
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:
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:
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:
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 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
Consider an example with a drawdown limit of
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:
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
Now, observe the behavior of the CPPI strategy when varying the maximum accepted drawdown:
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
- The process starts at zero:
; - It has independent increments, meaning future changes
, for , are independent of past values , for ; - Has Gaussian increments, meaning increments following a normal distribution with mean 0 and variance
, i.e., ; - The process has continuous paths, implying
varies smoothly over time.
Therefore,
Geometric Brownian Motion (GBM)
A stochastic process Geometric Brownian Motion if it adheres to the following stochastic differential equation (SDE):
where Brownian motion (i.e. a Wiener process), drift (trend), and volatility. Both are constants.
Returns
By dividing the equation by
TIP
In the given equation,
Here, the left side represents the percentage return.
Considering that
This substitution is valid as
The formula thus transforms into:
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:
indicating that the generated returns have a mean of
Log-Returns and Price Evolution
For log-returns, approximations using Taylor series expansions lead to:
where
The price evolution of a stock, for example, can be modeled using the GBM formula:
TIP
Considering the stochastic differential equation and dividing by
which delineates the process's evolution over time. For instance, the process
Initially, it is recognized that:
particularly when
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
Exponentiating both sides yields:
To deduce the solution, it is noted that for each unit
This equation describes the evolution of, for example, a stock price starting from an initial price
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:
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
where
This reveals that
Consequently, the geometric Brownian motion for (percentage) returns with
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:
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:
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 ipywidgetsand 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.
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:
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.
