Long/short investing is often presented as a more sophisticated form of investing: more leverage, more instruments, more degrees of freedom. In practice, this framing misses the point. If leverage alone was the objective, it would be cheaper to use derivatives or outright borrowing. If returns were the goal, then low-cost ETFs would provide exposure at a reasonable price. If access to exotic instruments was the objective, private equity or niche funds would satisfy the curiosity.
The "raison d'etre" of long/short investing is neither leverage nor access to assets. Investors engage in long/short strategies because they are seeking a return stream that behaves differently from traditional long only exposure. They want to go to bed knowing that in up, down and awful markets, their money will keep on growing. In execution trader English, the value proposition of long/short is the promise of a smoother equity curve.
A smooth equity curve is not merely an aesthetic preference. It is a structural requirement imposed by real world constraints: leverage limits, redemptions, mandate, and human behavior. Long/short investing exists to address these constraints, not to escape them.
The most robust way to achieve a smooth equity curve is to combine strategies with opposite payoffs and risk profiles. Some strategies may not even be worth running as a standalone. And yet, when combined, they can redress the equity curve in times of need. All strategies compete for capital. The logic that governs capital deployment across strategies and asset classes is called asset allocation. In this chapter, we will model returns, i.e. what strategies produce. We will run classic asset allocation algorithms. We will also introduce an asset allocation algorithm based on system failure.
In this chapter we will cover the following topics:
"Smooth" and low volatility are often used interchangeably. A portfolio can however have low volatility, post an attractively high Sharpe ratio and yet still be terminally risky if it suffers rare but catastrophic drawdowns. Conversely, a portfolio can exhibit an ugly Sharpe while remaining investable if drawdowns are shallow and recoveries are fast.
In practice, smoothness refers to the interrelated properties of drawdowns:
Long/short strategies are attractive because, in theory, they can mitigate all three. The mere fact that they have a long and a short book decorrelates with the index. By harvesting relative value rather than outright beta, long/short strategies aim to produce returns that are more evenly spaced through time. At least, this is the promise.
Like everything else in life, there is a stated reason why we do things. Then, there is a real reason, often subconscious, why we do things. The real reason why investors will park money into long/short vehicles is capital survivability. Their "raison d'etre" is the confidence that their investment will survive and potentially thrive during adverse market conditions.
Another frequently cited motivation for long/short investing is uncorrelated returns. In normal market conditions, many long/short strategies do exhibit low correlation to traditional assets. However, correlations are not static.
As the saying goes, the only thing that goes up in down markets is correlation. Liquidity constraints, forced deleveraging, and crowding effects can cause strategies that appear independent to fail simultaneously.
The true litmus test of long/short is how it behaves when correlation spikes and liquidity evaporates. Those who still stand up when everyone else is down will stand out.
Diversification, low net exposure, and low net Beta will not guarantee a smooth equity curve. Everything in life is a trade-off. Smoothing returns typically requires one or more of the following:
These mutually exclusive characteristics shape the distribution of returns far more than the choice of assets. We amply discussed in previous chapters the properties of left and right skewed strategies. This leads us to the two strategies ever traded.
In Chapter 5, we demonstrated that regardless of the asset class and timeframe, there are only two strategy archetypes: left or right skew, mean reversion or trend following in execution trader English. These dominant archetypes are defined by their exit strategy. In quant trader parlance, strategies differ along a single axis: which side of the return distribution is open ended. In retail trader English, how you chose to close the position will determine the type of strategy you trade. Let's start with left-skewed strategies.
Left-skewed strategies are characterized by frequent and consistent small gains but rare, large losses. Mean reversion is the typical example. Mean reversion strategies arbitrage inefficiencies. Once the deviation closes, there is no reason to remain in the trade. Profits are therefore capped by design.
Losses, however, are more difficult to constrain. Stop losses are often incompatible with the core premise of reversion. Inefficiencies may widen before reverting to the mean. As a result, losses tend to be endured until either reversion occurs or the position fails catastrophically.
This is what a typical distribution of returns for a left skewed strategy looks like:

Figure 9.1: Distribution of returns for a left skewed strategy
This asymmetry produces a return distribution with:
In Queen's English, the captain of the Titanic had a 99.9% win rate.
Trend following is the typical case of a right-skewed strategy. No single trade is allowed to inflict significant damage. Profits, however, have no predefined ceiling. Winning trades are allowed to persist as long as the trend is your friend. "Cut your losses short, run your winners" is the mantra.
The cost of this asymmetry is frequency. Most trades are small losers. Profits are lumpy. Drawdowns emerge from the accumulation of small losses. The distribution of returns for a right skewed strategy looks like this:

Figure 9.2: Distribution of returns for a right skewed strategy
This produces a return distribution with:
Failure occurs not through a collapse, but through the persistence of drawdown. In Queen's English, market participants expect to get rejected a lot, but they also know it takes one yes to make a lucky day.
An ideal smooth equity curve would combine the left skewed strategies to generate steady returns, and right skewed ones to provide convexity and crisis protection. These are unfortunately incompatible payoffs and risk profiles. Combining skews does not eliminate failure modes; it simply redistributes them. An ideal distribution of returns would look like this:

