Portfolio & Risk·Portfolio Construction·Beginner

Equal Weight Portfolio

Implement a simple equal-weight portfolio as a surprisingly powerful and robust allocation benchmark, rigorously comparing its out-of-sample risk-adjusted performance against far more complex optimization-based methodologies across multiple market regimes and time periods.

portfolio-theoryrisk-management

Equal Weight Portfolio — Portfolio Construction

Category: Portfolio | Subcategory: Construction


What This Notebook Does

Equal weight (1/N) is the simplest possible portfolio: allocate the same dollar amount to every asset. Despite its simplicity, research by DeMiguel et al. (2009) showed that 1/N often outperforms sophisticated optimisation methods out-of-sample because it avoids estimation error.

Key properties:

  • No estimation required — no expected returns, no covariance matrix needed
  • Maximum diversification by count (but not by risk)
  • Rebalancing drag — frequent rebalancing back to 1/N can hurt in trending markets

This notebook:

  1. Fetches price data (Yahoo Finance or synthetic) for a crypto portfolio
  2. Constructs the equal-weight portfolio and tracks its equity curve
  3. Analyses drift — how weights deviate from 1/N over time without rebalancing
  4. Compares monthly rebalanced vs buy-and-hold equal-weight
  5. Computes standard performance metrics: Sharpe, Sortino, max drawdown
  6. Visualises weight drift, equity curves, and rolling Sharpe
  7. Exports results
[1]
!pip install numpy pandas matplotlib seaborn scipy yfinance --quiet
[2]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

Set USE_LIVE_DATA = True to pull real OHLC data from Yahoo Finance (free, no API key). The tickers use Yahoo's -USD suffix for crypto. REBAL_FREQ controls how often the portfolio is reset to equal weights — 'ME' means month-end.

[3]
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False  # Set True to fetch from Yahoo Finance (no API key needed)
TICKERS       = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD','MATIC-USD']
START_DATE    = '2022-01-01'
END_DATE      = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS     = ['BTC','ETH','SOL','BNB','AVAX','MATIC']
REBAL_FREQ = 'ME'   # month-end rebalancing
RF_ANNUAL  = 0.04
print('Config ready.')
Config ready.

Section 2 — Data Acquisition

When USE_LIVE_DATA = True, yfinance.download() fetches adjusted closing prices directly from Yahoo Finance — no API key required. The synthetic path simulates correlated crypto returns using a Cholesky decomposition of a realistic covariance matrix, ensuring the data has properties (fat tails, positive correlation) similar to real crypto assets.

[4]
if USE_LIVE_DATA:
    import yfinance as yf
    prices  = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
    prices.columns = ASSETS
    prices.dropna(inplace=True)
    print(f'Live data loaded: {len(prices)} trading days')
else:
    rng  = np.random.default_rng(42)
    n    = 730
    vols = np.array([0.65, 0.75, 1.20, 0.70, 1.10, 1.40]) / np.sqrt(252)
    corr = np.array([
        [1.00, 0.85, 0.70, 0.75, 0.65, 0.60],
        [0.85, 1.00, 0.75, 0.70, 0.68, 0.62],
        [0.70, 0.75, 1.00, 0.65, 0.72, 0.75],
        [0.75, 0.70, 0.65, 1.00, 0.60, 0.55],
        [0.65, 0.68, 0.72, 0.60, 1.00, 0.78],
        [0.60, 0.62, 0.75, 0.55, 0.78, 1.00]])
    cov  = np.outer(vols, vols) * corr
    L    = np.linalg.cholesky(cov)
    mu   = np.array([0.50, 0.45, 0.80, 0.35, 0.70, 0.90]) / 252
    rets_arr = rng.standard_normal((n, len(ASSETS))) @ L.T + mu
    idx  = pd.date_range('2022-01-01', periods=n, freq='B')
    returns  = pd.DataFrame(rets_arr, columns=ASSETS, index=idx)
    prices   = (1 + returns).cumprod() * 100
    print(f'Synthetic data: {len(prices)} days')

returns = prices.pct_change().dropna()
print(f'Return matrix shape: {returns.shape}')
Synthetic data: 730 days
Return matrix shape: (729, 6)

Section 3 — Equal-Weight Portfolio Construction

The daily equal-weight portfolio return is simply the average of all asset returns: r_p = mean(r_i). For the rebalanced version, we reset weights to 1/N on every month-end date and let them drift in between. This captures the realistic cost and benefit of rebalancing — selling winners, buying losers.

[5]
n_assets = len(ASSETS)
w_ew     = np.ones(n_assets) / n_assets

# Buy-and-hold equal weight (weights drift freely)
port_bah = returns.mean(axis=1)

# Monthly rebalanced equal weight
rebal_dates = returns.resample(REBAL_FREQ).last().index
daily_rets  = []
current_w   = w_ew.copy()

for date, row in returns.iterrows():
    if date in rebal_dates:
        current_w = w_ew.copy()
    daily_rets.append(float(current_w @ row.values))
    # Update weights for drift
    current_w = current_w * (1 + row.values)
    if current_w.sum() > 0:
        current_w /= current_w.sum()

