Shannon's Demon: Are we harvesting volatility at the rebalance?

Edit: This is not simple by any means. Several factors interact in complex ways:

  • Skew tends to increase with volatility
  • Momentum effects are often stronger in highly volatile assets
  • Selection bias (P123 strategies tend to pick "winners" among volatile stocks)
  • Rebalancing frequency interacts with all of these effects

The simulation below explores just one piece of this puzzle.

I've always suspected volatility harvesting plays a role in our strategy returns, but quantifying it has been challenging. Let's call this "Jrinne's Conjecture" - that frequent rebalancing of volatile stocks creates additional returns beyond what you'd expect from buy-and-hold.

The theoretical basis comes from Shannon's Demon, where rebalancing between just two volatile assets can generate profits even when the individual assets go nowhere. Our P123 strategies often deal with 15+ stocks, many highly volatile, and we rebalance frequently. This suggests we might be benefiting from a multi-asset version of Shannon's effect.

To test this, I worked with Claude to simulate a 15-stock portfolio where:

  • Stocks have different volatilities (20% to 60% annually)
  • Expected returns scale with volatility (5% to 15% annually)
  • Weekly rebalancing back to equal weights

The simulation shows a ~2% annual premium from rebalancing alone (above a 4.73% for buy and hold for this simulation). This is conservative since:

  1. Real stock volatilities are often higher
  2. Returns often have positive skew

While not definitive proof, the simulation supports the conjecture that volatility harvesting contributes to our returns through systematic rebalancing.

The output:

Screenshot 2024-10-25 at 6.42.26 AM

More about Shannon's Demon from Fortune's Formula: The Untold Story of the Scientific Betting System That Beat the Casinos and Wall Street:

'To make this clear: Imagine you start with $1,000, $500 in stock and $500 in cash. Suppose the stock halves in price the first day. (It’s a really volatile stock.) This gives you a $750 portfolio with $250 in stock and $500 in cash. That is now lopsided in favor of cash. You rebalance by withdrawing $125 from the cash account to buy stock. This leaves you with a newly balanced mix of $375 in stock and $375 cash. Now repeat. The next day, let’s say the stock doubles in price. The $375 in stock jumps to $750. With the $375 in the cash account, you have $1,125. This time you sell some stock, ending up with $562.50 each in stock and cash. Look at what Shannon’s scheme has achieved so far. After a dramatic plunge, the stock’s price is back to where it began. A buy-and-hold investor would have no profit at all. Shannon’s investor has made $125."

Poundstone, William. Fortune's Formula: The Untold Story of the Scientific Betting System That Beat the Casinos and Wall Street (pp. 202-203). Farrar, Straus and Giroux. Kindle Edition.

The code used:

import pandas as pd

def simulate_cross_sectional_vh(n_stocks=15, periods=52, vol_range=(0.2, 0.6), return_range=(0.05, 0.15)):
    """
    Simulate the effect of cross-sectional volatility harvesting with stocks of different volatilities
    """
    # Create stocks with different volatilities and expected returns
    # Assuming higher volatility stocks have higher returns (risk-return relationship)
    volatilities = np.linspace(vol_range[0], vol_range[1], n_stocks)
    expected_returns = np.linspace(return_range[0], return_range[1], n_stocks)
    
    # Simulate weekly returns for each stock
    returns_list = []
    for i in range(n_stocks):
        weekly_vol = volatilities[i] / np.sqrt(52)
        weekly_ret = expected_returns[i] / 52
        stock_returns = np.random.normal(weekly_ret, weekly_vol, periods)
        returns_list.append(stock_returns)
    
    returns_matrix = np.array(returns_list).T
    
    # Compare strategies
    def run_strategy(returns, rebalance=True):
        portfolio_value = 1.0
        weights = np.ones(n_stocks) / n_stocks
        values = []
        
        for week_returns in returns:
            # Update portfolio value
            portfolio_value *= (1 + np.sum(weights * week_returns))
            
            # Update weights based on returns
            weights = weights * (1 + week_returns)
            weights = weights / np.sum(weights)
            
            # Rebalance if enabled
            if rebalance:
                weights = np.ones(n_stocks) / n_stocks
                
            values.append(portfolio_value)
            
        return values
    
    # Run both strategies
    rebalanced_values = run_strategy(returns_matrix, rebalance=True)
    buy_hold_values = run_strategy(returns_matrix, rebalance=False)
    
    # Calculate metrics
    rebalanced_return = (rebalanced_values[-1] - 1) * 100
    buy_hold_return = (buy_hold_values[-1] - 1) * 100
    
    # Calculate contribution from different volatility levels
    vol_contribution = {}
    for i in range(n_stocks):
        vol = volatilities[i]
        ret = expected_returns[i]
        vol_contribution[f'Stock {i+1}'] = {
            'Volatility': vol,
            'Expected Return': ret,
            'Actual Return': np.mean(returns_matrix[:, i]) * 52 * 100
        }
    
    results = {
        'Rebalanced Return (%)': rebalanced_return,
        'Buy & Hold Return (%)': buy_hold_return,
        'Cross-sectional VH Premium (%)': rebalanced_return - buy_hold_return,
        'Stock Contributions': pd.DataFrame(vol_contribution).T
    }
    
    return results