Figure 9.3: Distribution of returns for a combined left and right skewed strategy
It would combine the compounding of frequent small profits from the left skew with the capital appreciation of the right skew. This mode in positive territory means there would be plateaus instead of drawdowns. It would catch trends one after the other.
The combined skew portfolio would look like this:
| Attribute | Left-Skew Strategy | Right-Skew Strategy | Combined Portfolio |
|---|---|---|---|
| Typical example | Mean reversion | Trend following | Multi-strategy |
| Profit side | Capped | Open-ended | Mixed |
| Loss side | Open-ended | Capped | Mixed |
| Win frequency | > 50% | ~20-40% | Medium |
| Loss frequency | ~20-40% | > 50% | Medium |
| Dominant risk | Tail event | Persistent drawdown | Correlation & liquidity |
| Failure mode | Sudden collapse | Slow bleed | Regime coupling |
| Equity curve | Smooth until break | Choppy with convex jumps | Smooth, then stressed |
Table 9.1: Properties of left, right and combined skewed strategies
Under normal conditions, the combination may appear exceptionally smooth. Under stress, correlations spike and liquidity evaporates. This can lead to the simultaneous failure of both archetypes. Contagion is a cause of death in multi-strategy portfolios. Survivability depends on how respective failures are managed. This is what we are going to see next.
The importance of this classification is not academic. Allocation frameworks implicitly assume certain failure modes. Mean-variance optimization, for example, treats volatility as the primary risk. Furthermore, it assumes that losses are bounded, correlations stable, and assets prices convex. All of these assumptions are violated differently by the left and right skewed strategies.
Before asking how to allocate across strategies, we must first be explicit about how each strategy fails. The remainder of this chapter builds on this distinction.
"The wise man does immediately what the fool does eventually."
– Niccolo Machiavelli
Most financial simulations begin by modeling asset prices. They typically use geometric Brownian motion (GBM). This approach assumes that prices evolve continuously with constant volatility and normally distributed log returns. This is the correct mathematical answer to the wrong approach.
Geometric Brownian motion describes what markets do, not how they are traded. It is not "what" but "how" we trade that shapes the geometry of return distributions. In quant trader English, the shape of a strategy's return distribution is therefore not an intrinsic property of the underlying asset, it is an emergent property of exit strategies and risk controls. Long/short portfolios do not fail because prices follow an incorrect stochastic process, they fail because trading rules impose asymmetric constraints on profits, losses, and holding periods.
In execution trader English, trend followers and arbitrageurs will trade the same instrument with completely different sets of rules. This will produce radically different outcomes if one caps profits and tolerates losses while the other caps losses and allows profits to develop.
Since the objective of this chapter is to test portfolio construction under realistic failure modes, the simulator must operate at the strategy return level, not the price level. This is a critical distinction. Modeling prices and then layering strategies on top obscures the very dynamics that determine portfolio survival.
Strategies produce returns. We have the choice of building various strategies, running them on various markets, then aggregating the results. We would then run asset allocation algos on those results, hoping there would be enough shocks to stress test them. The alternative is to directly simulate returns and inject shocks.
To simulate realistic portfolios, we must first generate baseline return streams that reproduce the typical behavior of the two dominant strategy archetypes.
A left skewed strategy, such as mean reversion, produces a return stream with a positive mode, a high win rate, capped gains, and infrequent but potentially severe losses. Its distribution appears stable under normal conditions but contains a concentrated left tail.
A right skewed strategy, such as trend following, produces a return stream with a negative near zero mode, a low win rate, frequent small losses, and rare large gains. Its profitability depends on a small number of extreme positive outcomes, resulting in positive skew and high kurtosis.
These baseline generators are not intended to be market accurate. They are structural abstractions designed to isolate how skew alone affects portfolio behavior.
The following table summarizes the distribution of returns:
| Attribute | Mean Reversion | Trend Following |
|---|---|---|
| Return mode | Positive | Negative |
| Win rate | High | Low |
| Gains | Frequent and capped | Rare but large |
| Losses | Rare but large | Frequent and capped |
| Skew | Left (negative) | Right (positive) |
| Kurtosis | Low to moderate (platykurtic) | High (leptokurtic) |
| Expectancy driver | Many small wins | Few large winners |
Table 9.2: Properties of mean reversion and trend following strategies
Next, markets do not follow a clean Gaussian distribution. We need to introduce some shocks to the system.
Baseline skew is insufficient to explain strategy failure. Ships do not sink in fair weather. Similarly, strategies thrive in ordinary fluctuations but fail when the assumptions embedded in their trading logic are violated.
Mean reversion strategies fail when prices trend instead of reverting to the mean. They produce sudden large losses that erase long periods of steady gains. These losses are not gradual. They occur as discrete tail events. Trend following strategies fail when drawdowns persist. Trend followers make money in up and down markets, only to give it back in sideways markets.
The simulator must therefore inject explicit tail shocks with controllable probability and magnitude rather than assuming they emerge organically from Gaussian noise.
Trend following strategies experience drawdowns through extended sequences of whipsaws, where losses are small but frequent. The cumulative effect is a slow bleed that erodes both mental and financial capital.
To capture this behavior, the simulator must impose regimes in which returns are systematically suppressed for extended periods. This mimics adverse environments where a strategy's edge temporarily disappears, and critics of trend-following feel vindicated.
Portfolio level failure often occurs when diversification collapses. During periods of stress, correlation between strategies goes to 1. Losses that were previously independent become synchronized.
In these regimes, both left and right skewed strategies can fail simultaneously, albeit through different mechanisms. Mean reversion strategies suffer tail losses, while trend following strategies fail to capture burgeoning trends.
A realistic simulator must therefore include correlation spikes and exogenous shocks that force strategy returns to move together.
The simulator is not calibrated to any single market, time period, or strategy. Its outputs are not predictions. They are controlled adversarial scenarios designed to expose structural weaknesses in portfolio construction. Specifically, it is built to answer the following four questions:
| Component | Question |
|---|---|
| Skew generator | How does the strategy fail in isolation? |
| Tail injector | How does it fail suddenly? |
| Persistence | How does it fail psychologically? |
| Correlation shock | How does it fail in a portfolio? |
Table 9.3: Rationales for stress test scenarios
All functions below are intentionally simple, transparent, and modular. For the sake of simplicity, we have renamed left and right skewed strategies by their usual street name: mean reversion (MR) and trend following (TF). Without further ado, let's proceed with their algorithmic definitions.
It is now time to translate theory into practical code.
For this chapter and the rest of the book, we will be working with a few more libraries than usual. If they are not installed on your machine, you know the drill:
pip install –upgrade So, please remember to import them first:
import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.optimize import minimize from scipy.cluster.hierarchy import linkage, leaves_list from scipy.spatial.distance import squareform We have more exotic libraries from scientific python.
Let's proceed with mean reversion.
We will start with a classic left skewed strategy. We want small, consistent gains and rare large losses.
def mean_reversion_log_returns(n, p_win, p_jump, win_mean, win_std, loss_mean, loss_std, jump_min, jump_max, seed=None): p_loss = 1 - p_win - p_jump rng = np.random.default_rng(seed) u = rng.random(n) r = np.empty(n) win_mask = u < p_win loss_mask = (u >=p_win)&(u< p_win + p_loss) jump_mask = u >= p_win + p_loss r[win_mask] = rng.normal(win_mean, win_std, win_mask.sum()) r[loss_mask] = rng.normal(loss_mean, loss_std, loss_mask.sum()) r[jump_mask] = rng.uniform(jump_min, jump_max, jump_mask.sum()) return r Generating a realistic mean reversion trading strategy return series that exhibits negative skewness through a three-regime model. The function simulates daily log returns where the strategy typically wins with small profits but occasionally experiences significant losses through jump events. Here is how we do it step by step:
The function produces a time series with mean reversion characteristics:
This creates the desired negative skewness pattern typical of mean reversion strategies, where profits accumulate gradually, but losses can be sudden and substantial.
Next, let's define a trend following simulator.
We want to simulate a right skewed strategy with frequent losses and infrequent large gains.
def trend_following_log_returns(n, p_win, p_jump, win_mean, win_std, loss_mean, loss_std, jump_scale, seed=None): p_loss = 1 - p_win - p_jump rng = np.random.default_rng(seed) u = rng.random(n) r = np.empty(n) loss_mask = u < p_loss win_mask = (u >=p_loss)&(u< p_loss + p_win) jump_mask = u >= p_loss + p_win r[loss_mask] = rng.normal(loss_mean, loss_std, loss_mask.sum()) r[win_mask] = rng.normal(win_mean, win_std, win_mask.sum()) r[jump_mask] = rng.exponential(jump_scale, jump_mask.sum()) return r The function simulates daily log returns where the strategy frequently experiences small losses but occasionally captures large gains through momentum-driven jump events.
The function produces a time series with trend following characteristics:
Next, let's configure the baseline.
We will configure realistic parameters for mean reversion and trend following strategies, generate baseline return series with distinct skewness characteristics, and visualize their equity performance patterns.
Let's explain the configuration:
mr_params = dict(p_win=2/3, p_jump=0.008, win_mean=0.001, win_std=0.0001, loss_mean=-0.0015, loss_std=0.0002, jump_min= -0.02, jump_max= -0.007)tf_params = dict(p_win=1/3, p_jump=2/30, win_mean=0.0016, win_std=0.003, loss_mean=-0.0012, loss_std=0.0003, jump_scale=0.004)mr = mean_reversion_log_returns(n=5000, **mr_params, seed=1)tf = trend_following_log_returns(n=5000, **tf_params, seed=2)returns_simulations = pd.DataFrame()returns_simulations['MR'] = mrreturns_simulations['TF'] = tfplt.figure(figsize=(12, 4))plt.plot(np.exp(np.cumsum(mr)), label="MR", linestyle='-', linewidth=2, color='orange')plt.plot(np.exp(np.cumsum(tf)), label="TF", linestyle='-', linewidth=2, color='gray')plt.title("Left & Right Skewed Strategies Cumulative Returns")plt.xlabel("Time (Days)")plt.ylabel("Equity (Normalized)")plt.legend()plt.grid(alpha=0.3)plt.tight_layout()plt.show()returns_simulations.hist(bins=100, figsize=(10, 3), alpha=0.7, edgecolor='blue')plt.suptitle('Returns Simulations Histograms', fontsize=16)plt.tight_layout()plt.show() This can appear a bit disconcerting at first. Let's explain what we just did:
This generates the following charts:

Figure 9.4: MR and TF baseline cumulative returns
This produces baseline simulations demonstrating contrasting risk profiles. In financial creole, mean reversion grinds some "steady Eddy returns", unimpressive but consistent with occasional sharp drops. Meanwhile, trend following exhibits a more volatile path with occasional large upward moves, followed by long periods of humbling returns.
All parameters are explicit, so feel free to draw the shapes that most resemble your vision. Let's take a look at the distribution of returns.