port_rebal = pd.Series(daily_rets, index=returns.index)

equity_bah   = (1 + port_bah).cumprod()
equity_rebal = (1 + port_rebal).cumprod()

print(f'Buy-and-hold total return: {(equity_bah.iloc[-1]-1):.1%}')
print(f'Monthly rebalanced return: {(equity_rebal.iloc[-1]-1):.1%}')
Buy-and-hold total return: -68.4%
Monthly rebalanced return: -64.9%

Section 4 — Performance Metrics

We compute the standard set of risk-adjusted performance metrics. The Sharpe ratio uses an annualisation factor of √252 (trading days). Sortino only penalises downside deviation — days where returns fall below zero — which is more relevant for assets like crypto where large positive skewness can inflate the Sharpe.

[6]
def performance_metrics(rets: pd.Series, rf: float = RF_ANNUAL / 252) -> dict:
    """
    Compute annualised performance metrics for a daily return series.

    Parameters
    ----------
    rets : pd.Series  Daily return series.
    rf   : float      Daily risk-free rate.

    Returns
    -------
    dict  Annual return, volatility, Sharpe, Sortino, max drawdown.
    """
    ann_ret = rets.mean() * 252
    ann_vol = rets.std() * np.sqrt(252)
    sharpe  = (rets.mean() - rf) / (rets.std() + 1e-9) * np.sqrt(252)
    down    = rets[rets < 0].std() + 1e-9
    sortino = (rets.mean() - rf) / down * np.sqrt(252)
    eq      = (1 + rets).cumprod()
    drawdown = ((eq - eq.cummax()) / eq.cummax()).min()
    return {'Ann Return': f'{ann_ret:.1%}', 'Ann Vol': f'{ann_vol:.1%}',
            'Sharpe': f'{sharpe:.2f}', 'Sortino': f'{sortino:.2f}',
            'Max DD': f'{drawdown:.1%}'}

for name, rets in [('Buy-and-Hold EW', port_bah), ('Monthly Rebalanced EW', port_rebal)]:
    m = performance_metrics(rets)
    print(f'{name}: {m}')
Buy-and-Hold EW: {'Ann Return': '-6.2%', 'Ann Vol': '81.8%', 'Sharpe': '-0.12', 'Sortino': '-0.20', 'Max DD': '-93.3%'}
Monthly Rebalanced EW: {'Ann Return': '-3.0%', 'Ann Vol': '81.3%', 'Sharpe': '-0.09', 'Sortino': '-0.14', 'Max DD': '-92.9%'}

Section 5 — Weight Drift Analysis

Without rebalancing, high-return assets grow to dominate the portfolio. This plot shows how far each asset's weight drifts from the initial 1/N target over the full holding period. A large drift means the portfolio has become concentrated in a few winners — a hidden risk that rebalancing prevents.

[7]
cumulative = (1 + returns).cumprod()
drifted_w  = cumulative.div(cumulative.sum(axis=1), axis=0)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Equal Weight Portfolio Analysis', fontsize=13, fontweight='bold')

ax1 = axes[0]
for asset in ASSETS:
    ax1.plot(drifted_w.index, drifted_w[asset], lw=1.2, label=asset)
ax1.axhline(1/n_assets, color='black', ls='--', lw=1, label=f'Target 1/N = {1/n_assets:.2f}')
ax1.set_ylabel('Portfolio Weight')
ax1.legend(fontsize=7, ncol=2)
ax1.set_title('Weight Drift Without Rebalancing')

ax2 = axes[1]
ax2.plot(equity_bah.index, equity_bah, label='Buy-and-Hold EW', color='#9e9e9e', lw=1.5)
ax2.plot(equity_rebal.index, equity_rebal, label='Monthly Rebalanced EW', color='#1976d2', lw=1.5)
ax2.set_ylabel('Growth of $1')
ax2.legend(fontsize=8)
ax2.set_title('Rebalanced vs Buy-and-Hold Equity Curve')

plt.tight_layout(); plt.show()
cell output

Section 6 — Export

Save the daily portfolio returns for both strategies and the weight drift history to CSV for downstream use in backtesting or reporting notebooks.

[8]
out = pd.DataFrame({'bah_return': port_bah, 'rebal_return': port_rebal})
out.to_csv('equal_weight_portfolio.csv')
drifted_w.to_csv('equal_weight_drift.csv')
print('Saved: equal_weight_portfolio.csv, equal_weight_drift.csv')
Saved: equal_weight_portfolio.csv, equal_weight_drift.csv

Conclusion

This notebook demonstrates the construction and analysis of an equal-weight portfolio strategy, comparing a buy-and-hold approach with a monthly rebalanced version. We've seen how weights can drift significantly without rebalancing, and how rebalancing can impact overall portfolio performance and risk metrics. The analysis provides insights into the trade-offs between simplicity, diversification, and rebalancing frequency in portfolio management.