# Run simulation
np.random.seed(42)  # For reproducibility
results = simulate_cross_sectional_vh(
    n_stocks=15,
    periods=52,
    vol_range=(0.2, 0.6),  # 20% to 60% annual volatility
    return_range=(0.05, 0.15)  # 5% to 15% annual expected return
)

print("\nCross-sectional Volatility Harvesting Analysis")
print("=" * 50)
print(f"\nRebalanced Portfolio Return: {results['Rebalanced Return (%)']:.2f}%")
print(f"Buy & Hold Return: {results['Buy & Hold Return (%)']:.2f}%")
print(f"Cross-sectional VH Premium: {results['Cross-sectional VH Premium (%)']:.2f}%")

print("\nContribution by Stock Volatility Level:")
print(results['Stock Contributions'].round(3))

# Calculate additional metrics
stock_data = results['Stock Contributions']
correlation = np.corrcoef(stock_data['Volatility'], stock_data['Actual Return'])[0,1]
print(f"\nCorrelation between Volatility and Return: {correlation:.3f}")

# Calculate rebalancing profit opportunities
vol_spread = stock_data['Volatility'].max() - stock_data['Volatility'].min()
print(f"Volatility Spread (High - Low): {vol_spread:.3f}")

1 Like

This is the idea behind arithmetic mean returns

1 Like

TL;DR: Short answer: No. We are probably not doing much volatility harvesting with our ports at P123. Shannon's Demon is an interesting thought experiment but not meaningful for us.

I hope I did not overstate or mislead as I explored this.. The above is early code. As I explored this over the weekend I have concluded volatility harvesting has little effect for P123 ports AND the purported benefits of reduced volatility drag from rebalancing portfolios (e.g., a typically recommended 60/40 stock/bond portfolio) is HIGHLY EXAGGERATED. Much less than 1% with ideal assumptions about reductions in volatility thru diversification and rebalancing.

Better code for 15 stocks with ideal (not real) situation of no correlation between assets. Code shows the effect is significant only at EXTREME volatiles. I did not want to leave the wrong impression with the above:

BTW, I would love to be wrong about this conclusion and would appreciate it if I were shown an error in my code or assumptions. But for now, I think I will stop believing volatility harvesting is helpful at all.


import numpy as np

def expected_log_growth(num_stocks=15, sigma_annual=0.2, num_weeks=52, num_simulations=100000):
    # Convert annual volatility to weekly volatility
    sigma_weekly = sigma_annual / np.sqrt(num_weeks)
    
    # Simulate log returns: shape (num_simulations, num_stocks)
    R = np.random.normal(0, sigma_weekly, size=(num_simulations, num_stocks))
    
    # Calculate sum of exponentiated returns
    sum_exp_R = np.exp(R).sum(axis=1)
    
    # Calculate log of average
    log_growth = np.log(sum_exp_R / num_stocks)
    
    # Return the mean expected log growth
    return np.mean(log_growth)

# Example usage:
sigma_annual_values = np.linspace(1.0, 10.0, 10)  # Annual volatility from 10% to 100%
for sigma in sigma_annual_values:
    egl = expected_log_growth(sigma_annual=sigma)
    annualized_growth = 100*(np.exp(egl) - 1)
    print(f"Annual Sigma: {sigma:.2f}, Annualized Growth: {annualized_growth:.6f}%")