Figure 9.5: MR and TF baseline returns distributions
This is consistent with the pictures shown at the beginning of the chapter. This serves as a decent basis for analysis.
Next, we will inject shocks. Mean reversion and trend following fail differently. Here is a small table that recaps the specific failure modes.
| Strategy | Failure mode | Shock type |
|---|---|---|
| Mean reversion | Liquidity / collapse | Single-day jump (replace) |
| Trend following | Whipsaw / regime decay | Drawdown persistence |
Table 9.4: Failure mode by strategy
Let's start with the left tail of mean reversion.
Simply said, mean reversion crashes.
def inject_shocks(returns, p_shock, shock_min, shock_max, mode="replace", seed=None): rng = np.random.default_rng(seed) shocked = returns.copy() events = rng.random(len(returns)) < p_shock shocks = rng.uniform(shock_min, shock_max, len(returns)) if mode == "replace": # "replace" or "add" shocked[events] = shocks[events] elif mode == "add": shocked[events] += shocks[events] else: raise ValueError("mode must be 'replace' or 'add'") return shocked Injecting market shocks into existing return series to model structural breakdowns, liquidity crises, or other tail risk scenarios.
The function produces a return series with injected extreme events that simulate:
Using a fighting sports analogy, mean reversion strategies fail by way of knockout. Meanwhile, trend following strategies lose on points.
Next, we will inject persistent drawdowns in the trend following strategies.
Trend following strategies die by a thousand paper cuts. We will simulate a slow bleed.
def inject_tf_drawdown_regime(returns, p_regime, duration, dd_mean, dd_std, seed=None): rng = np.random.default_rng(seed) shocked = returns.copy() n = len(returns) t = 0 while t < n: if rng.random() < p_regime: d = min(duration, n - t) shocked[t:t+d] = rng.normal(dd_mean, dd_std, d) t += d else: t += 1 return shocked This function simulates periods when trend following strategies experience prolonged drawdown due to choppy markets, false signals, or regime changes.
The function produces a return series with realistic trend following drawdown characteristics:
This models the reality that trend following strategies struggle in range-bound or highly volatile markets with frequent false breakouts.
At this stage, let's plot both strategies and how they fail individually.
We have charted baselines and defined how they respectively failed. Let's plot this on a graph.
mr_collapse_params = dict(p_shock=0.0025, shock_min=-0.015, shock_max=-0.008, mode="replace") tf_drawdown_params = dict(p_regime=0.006, duration=20, dd_mean=-0.0004, dd_std=0.0006 ) mr_collapse = inject_shocks(mr, **mr_collapse_params, seed=10) tf_dd = inject_tf_drawdown_regime(tf, **tf_drawdown_params, seed=20) returns_simulations['MR collapse'] = mr_collapse returns_simulations['TF drawdown'] = tf_dd plt.figure(figsize=(12, 4)) plt.plot(np.exp(np.cumsum(mr)), label="MR", linestyle='-', linewidth=2, color='orange') plt.plot(np.exp(np.cumsum(tf)), label="TF", linestyle='-', linewidth=2, color='gray') plt.plot(np.exp(np.cumsum(mr_collapse)), label="MR collapse", linewidth=1.5, color='orange',alpha=0.7) plt.plot(np.exp(np.cumsum(tf_dd)), label="TF drawdown", linewidth=1.5, color='gray', alpha= 0.7) plt.title("Left & Right Skewed Strategies baselines and stressed") plt.xlabel("Time (Days)") plt.ylabel("Equity (Normalized)") plt.legend() plt.grid(alpha=0.3) plt.tight_layout() plt.show() returns_simulations.hist(bins=100, figsize=(10, 3), alpha=0.7, edgecolor='blue') plt.suptitle('Returns Simulations Histograms', fontsize=16) plt.tight_layout() plt.show() Injecting collapse events into mean reversion returns and persistent drawdown regimes into trend following returns to model realistic failure modes. We then plot baseline and stressed charts
This produces the following chart:

Figure 9.6: MR and TF baselines and stressed
This really captures the specific failures of each strategy. Trend following stagnates and exhausts market participants patience. Mean reversion tanks and does not recover.
Next, let's see what happens when both fail simultaneously.
We will introduce two types of shocks. First, we will have a brutal macro shock, something akin to the 1995 Kobe earthquake. This natural catastrophe revealed the monumental trading losses of superstar trader Nick Leeson, which ultimately led to the bankruptcy of the venerable Barings bank. Then, we will simulate a liquidity event (crowded trade). This is a more frequent event, with a fairly high probability of 1% and moderate impact, to simulate high slippage and transaction costs.
macro_shock = dict(p_shock=0.002, shock_min=-0.02, shock_max=-0.01, mode="replace") mr_macro = inject_shocks(mr, **macro_shock, seed=99) tf_macro = inject_shocks(tf, **macro_shock, seed=99) crowded_shock = dict(p_shock=0.01, shock_min=-0.0075, shock_max=-0.004, mode="replace") mr_crowded = inject_shocks(mr, **crowded_shock, seed=10) tf_crowded = inject_shocks(tf, **crowded_shock, seed=10) returns_simulations['MR collapse'] = mr_collapse returns_simulations['TF drawdown'] = tf_dd returns_simulations['MR macro'] = mr_macro returns_simulations['TF macro'] = tf_macro returns_simulations['MR crowded'] = mr_crowded returns_simulations['TF crowded'] = tf_crowded plt.figure(figsize=(12, 4)) plt.plot(np.exp(np.cumsum(mr)), label="MR", linestyle='-', linewidth=2, color='orange') plt.plot(np.exp(np.cumsum(tf)), label="TF", linestyle='-', linewidth=2, color='gray') plt.plot(np.exp(np.cumsum(mr_collapse)), label="MR collapse", linewidth=1.5, color='orange', alpha=0.7) plt.plot(np.exp(np.cumsum(tf_dd)), label="TF drawdown", linewidth=1.5, color='gray', alpha=0.7) plt.plot(np.exp(np.cumsum(mr_macro)), label="MR macro", linestyle='--', linewidth=1, color='orange', alpha=0.5) plt.plot(np.exp(np.cumsum(tf_macro)), label="'TF macro'", linestyle='--', linewidth=1, color='gray', alpha=0.5) plt.plot(np.exp(np.cumsum(mr_crowded)), label="MR crowded", linestyle=':', linewidth=1.3, color='orange', alpha=0.6) plt.plot(np.exp(np.cumsum(tf_crowded)), label="TF crowded", linestyle=':', linewidth=1.3, color='gray', alpha=0.6) plt.title("Left & Right Skewed Strategies multiple stresses") plt.xlabel("Time (Days)") plt.ylabel("Equity (Normalized)") plt.legend() plt.grid(alpha=0.3) plt.tight_layout() plt.show() returns_simulations.hist(bins=100, figsize=(10, 3), alpha=0.7, edgecolor='blue') plt.suptitle('Returns Simulations Histograms', fontsize=16) plt.tight_layout() plt.show() Applying systematic market shock scenarios to baseline strategies and creating a comprehensive visualization of all strategy variants.
This produces a chart for all cumulative returns under each scenario, demonstrating how different shock types affect strategy performance.

Figure 9.7: MR and TF baselines and multiple stresses
The baseline strategies show distinct skewness patterns; targeted stresses reveal strategy-specific vulnerabilities; while systematic shocks demonstrate correlation effects during market crises.
This step-by-step construction may seem redundant. As a practitioner, it is, however, essential to have a precise image in mind of how strategies fail. This is the reality of markets. This is what you will encounter.
This simulator does not attempt to model asset prices, but how strategies fail. With these components, we can now test allocation frameworks against:
Before we move to asset allocation, let's recap a little bit. Our ultimate objective is to produce a robust, smooth equity curve. We know that there are two archetypes: mean reversion or trend following. We understand that robustness comes from trend following and smoothness from mean reversion. So, we must trade both to achieve our objective. We have finite resources, so the next logical step is to find the optimal way to allocate capital to each strategy. That is the problem addressed in the next section. We will run simplified versions of some classic asset allocation algorithms. We will test whether classical allocation methods respect these failure geometries.
"In life as in war, nothing happens as you expect it to."
Attributed to Robert Greene
Classic asset allocation is usually presented as an optimization problem: How do we allocate resources to distribute risk efficiently? Whether framed as mean–variance optimization, risk parity, or diversification maximization, the underlying goal is the same. We want to construct a portfolio that achieves the best possible trade-off between expected returns and volatility. This framing carries three implicit assumptions:
These assumptions are not stated explicitly. They are simply embedded in the mathematics. They are also the reason classic allocation frameworks appear elegant, tractable, and broadly applicable.
Traditional allocation theory is built on asset prices, not on how market participants actually trade those assets. It treats returns as distributions to be optimized rather than as the outcome of strategies with specific structural weaknesses.
The critical question classic asset allocation theory does NOT ask is: How do portfolios fail?
This omission matters little when all assets share broadly similar statistical properties (roughly convex returns, limited tail risk, and modest correlations during stress). In that world, variance is a reasonable proxy for risk, and rebalancing improves outcomes.
Things get a lot more complicated when combining strategies with opposite payoffs and risks. Classic allocation frameworks implicitly treat both as interchangeable "assets" whose risks can be summarized by covariance matrices. In doing so, they obscure the very information that matters most under stress. Variance is not risk. Ruin is risk. We have simulated ruin for both archetypes. We will now run classic asset allocation algorithms and observe how they perform when strategies fail. We will sequentially go through:
Asset allocation is a fascinating topic, often misunderstood. Every algorithm has some philosophical underpinning of risk translated into a practical mathematical formula. Every algorithm that has survived the test of time has some theoretical merit as well as empiric validity.
Now, we have taken a completely different approach. Rather than debating these issues abstractly, we made them explicit through simulation. We implemented a set of standard asset allocation methodologies. We will start with the naivest methodology.
This is self-explanatory and will be recaptured in the simulation loop.
if algo_name == 'EW': # Equal weight is static ew = np.array([0.5, 0.5]) aligned_returns = pair_returns.iloc[51:] portfolio_returns = (aligned_returns * ew).sum(axis=1) * gross_exposure Mean-variance is the granddaddy of asset allocation algorithms. It was introduced by Harry Markowitz in 1952. It was the first formal theory to treat portfolio risk mathematically rather than intuitively. It frames investing as a trade-off between return (the mean) and uncertainty (the variance), defining the efficient frontier of portfolios that offer the highest return for each level of risk. MeanVar's Achilles' heel is its sensitivity to noisy estimates: Small variances that can lead to extreme allocations.
def mean_variance_weights(returns, risk_aversion=1.0, regularization=1e-5):mu = returns.mean(axis=0)cov = np.cov(returns, rowvar=False)cov = cov + np.eye(cov.shape[0]) * regularizationif returns.shape[0] < returns.shape[1] + 2:return np.ones(returns.shape[1]) / returns.shape[1]try:inv_cov = np.linalg.pinv(cov)raw = inv_cov @ muif np.sum(np.abs(raw)) == 0:return np.ones(len(raw)) / len(raw)weights = raw / np.sum(np.abs(raw))return weightsexcept (np.linalg.LinAlgError, ValueError):return np.ones(returns.shape[1]) / returns.shape[1]def rolling_mean_variance(returns, window=200, min_periods=100): weights_list = [] for i in range(min_periods, len(returns)): w = mean_variance_weights(returns.iloc[max(0, i-window):i].values) weights_list.append(w) return pd.DataFrame(weights_list, index=returns.index[min_periods:], columns=returns.columns) Computing optimal portfolio weights using mean variance, normalized to allow both long and short positions.
This returns a rolling Mean-variance weights, or equal weights.
Risk parity was developed in the late 1990s by Ray Dalio and his team at Bridgewater. It reframed portfolio construction by shifting focus from returns to risk contributions. Instead of allocating capital, it allocates risk equally across assets, so no single asset dominates portfolio volatility. This approach proved especially attractive after repeated market crises revealed the fragility of return forecasts. RP limitation is that it treats correlations only indirectly. In a nutshell, we allocate capital inversely proportional to volatility while allowing longs and shorts.
def risk_parity_weights(returns): mu = returns.mean(axis=0) vols = returns.std(axis=0) if returns.shape[0] < 2: return np.ones(returns.shape[1]) / returns.shape[1] if np.any(vols == 0): vols = np.where(vols == 0, 1e-10, vols) raw = np.sign(mu) * (1 / vols) if np.sum(np.abs(raw)) == 0: return np.ones(len(raw)) / len(raw) weights = raw / np.sum(np.abs(raw)) return weightsdef rolling_risk_parity(returns, window=200, min_periods=50): weights_list = [] for i in range(min_periods, len(returns)): w = risk_parity_weights(returns.iloc[max(0, i-window):i].values) weights_list.append(w) return pd.DataFrame(weights_list, index=returns.index[min_periods:], columns=returns.columns) This elegant formula has been adapted for the long-short world:
and standard deviations σ from historical data.This returns weights where lower-volatility assets receive bigger allocations, while allowing both long and short positions.
Minimum variance is the direct descendant of Markowitz's 1950s work. It focuses solely on reducing portfolio volatility rather than forecasting returns. It selects the asset weights that produce the lowest possible variance given the covariance structure. The appeal is robustness. By avoiding return estimates, it sidesteps the noisiest input. The drawback is that it can concentrate heavily in low-volatility, highly correlated assets. In execution trader English, MinVar sacrifices returns for low volatility.
def min_variance_weights(returns, regularization=1e-5): cov = np.cov(returns, rowvar=False) cov = cov + np.eye(cov.shape[0]) * regularization if returns.shape[0] < returns.shape[1] + 2: return np.ones(returns.shape[1]) / returns.shape[1] try: inv_cov = np.linalg.pinv(cov) ones = np.ones(cov.shape[0]) raw = inv_cov @ ones if np.sum(np.abs(raw)) == 0: return np.ones(len(raw)) / len(raw) weights = raw / np.sum(np.abs(raw)) return weights except (np.linalg.LinAlgError, ValueError): return np.ones(returns.shape[1]) / returns.shape[1] def rolling_min_variance(returns, window=200, min_periods=100): weights_list = [] for i in range(min_periods, len(returns)): w = min_variance_weights(returns.iloc[max(0, i-window):i].values) weights_list.append(w) return pd.DataFrame(weights_list, index=returns.index[min_periods:], columns=returns.columns) Minimum Variance is explained as follows:
This returns weights that minimize portfolio variance without regard to return expectations. Tends to concentrate on low-volatility, low-correlation assets.
We have all heard of the famous market nugget of wisdom: diversification is the only free lunch in finance. Yves Choueifaty brilliantly translated this aphorism into an actual asset allocation algorithm in 2008: maximum diversification (MaxDivers). The objective was to maximize diversification to avoid correlation during crisis. The idea is to exploit imperfect correlations so the whole is less risky than its parts. It avoids explicit return forecasts while actively using the correlation structure. Its weakness is sensitivity to covariance estimation, similar to other variance-based methods.
def max_diversification_weights(returns, regularization=1e-5): vols = returns.std(axis=0) if returns.shape[0] < returns.shape[1] + 2: return np.ones(returns.shape[1]) / returns.shape[1] try: corr = np.corrcoef(returns, rowvar=False) corr = (corr + corr.T) / 2 corr = np.clip(corr, -0.9999, 0.9999) cov = np.diag(vols) @ corr @ np.diag(vols) cov = cov + np.eye(cov.shape[0]) * regularization inv_cov = np.linalg.pinv(cov) raw = inv_cov @ vols if np.sum(np.abs(raw)) == 0: return np.ones(len(raw)) / len(raw) weights = raw / np.sum(np.abs(raw)) return weights except (np.linalg.LinAlgError, ValueError): return np.ones(returns.shape[1]) / returns.shape[1] def rolling_max_diversification(returns, window=200, min_periods=100): weights_list = [] for i in range(min_periods, len(returns)): w = max_diversification_weights(returns.iloc[max(0, i-window):i].values) weights_list.append(w) return pd.DataFrame(weights_list, index=returns.index[min_periods:], columns=returns.columns) Let's explain maximum diversification step-by-step:
Maximum Sharpe asset allocation is another variation on the work of Markowitz and Tobin. Standard deviation is the square root of variance after all. It targets the portfolio with the highest Sharpe ratio. Conceptually, it represents the best risk-adjusted bet available in the market. In theory, it is powerful. In practice, it is fragile.
def max_sharpe_weights(returns, regularization=1e-5): mu = returns.mean(axis=0) cov = np.cov(returns, rowvar=False) cov = cov + np.eye(cov.shape[0]) * regularization if returns.shape[0] < returns.shape[1] + 2: # Fall back to equal weight if insufficient data return np.ones(returns.shape[1]) / returns.shape[1] try: inv_cov = np.linalg.pinv(cov) raw = inv_cov @ mu if np.sum(np.abs(raw)) == 0: return np.ones(len(raw)) / len(raw) weights = raw / np.sum(np.abs(raw)) return weights except (np.linalg.LinAlgError, ValueError): return np.ones(returns.shape[1]) / returns.shape[1] def rolling_max_sharpe(returns, window=200, min_periods=100): weights_list = [] for i in range(min_periods, len(returns)): w = max_sharpe_weights(returns.iloc[max(0, i-window):i].values) weights_list.append(w) return pd.DataFrame(weights_list, index=returns.index[min_periods:], columns=returns.columns) The explanation is straightforward:
This function returns weights on the tangency portfolio, maximizing risk-adjusted returns.
Marcos López de Prado introduced Hierarchical Risk Parity (HRP) in 2016. It was designed as a practical fix to the instability of classical optimization. It uses hierarchical clustering to group correlated assets and then allocates risk recursively across clusters rather than across individual assets. By avoiding matrix inversion, it is far more robust to estimation error and noisy data. Conceptually, it treats markets as structured systems, not flat lists of assets.
HRP builds portfolios by:
It is computationally intensive. We have broken it down into a few functions. We will first define the clustering and recursive bisectional functions.
def get_cluster_var(cov, cluster_items): cov_slice = cov[np.ix_(cluster_items, cluster_items)] w = 1 / np.diag(cov_slice) w = w / w.sum() return w @ cov_slice @ w def hrp_recursive_bisect(cov, sort_ix): w = np.ones(len(sort_ix)) clusters = [sort_ix] while len(clusters) > 0: clusters = [c[start:end] for c in clusters for start, end in ((0, len(c)//2), (len(c)//2, len(c))) if len(c) > 1] for i in range(0, len(clusters), 2): if i + 1 < len(clusters): c0, c1 = clusters[i], clusters[i+1] v0 = get_cluster_var(cov, c0) v1 = get_cluster_var(cov, c1) alpha = 1 - v0 / (v0 + v1) w[c0] *= alpha w[c1] *= 1 - alpha return w We will now define the weight and rolling average functions.
def hrp_weights(returns): # Check if we have enough data if returns.shape[0] < returns.shape[1] + 2: # Fall back to equal weight if insufficient data return np.ones(returns.shape[1]) / returns.shape[1] try: cov = np.cov(returns, rowvar=False) corr = np.corrcoef(returns, rowvar=False) corr = (corr + corr.T) / 2 corr = np.clip(corr, -0.9999, 0.9999) dist = np.sqrt(np.maximum((1 - corr) / 2, 0)) # Ensure non-negative np.fill_diagonal(dist, 0) dist = (dist + dist.T) / 2 # Ensure symmetry # Hierarchical clustering condensed = squareform(dist) link = linkage(condensed, method='single') sort_ix = leaves_list(link) weights = hrp_recursive_bisect(cov, sort_ix) mu = returns.mean(axis=0) weights = weights * np.sign(mu) if np.sum(np.abs(weights)) == 0: return np.ones(len(weights)) / len(weights) weights = weights / np.sum(np.abs(weights)) return weights except (np.linalg.LinAlgError, ValueError, FloatingPointError): return np.ones(returns.shape[1]) / returns.shape[1] def rolling_hrp(returns, window=200, min_periods=100): weights_list = [] for i in range(min_periods, len(returns)): w = hrp_weights(returns.iloc[max(0, i-window):i].values) weights_list.append(w) return pd.DataFrame(weights_list, index=returns.index[min_periods:], columns=returns.columns) HRP is sophisticatedly elegant. Here is how it works:
This returns weights that respect hierarchical correlation structure without matrix inversion. It is more stable than traditional methods, it allows shorts via return signs, and it is robust to estimation error.
Next, we will run the classic asset algorithms across 6 strategy permutations.
Each allocator is implemented in rolling form, using a 250-day lookback window. Allocations were recomputed dynamically, exactly as they would be in a live portfolio. We then subjected these allocators to a range of controlled scenarios designed to expose failure modes rather than average behavior:
Each simulation started with:
For each combination of allocator and scenario, we computed:
We then overlaid equity curves across allocators, not to rank performance, but to observe how capital was lost.
# Unified allocation comparison pairs = [('MR', 'TF'), ('MR collapse', 'TF drawdown'), ('MR collapse', 'TF'), ('MR', 'TF drawdown'), ('MR macro', 'TF macro'), ('MR crowded', 'TF crowded')] allocation_methods = {'EW': lambda r, w: np.array([0.5, 0.5]), 'MeanVar': rolling_mean_variance, 'MaxSharpe': rolling_max_sharpe, 'RP': rolling_risk_parity, 'HRP': rolling_hrp, 'MinVar': rolling_min_variance, 'MaxDiv': rolling_max_diversification} gross_exposure = 2.0 initial_capital = 1_000_000 window = 250 equity_df = pd.DataFrame() for col in returns_simulations.columns: standalone_returns = returns_simulations[col].iloc[51:] equity_df[col] = initial_capital * (1 + standalone_returns * gross_exposure).cumprod() for mr_col, tf_col in pairs: pair_returns = returns_simulations[[mr_col, tf_col]] pair_name = f"{mr_col} / {tf_col}" for algo_name, algo_func in allocation_methods.items(): if algo_name == 'EW': # Equal weight is static ew = np.array([0.5, 0.5]) aligned_returns = pair_returns.iloc[51:] portfolio_returns = (aligned_returns * ew).sum(axis=1) * gross_exposure else: # Rolling allocation methods weights = algo_func(pair_returns, window=window) weights_shifted = weights.shift(1).dropna() aligned_returns = pair_returns.loc[weights_shifted.index] portfolio_returns = (weights_shifted * aligned_returns).sum(axis=1) * gross_exposure equity = initial_capital * (1 + portfolio_returns).cumprod() col_name = f"{pair_name} {algo_name}" equity_df[col_name] = equity equity_df.columns.tolist() Let's go through this block of code step-by-step:
This produces unified equity DataFrame enabling direct comparison of allocation algorithms across stress scenarios, revealing which methods are robust to either strategy-specific failures (collapse, drawdown) and shocks (macro, liquidity crisis).
Next, let's plot a few charts to visualize the results.
algo_names = list(allocation_methods.keys()) # Plot by pair (comparing all algorithms + DEA for each pair) for mr_col, tf_col in pairs: pair_name = f"{mr_col} / {tf_col}" plt.figure(figsize=(14, 4)) # Baselines for col, ls in [(mr_col, '-'), (tf_col, '--')]: baseline = returns_simulations[col].iloc[window+1:] equity = initial_capital * (1 + baseline * gross_exposure).cumprod() plt.plot(equity.index, equity.values, label=f'{col} standalone', color='gray', alpha=0.4, linewidth=1.5, linestyle=ls) # Classic algorithms for algo in algo_names: if (col := f"{pair_name} {algo}") in equity_df.columns: equity_df[col].plot(label=algo, markersize=5) # DEA if (dea_col := f"{pair_name} DEA tol=0.12 linear") in equity_df.columns: equity_df[dea_col].plot(label='DEA (tol=0.12)', color='red', linestyle='-', linewidth=2.5, marker='*', markevery=200, markersize=8) plt.title(pair_name) plt.legend(loc='upper left', fontsize=8, ncol=3) plt.grid(alpha=0.3) plt.ylabel('Equity') plt.xlabel('Time') plt.tight_layout() plt.show() Charting blocks of code tend to be verbose, yet easy to understand:
This produces six comparison charts (one per stress scenario pair) showing how asset allocation algos perform relative standalone strategies.

Figure 9.8: Asset allocation algorithms equity curves under baseline scenarios MR and TF
This is the default. The baselines MR and TF are pale light grey.
Let's plot when drawdown persists and mean reversion collapses

Figure 9.9: Asset allocation algorithms equity curves under baseline scenarios MR collapse and TF drawdown
The most striking result is also the simplest: Equal weight outperformed every other allocation method across stress scenarios. This is not because equal weight is optimal, but because it is structurally naive. It does not attempt to infer risk from unstable statistics and therefore does not rebalance aggressively into failure modes.
The more sophisticated the allocator, the worse it performed under stress. This is probably not entirely accidental. The limitations are well documented in academic literature, but here are small empiric findings:
All algos underperform the baseline trend following. Maximum Sharpe, risk parity and HRP even underperformed mean reversion. Those sophisticated algos did not merely fail to prevent drawdowns; they amplified them. As left skewed strategies enter pre-failure regimes, their recent volatility remains low. Allocators interpret this as low risk and increase allocation, precisely when survivability should have dictated the opposite. At the same time, right skewed strategies are penalized for their drawdowns, despite those drawdowns being the cost of convexity.
No amount of strategy level risk management could prevent this outcome, because the failure occurs at the portfolio level. This explains why some practitioners resort to manually unplug their algos during crisis.
Classic allocation frameworks optimize expected outcomes. They do not optimize paths. But paths are what determine survival.
This leads to a fundamental inversion of priorities: survivability precedes optimality. Before asking how to allocate capital efficiently, one must decide which failures are acceptable and which are existential. Allocation is therefore not a question of efficiency. It is a question of choice.
Once framed correctly, asset allocation becomes less mysterious and more honest.
Every allocation allows certain failure modes and forbids others. Classic frameworks make these choices implicitly. In portfolios that combine left and right skewed risks and payoffs, those assumptions do not hold well as we just saw. Allocation must therefore be based not on abstract notions of risk, but on explicit recognition of how strategies fail.
Classic asset allocation algorithms assume survival. Meanwhile, every strategist has agonized over decommissioning loss-making strategies. In crisis time, managers override the algorithms precisely to trade another day. When push comes to shove, survival is the name of the game. This realization sets the stage for the next section, where allocation is rebuilt around failure modes.
" The purpose of knowledge is action, not knowledge. "
– attributed to Aristotle
So, far, we have explored various asset allocation algorithms. We have submitted them to various stress scenarios. The victorious algorithm was also the least sophisticated. In the absence of better judgment, let's split everything 50/50.
Deep down, we instinctively know that the sole justification in asset allocation is behavior under stress. After all, who cares about asset allocation when everything is working fine if we give it all back when markets misbehave?
Let's see if we can come up with a different solution. Let's start by stating the problem we are solving. We want a capital allocation system that:
This is a tall order. This is not about minimizing volatility, maximizing Sharpe, or even returns. This is about survival. We want to live to trade another day. So, let's start with the architecture of the system.
This system cleanly separates into five layers. First, we estimate the geometry of failure for each strategy. We consider the elasticity of the system at its peak. We apply a risk appetite oscillator to target exposures.
Each strategy has a specific mode of failure. Either it collapses, or it bleeds out.
def upper_band_limit(st_dd, lt_dd, corr_adj, lt_dd_tolerance, st_dd_tolerance, lqdty_haircut): min_dd = min(st_dd_tolerance/st_dd, lt_dd_tolerance/lt_dd) upper_band = round(min(min_dd * (1 - corr_adj), 1) * (1-lqdty_haircut), 2) return upper_band This gives us the maximum allocation acceptable for each strategy. Even if strategies fail, we should be able to survive. Those are hard upper limits. Remember that left skewed strategies often go out of business because they over-leverage. Note that the total can go above 1, as we will see next.
Sometimes the gods of the markets smile upon us. Both strategies fire on all cylinders. Equity is close to an all-time-high. We know we leave money on the table. We want to beef up some positions here and there, and yet we know better than to forego disciplined risk management.
The upper limits for trend following and mean reversion are based on their respective failure geometry. They do not have to add up to 1. If everything works, we can allow for a little bit of elasticity. Let's take a practical example. The current setting returns:
upper_band_MR = 0.3 ; upper_band_TF = 0.86
upper_band_limit = upper_band_MR + upper_band_TF = 0.3 + 0.86 = 1.16
gross_exposure_limit = upper_band_limit * 200% = 1.16 * 200 % = 232%
This means that the maximal gross exposure can go to 232%. This is not unconstrained leverage. Both systems operate within their acceptable failure. Elasticity is only possible when both strategies perform, equity is close to an all-time-high, and risk oscillator is at peak. This is an endogenous leverage envelope derived from:
This is much stronger than an arbitrary leverage cap. This is the maximum elasticity of gross exposure that diversification can justify. This is derived from failure analysis, not optimism. We simply harvest that little bit of extra juice from the market.
Should anything happen, the gross exposure will be reduced, as we will see next.
There is one universal truth in the asset management business. People react much more strongly to losses than profits, as the Nobel prize winners Kahneman and Tversky demonstrated with the Prospect Theory in 1979. As such, this risk appetite oscillator is the dominant control variable. It will collapse risk as soon as a drawdown widens.
We unveiled the risk appetite oscillator in . We set a drawdown tolerance. Capital will be aggressively pulled back as equity recedes from its highs. In Queen's English, we trim sail when a storm looms.
def risk_appetite(equity_curve, max_drawdown_tolerance, min_risk, max_risk, smoothing_span=20, curve_shape='linear', drawdown_window=0): """ curve_shape : {'linear', 'aggressive', 'conservative'}, default='linear' 'aggressive': Scale up quickly when recovering (convex < 1) 'conservative': Scale up slowly when recovering (concave > 1) 'linear': Proportional response """ equity = pd.Series(equity_curve) if drawdown_window > 0: running_max = equity.rolling(window=drawdown_window, min_periods=1).max() else: running_max = equity.expanding().max() # All-time high drawdown = equity / running_max - 1 normalized = 1 - np.minimum(drawdown / max_drawdown_tolerance, 1) smoothed = normalized.ewm(span=smoothing_span).mean() # mapping: convex (<1 aggressive), concave (>1 conservative) power_map = {'aggressive': min_risk / max_risk, 'conservative': max_risk / min_risk, 'linear': 1} power = power_map.get(curve_shape, 1) transformed = smoothed ** power risk_appetite = min_risk + (max_risk - min_risk) * transformed return risk_appetite This sets the maximum gross market value, or gross exposure as:
In practice, market practitioners will probably opt for tighter min and max exposures. It is costly to trim exposure in times of crisis.
At the end of the day, what any market practitioner wants is to make hay when the sun shines and protect capital when nothing seems to work. This means taking smaller bets. The exposure allocation is built for that. Let's try and assess the potential maximum drawdown. Here is a back of the envelope mathematical calculation drawdown.
For log-equity processes, max drawdown:

Where:



Our time horizon T is one year in a multi-strategy system, standard regime-scale.
Then, we plug in our numbers:



That DDmax is the short-term drawdown type, not the entire peak-to-trough. Empirically, peak-to-trough tends to be longer and more protracted, 2–3× this number.
The difference between exposure allocation and other systems is exposure management. Classic algos assume static leverage through thick and thin. Exposure allocation aggressively cuts leverage as soon as equity rolls over. This happens before the worst of the drawdown accrues. This should keep realized drawdowns toward the lower end of the range. So, instead of a kiss of death of 30%, drawdowns should hover between 23% to 27%.
Mean reversion is supposed to compensate for the protracted stagnation of trend following. Meanwhile, the right skewed strategy is supposed to perform when the left skewed one collapses. It would be regrettable to punish one for the sins of the other. So, we have come up with a cute little function called temporary boost.
When one strategy performs and the other fails, boost its allocation within its acceptable upper limit. When both strategies perform or fail, turn the boost off.
Since trend following has a heavy weight and has long periods of underwhelming performance, it makes sense to give a little boost to its contribution. We use a rolling monthly average to avoid chaotic exposure fluctuations.
def temporary_boost(series1, series2, boost_val, duration): cond1 = series1.pct_change(duration) < 0 cond2 = series2.pct_change(duration) >= 0 temp_boost = np.where(cond1 & cond2, boost_val, 1) return temp_boost We boost exposures from time to time. However, we want to make sure risk does not exceed the acceptable failure tolerance. So, we clip to the upper limit.
def calculate_raw_weight(upper_band, boost, gross_exposure): return upper_band * boost * gross_exposure def clip_weight(weight, upper_band, min_exposure, max_exposure): return np.clip(weight, upper_band * min_exposure, upper_band * max_exposure) All those layers may seem a bit tedious. However, they make intuitive sense. Next, let's define the algorithm.
Unlike the previous algorithms, this one is recursive. It uses equity[t-1] to calculate the equity[t]. It is inherently slower to calculate. This function acts as a wrapper. Practitioners will break it apart to fit their own system.
Exposure allocation operates recursively through time, using equity[t-1] to determine position sizing at equity[t]. At each step, the algorithm calculates gross exposure via the risk oscillator, applies strategy-specific upper bands and temporary boosts, clips weights to acceptable ranges, computes portfolio returns, and compounds equity forward. This recursive structure captures path dependency: today's allocation depends on yesterday's drawdown state. It makes the system slower but more responsive to realized risk. The complete implementation follows:
def calculate_portfolio_return(w_MR, w_TF, mr_return, tf_return):return w_MR * mr_return + w_TF * tf_returndef exposure_allocation(mr_returns, tf_returns, initial_capital, upper_band_MR, upper_band_TF, min_exposure, max_exposure, k, risk_params, curve_shape='linear', mr_boost_val=2.0, tf_boost_val=1.1): n = len(mr_returns) equity = np.full(n, np.nan, dtype=float) equity[0] = initial_capital w_MR_series = np.full(n, np.nan, dtype=float) w_TF_series = np.full(n, np.nan, dtype=float) portfolio_returns = np.full(n, np.nan, dtype=float) gross_exp = np.full(n, np.nan, dtype=float) mr_boost_series = temporary_boost(tf_returns, mr_returns, mr_boost_val, k) tf_boost_series = temporary_boost(mr_returns, tf_returns, tf_boost_val, k) for t in range(1, n): gross_exposure = risk_appetite(equity[:t], curve_shape=curve_shape, **risk_params).iloc[-1] gross_exp[t] = gross_exposure w_MR = calculate_raw_weight(upper_band_MR, mr_boost_series[t], gross_exposure) w_TF = calculate_raw_weight(upper_band_TF, tf_boost_series[t], gross_exposure) w_MR = clip_weight(w_MR, upper_band_MR, min_exposure, max_exposure) w_TF = clip_weight(w_TF, upper_band_TF, min_exposure, max_exposure) w_MR_series[t] = w_MR w_TF_series[t] = w_TF portfolio_returns[t] = calculate_portfolio_return(w_MR, w_TF, mr_returns.iloc[t], tf_returns.iloc[t]) equity[t] = equity[t-1] * (1 + portfolio_returns[t]) return pd.DataFrame({'equity': equity, 'w_MR': w_MR_series, 'w_TF': w_TF_series, 'portfolio_returns': portfolio_returns, 'gross_exposure': gross_exp, 'mr_boost': mr_boost_series, 'tf_boost': tf_boost_series}, index=mr_returns.index) We implement the core DEA algorithm.
This produces allocation history with dynamic risk scaling.
Next, let's run the algorithm across multiple configurations.
bands_params = dict(st_dd_tolerance = 0.05, lt_dd_tolerance = 0.20, lqdty_haircut = 0.1) mr_bands_params = dict(st_dd = 0.06, lt_dd = 0.18, corr_adj = 0.6) tf_bands_params = dict(st_dd = 0.035, lt_dd = 0.25, corr_adj = - 0.2) upper_band_MR = upper_band_limit(**mr_bands_params, **bands_params) upper_band_TF = upper_band_limit(**tf_bands_params, **bands_params) print(f"Upper band MR: {upper_band_MR}, TF: {upper_band_TF}") min_exposure = 0.5 ; max_exposure = 2.0 mr_boost_val = 2.0 ; tf_boost_val = 1.1 ; k = 20 tolerance_levels = [0.08, 0.12, 0.18] curve_shapes = [ 'aggressive',] #[ 'aggressive','linear', 'conservative'] # Drop existing ExpAll columns from equity_df if they exist exposure_cols = [col for col in equity_df.columns if 'DEA' in col] if exposure_cols: equity_df = equity_df.drop(columns=exposure_cols) exposure_results = {} for mr_col, tf_col in pairs: pair_name = f"{mr_col} / {tf_col}" for tol in tolerance_levels: for shape in curve_shapes: risk_params = dict(max_drawdown_tolerance=-tol, min_risk=0.8, max_risk=2.0, smoothing_span=20, drawdown_window=window) results = exposure_allocation( returns_simulations[mr_col], returns_simulations[tf_col], initial_capital, upper_band_MR, upper_band_TF, min_exposure, max_exposure, k, risk_params, curve_shape=shape, mr_boost_val=mr_boost_val, tf_boost_val=tf_boost_val) exposure_results[f"{pair_name} DEA tol={tol} {shape}"] = results['equity'].iloc[window+1:] exposure_df = pd.DataFrame(exposure_results) equity_df = pd.concat([equity_df, exposure_df], axis=1) print(f"Added {len(exposure_results)} DEA columns") We execute DEA across all stress scenario pairs and multiple parameter combinations.
equity_df.We generate 18 DEA configurations (6 pairs × 3 tolerances × 1 shape) demonstrating how risk scaling parameters affect performance under varying stress conditions.
Let's plot the results using the same block of code. We amended the previous block of code with DEA.

Figure 9.10: DEA and Asset allocation algorithms equity curves under baseline scenarios
Even though it was not the stated objective, the exposure allocation algorithm consistently outperforms all other algos, including the trend following baseline.
Several reasons explain this outperformance:
Let's plot the mean equity curves by asset allocation method. We calculate a simple average for all scenarios including the respective baselines.
# Plot mean equity curves for all allocation algorithms plt.figure(figsize=(14, 6)) for mr_col, tf_col in pairs: pair_name = f"{mr_col} / {tf_col}" pair_cols = [c for c in equity_df.columns if c.startswith(pair_name) and 'DEA' not in c] if pair_cols: equity_df[pair_cols].mean(axis=1).plot(label=pair_name, linewidth=1.0, alpha=0.3, linestyle=':') for algo in algo_names: algo_cols = [c for c in equity_df.columns if f' {algo}' in c and 'DEA' not in c] if algo_cols: equity_df[algo_cols].mean(axis=1).plot(label=algo, markersize=5) for tol, color in zip([0.08, 0.12, 0.18], ['blue', 'red', 'green']): dea_cols = [c for c in equity_df.columns if f'DEA tol={tol}' in c] if dea_cols: equity_df[dea_cols].mean(axis=1).plot(label=f'DEA tol={tol}', color=color, linewidth=2.5, marker='*', markevery=200, markersize=8) plt.title('Mean Equity Curves by Allocation Method', fontsize=14, fontweight='bold') plt.xlabel('Time', fontsize=12) plt.ylabel('Equity', fontsize=12) plt.legend(loc='upper left', fontsize=9, ncol=3) plt.grid(alpha=0.3) plt.tight_layout() plt.show() This produces the following chart:

Figure 9.11: Mean equity curves across DEA and Asset allocation algorithms
Exposure allocation comes out as the undisputed winner. It may not be as pretty and smooth as we would like. It reaccelerates aggressively after every period of stagnation.
More importantly, it does a decent job at preserving capital. This algorithm offers both upside participation and downside protection. It is now time to hand out medals.
We have run multiple asset allocation algorithms across various failure scenarios. Let's rank the top three algorithms (gold, silver and bronze) by metric. We will use the average score across all scenarios.
def calc_metrics(equity_series): # returns = equity_series.pct_change().dropna() returns = np.log(equity_series / equity_series.shift(1)).dropna() total_return = (equity_series.iloc[-1] / equity_series.iloc[0]) - 1 n_years = len(returns) / 252 cagr = (1 + total_return) ** (1 / n_years) - 1 vol = returns.std() * np.sqrt(252) sharpe = (returns.mean() * 252) / vol if vol > 0 else 0 max_dd = (equity_series / equity_series.expanding().max() - 1).min() calmar = cagr / abs(max_dd) if max_dd != 0 else 0 wins = returns[returns > 0] losses = returns[returns <= 0] win_rate = len(wins) / len(returns) if len(returns) > 0 else 0 avg_win = wins.mean() if len(wins) > 0 else 0 avg_loss = abs(losses.mean()) if len(losses) > 0 else 1e-10 gain_exp = (win_rate * avg_win) - ((1 - win_rate) * avg_loss) profit_ratio = avg_win / avg_loss if avg_loss > 0 else 0 tail_ratio = np.percentile(returns, 95) / abs(np.percentile(returns, 5)) if np.percentile(returns, 5) != 0 else 0 return {'CAGR': cagr, 'Vol': vol, 'Sharpe': sharpe, 'MaxDD': max_dd, 'Calmar': calmar, 'WinRate': win_rate, 'ProfitRatio': profit_ratio, 'GainExp': gain_exp, 'TailRatio': tail_ratio, 'CommonSenseRatio': profit_ratio * tail_ratio} Once we calculate the metrics, we can proceed to build a metrics table:
# Build metrics table including DEA metrics_rows = [] for mr_col, tf_col in pairs: pair_name = f"{mr_col} / {tf_col}" for algo in algo_names: if (col := f"{pair_name} {algo}") in equity_df.columns: metrics_rows.append({'Pair': pair_name, 'Algo': algo, calc_metrics(equity_df[col].dropna())}) for col in [c for c in equity_df.columns if c.startswith(f"{pair_name} DEA")]: metrics_rows.append({'Pair': pair_name, 'Algo': col.replace(f"{pair_name} ", ""), calc_metrics(equity_df[col].dropna())}) metrics_df = pd.DataFrame(metrics_rows) metric_specs = [('Sharpe', True, '{:.2f}'), ('CAGR', True, '{:.2%}'), ('MaxDD', True, '{:.2%}'), ('Calmar', True, '{:.2f}'), ('WinRate', True, '{:.1%}'), ('GainExp', True, '{:.2f} bps'), ('ProfitRatio', True, '{:.2f}'), ('TailRatio', True, '{:.2f}'), ('CommonSenseRatio', True, '{:.2f}')] podium_data = [] for metric, higher_better, fmt in metric_specs: summary = algo_summary(metric, higher_better) row = {'Metric': metric} for rank, medal in enumerate(['Gold', 'Silver', 'Bronze'][:min(3, len(summary))]): val = summary['Mean'].iloc[rank] formatted = fmt.format(val * 10000) if 'bps' in fmt else fmt.format(val) row[f'{medal} Algo'] = summary.index[rank] row[f'{medal} Value'] = formatted podium_data.append(row) podium_df = pd.DataFrame(podium_data)[['Metric', 'Gold Algo', 'Gold Value', 'Silver Algo', 'Silver Value', 'Bronze Algo', 'Bronze Value']] podium_df We build a unified metrics table spanning all allocation methods (traditional and DEA) across all stress scenarios, then identify the top three performers for each metric.
We generate a competitive scorecard revealing which allocation methods consistently rank highest across different performance dimensions (Sharpe, CAGR, drawdown control, tail ratios).
| Metric | Gold Algo | Gold Value | Silver Algo | Silver Value | Bronze Algo | Bronze Value |
|---|---|---|---|---|---|---|
| Sharpe | MinVar | 0.71 | MaxDiv | 0.71 | EW | 0.71 |
| CAGR | DEA tol=0.18 aggressive | 4.11% | DEA tol=0.12 aggressive | 3.94% | MaxDiv | 3.87% |
| MaxDD | MinVar | -13.59% | EW | -13.71% | MaxDiv | -13.96% |
| Calmar | MaxDiv | 0.3 | EW | 0.3 | MinVar | 0.29 |
| WinRate | RP | 57.40% | HRP | 57.30% | MinVar | 56.20% |
| GainExp | DEA tol=0.18 aggressive | 1.59 bps | DEA tol=0.12 aggressive | 1.53 bps | MaxDiv | 1.51 bps |
| ProfitRatio | DEA tol=0.18 aggressive | 2.55 | DEA tol=0.12 aggressive | 2.54 | DEA tol=0.08 aggressive | 2.54 |
| TailRatio | DEA tol=0.18 aggressive | 2.35 | DEA tol=0.12 aggressive | 2.35 | DEA tol=0.08 aggressive | 2.28 |
| Common Sense Ratio | DEA tol=0.18 aggressive | 5.99 | DEA tol=0.12 aggressive | 5.97 | DEA tol=0.08 aggressive | 5.81 |
Table 9.5: Podium of asset allocation (gold, silver, bronze)
Sharpe is unsurprisingly low even for the gold medalist MinVar. The mere presence of trend following will torpedo any well-meaning Sharpe ratio. Equal weight came in second even before the MaxDiv, which has the implicit objective to optimize Sharpe. Unsurprisingly, MaxSharpe didn't feature on the podium for a simple reason: It will systematically penalize volatile, right-skewed strategies.
On the surface, MinVar has all the right metrics that make people feel good: highest Sharpe, lowest max drawdown, and highest win rate. It has only one problem; it doesn't deliver solid performance. This is a known flaw of this elegant algorithm. It sacrifices returns for low volatility.
DEA came in as gold, silver and bronze in both robustness measures such as gain expectancy, profit, tail and common sense ratios, as well as CAGR. That is a testimony to its robustness. It will keep you alive, even when it does not feel good.
Let's now conclude with a table summarizing the average metrics for all the asset allocation algorithms.
# Mean metrics across all pairs, grouped by allocation algorithm key_metrics = ['Sharpe', 'CAGR', 'MaxDD', 'Calmar', 'WinRate', 'ProfitRatio', 'TailRatio', 'CommonSenseRatio'] summary_df = metrics_df.groupby('Algo')[key_metrics].mean() # Apply heatmap: green=good, red=bad (reverse for MaxDD where less negative is better) higher_better = ['Sharpe', 'CAGR', 'Calmar', 'WinRate', 'ProfitRatio', 'TailRatio', 'CommonSenseRatio', 'MaxDD'] summary_df.style\ .format('{:.3f}')\ .background_gradient(cmap='RdYlGn', subset=higher_better, axis=0) We produce a concise scorecard showing typical performance for each allocation method across diverse stress conditions.
This produces the following table:
| Algo | Sharpe | CAGR | MaxDD | Calmar | Win Rate | Profit Ratio | Tail Ratio | Common Sense Ratio |
|---|---|---|---|---|---|---|---|---|
| DEA tol=0.08 aggressive | 0.59 | 0.04 | -0.17 | 0.23 | 0.30 | 2.55 | 2.30 | 5.88 |
| DEA tol=0.12 aggressive | 0.58 | 0.04 | -0.19 | 0.22 | 0.30 | 2.56 | 2.36 | 6.05 |
| DEA tol=0.18 aggressive | 0.59 | 0.04 | -0.20 | 0.22 | 0.30 | 2.57 | 2.36 | 6.07 |
| EW | 0.74 | 0.04 | -0.14 | 0.30 | 0.41 | 1.63 | 1.90 | 3.10 |
| HRP | 0.33 | 0.01 | -0.15 | 0.10 | 0.57 | 0.79 | 1.10 | 0.87 |
| MaxDiv | 0.74 | 0.04 | -0.14 | 0.30 | 0.36 | 2.08 | 2.01 | 4.18 |
| MaxSharpe | 0.39 | 0.02 | -0.18 | 0.12 | 0.48 | 1.18 | 1.54 | 1.82 |
| MeanVar | 0.39 | 0.02 | -0.18 | 0.12 | 0.48 | 1.18 | 1.54 | 1.82 |
| MinVar | 0.74 | 0.04 | -0.14 | 0.29 | 0.56 | 0.90 | 1.68 | 1.52 |
| RP | 0.36 | 0.02 | -0.15 | 0.11 | 0.57 | 0.79 | 1.26 | 1.00 |
Table 9.6: Average metric by asset allocation
In the final section, we will come back to DEA and look at max drawdown tolerance. Even though the process is computationally intensive, the DEA remains intuitive, especially for clients who cannot be bothered with the efficient frontier and intimidating math.
At the heart of this allocation framework lies a single, unifying principle: maximum drawdown tolerance. It is the one parameter that connects mathematics, investor psychology, and commercial viability. Risk management is not a collection of abstract metrics, volatility targets, leverage limits, or correlation assumptions. This framework elevates drawdown tolerance as the master control. Everything else becomes secondary.
Clients do not experience risk as volatility. They experience risk as losses from peak to trough. A 10% volatility figure carries little emotional weight. A 20% drawdown will trigger panic and redemption. Max drawdown tolerance aligns risk in portfolio construction with intuitive human behavior, not just statistical theory.
From a product and marketing standpoint, this conceptualisation is powerful. There is no need for a 20-page disclaimer or intimidating hermetic jargon. The conversation boils to a single question: "How much drawdown can you stomach before pulling the plug?"
This can even be reduced to a simple table:
| max_dd_tolerance | implied aggressiveness |
|---|---|
| −8% | very conservative |
| −12% | conservative |
| −18% | growth-oriented |
| −25% | aggressive |
Table 9.7: Drawdown tolerance and investor profile
Show them the table, plug the numbers in and let the exposures take care of themselves. That answer immediately defines aggressiveness, capital deployment, recovery dynamics, and long-term growth potential. Lower drawdown tolerance produces smoother equity curves and slower compounding. Higher tolerance allows greater upside and fatter tails. The trade-off is explicit, transparent, and honest.
The system does not promise to predict the future or hide behind intimidating math. It promises to respect a risk boundary.
Crucially, max_drawdown_tolerance also supersedes leverage as a primary control variable. Leverage is treated as a means, not an objective. Traditional risk systems ask how much you leverage can take on. This system asks how much drawdown is acceptable. As a result, leverage becomes a derived quantity, dynamically adjusted based on realized equity behavior.
Let's take an example. Leverage at 300%, instead of 200%, magnifies returns but also volatility. This will trigger the risk appetite oscillator, which will regulate exposure. As the drawdown worsens, exposure is compressed geometrically until the system stabilizes. Volatility is a signal, not the target. Leverage is adaptive, not static.
Most importantly, max drawdown tolerance acts as a governing invariant. Once strategy logic is fixed, this single number determines effective leverage and the shape of the equity curve. Capital aggressiveness will impact volatility and correlations.
At the end of the day, this framework reflects a deeper truth about investing: winning this infinite complex random game is done by staying in the game. If the asset allocation algorithm you choose ensures survivability, then you win as long as your strategies have a trading edge.
This chapter reframes asset allocation as a problem of survivability rather than optimization. Long/short investing exists to improve the path of returns, not merely their magnitude. Smooth equity curves are defined by drawdown depth, duration, and frequency. Investors seek capital survivability. Correlation, diversification, and Sharpe ratios often fail precisely when survivability matters most.
All trading strategies reduce to two archetypes: left-skewed (mean reversion) and right-skewed (trend following). These strategies fail differently: mean reversion collapses suddenly, while trend following bleeds slowly. Combining them redistributes risk but does not eliminate failure. During stress, correlations rise and both strategies can fail simultaneously.
To study these dynamics, the chapter models strategy returns rather than asset prices. We explicitly inject tail shocks, drawdown persistence, and correlation breakdowns. This adversarial simulator reveals that classic allocation frameworks – mean-variance, risk parity, minimum variance, Sharpe optimization, and hierarchical methods – systematically misallocate capital under stress. The more sophisticated the allocator, the more it amplifies failure modes.
Equal weight performs best among classic methods. The solution is a DEA system built around failure geometry. Capital is bounded by strategy-specific drawdown tolerances, correlation penalties, and liquidity haircuts. A drawdown-driven risk oscillator governs gross exposure, allowing temporary elasticity when conditions are favorable and rapidly de-risking when equity declines.
The dominant control parameter is maximum drawdown tolerance. Leverage becomes a consequence, not an input. Allocation becomes a choice about which failures are acceptable.
Asset allocation, properly understood, is not about efficiency. It is about survival. We have covered a lot of ground in this second edition.
In the next chapter, we will put everything together and unveil a practice that will transform your trading game.
Scan the QR code (or go to ). Search for this book by name, confirm the edition, and then follow the steps on the page.


Note: Have your invoice handy. Purchases made directly from the Packt website don't require an invoice.