Assignment 4

19.6. Assignment 4#

Please write your name below

In this assignment we explore different portfolio allocation rules applied to the Fama-French five-factor model.

The five factors are excess-return portfolios:

Factor

Long

Short

Mkt-RF

Market

Risk-free rate

SMB

Small caps

Large caps

HML

High book-to-market (value)

Low book-to-market (growth)

RMW

High profitability

Low profitability

CMA

Low investment

High investment

Each factor is already an excess return (no need to subtract rf again). Returns are in percent (5% appears as 5, not 0.05).


19.7. Exercises#

Exercise 1 — Import the data

Download the Fama-French 5-factor monthly returns from Ken French’s website via pandas_datareader.

import pandas_datareader.data as web
from pandas_datareader.famafrench import get_available_datasets
datasets = get_available_datasets()   # inspect this list to find the right name
ds = web.DataReader(<name>, 'famafrench')
df = ds[0]   # ds is a dict; key 0 is the monthly table

Steps:

  1. Import pandas, numpy, matplotlib, and pandas_datareader.

  2. Load the monthly 5-factor dataset. The result should have 6 columns: Mkt-RF, SMB, HML, RMW, CMA, RF.

  3. Convert the index to datetime using .to_timestamp().

  4. Confirm by calling df.head().

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import pandas_datareader.data as web

# your code below

df.head()

Exercise 2 — Mean-Variance Efficient (MVE) portfolio

The portfolio that maximises the Sharpe ratio has weights proportional to

\[w^{\text{MVE}} \propto \Sigma^{-1} \mu\]

where \(\mu\) is the vector of expected excess returns and \(\Sigma\) is the covariance matrix — both estimated from the full sample.

Steps:

  1. Estimate \(\mu\) (ERe) as the sample mean of each factor.

  2. Estimate \(\Sigma\) (CovRe) using .cov().

  3. Compute the raw weights: raw = np.linalg.inv(CovRe) @ ERe.

  4. Normalise so they sum to 1: divide by raw.sum(). Call these w_mve.

  5. Leverage the normalised weights so the portfolio’s in-sample annualised volatility matches the in-sample annualised volatility of the market factor (Mkt-RF).

    • Portfolio monthly variance: \(w^T \Sigma w\)

    • Market monthly variance: df['Mkt-RF'].var()

    • Required leverage: \(x = \sigma_{\text{mkt}} / \sigma_{\text{mve}}\)

    • Levered weights: \(W = x \cdot w^{\text{mve}}\)

  6. Report the weights and the in-sample Sharpe Ratio of the levered portfolio.

Store the levered weights in a Series or DataFrame column called mve.

# your code below

# 1-2. Sample moments
ERe   =
CovRe =

# 3. Raw MVE weights
raw_weights =

# 4. Normalise to sum to 1
w_mve =

# 5. Leverage to match market vol
sigma_mkt =   # annualised: df['Mkt-RF'].std() * 12**0.5
sigma_mve =   # annualised: (w_mve @ CovRe @ w_mve)**0.5 * 12**0.5
x         =
W_mve     = x * w_mve      # these are the final weights

# 6. In-sample Sharpe Ratio
port_returns =   # W_mve @ df[factors].T  → time series
SR_mve       =

print("Weights:\n", W_mve)
print(f"\nIn-sample SR: {SR_mve:.3f}")

Exercise 3 — Choosing leverage

The composition of the MVE portfolio (i.e., the relative weights across factors) is fixed by the ratio \(\Sigma^{-1}\mu\).
Leverage is a separate decision that scales all weights up or down without changing the portfolio’s Sharpe ratio.

Use the unit-normalised weights w_mve from Exercise 2 as your starting point.
In this exercise you choose different values of the scalar \(x\) to hit specific targets.


3a. Target a 10% annual expected excess return

The portfolio’s expected excess return (annualised, in percent) is

\[E[R^e_p]^{\text{ann}} = 12 \times x \cdot w^{\text{mve}\top} \mu\]

Set this equal to 10 (remember returns are in percent) and solve for \(x\).

Report the weights and the annualised expected excess return. Verify it equals 10%.


3b. Target a 10% annual volatility

The portfolio’s annualised volatility is

\[\sigma_p^{\text{ann}} = \sqrt{12} \times x \times \sqrt{w^{\text{mve}\top} \Sigma\, w^{\text{mve}}}\]

Set this equal to 10 (percent) and solve for \(x\).

Report the weights and verify the annualised vol equals 10%.


3c. Target a 10% annual total expected return

The total (not excess) expected return is

\[E[R_p]^{\text{ann}} = 12 \times rf + E[R^e_p]^{\text{ann}}\]

To hit a 10% total return target you also need an estimate of the risk-free rate \(rf\).

  • Use the average monthly RF from your data and annualise it.

  • Solve for \(x\) such that \(12 \times rf + 12 \times x \cdot w^{\text{mve}\top}\mu = 10\).

  • Report the weights and discuss: why does targeting a total return require more information than targeting an excess return?


3d. Key insight

Compute the Sharpe Ratio of the portfolio under each of the three leverage choices (3a, 3b, 3c).

What do you notice? Explain why this must always be the case.

# 3a — target 10% annual expected excess return
target_ERe = 10          # percent per year
x_3a =                   # solve: 12 * x * (w_mve @ ERe) = target_ERe
W_3a = x_3a * w_mve
print("3a weights:\n", W_3a)
print(f"Annual E[Re]: {12 * W_3a @ ERe:.2f}%")

# 3b — target 10% annual volatility
target_vol = 10          # percent per year
x_3b =                   # solve: 12**0.5 * x * (w_mve @ CovRe @ w_mve)**0.5 = target_vol
W_3b = x_3b * w_mve
print("\n3b weights:\n", W_3b)
print(f"Annual vol: {12**0.5 * (W_3b @ CovRe @ W_3b)**0.5:.2f}%")

# 3c — target 10% annual total return
rf_annual =              # 12 * df['RF'].mean()
target_total = 10        # percent per year
x_3c =                   # solve: rf_annual + 12 * x * (w_mve @ ERe) = target_total
W_3c = x_3c * w_mve
print("\n3c weights:\n", W_3c)
print(f"Annual total return: {rf_annual + 12 * W_3c @ ERe:.2f}%")

# 3d — Sharpe ratios (should all be equal)
def sharpe(w, ERe, CovRe):
    return (12 * w @ ERe) / (12**0.5 * (w @ CovRe @ w)**0.5)

print(f"\nSR 3a: {sharpe(W_3a, ERe, CovRe):.4f}")
print(f"SR 3b: {sharpe(W_3b, ERe, CovRe):.4f}")
print(f"SR 3c: {sharpe(W_3c, ERe, CovRe):.4f}")

Your discussion for 3c and 3d here.

Exercise 4 — Alternative allocation rules

The MVE formula \(w \propto \Sigma^{-1}\mu\) requires estimating both \(\mu\) and \(\Sigma\).
Practitioners often use simplified rules that relax one or both of these estimation steps.
Below are four common alternatives. For each one:

  • Derive or motivate the weights analytically

  • Implement the weights in code

  • Leverage to match the same volatility target as in Exercise 2 (market vol)


Rule 1 — Equal Weight (EW)

Assumption: We have no useful information to distinguish expected returns or risks across factors.
Assign the same weight to every factor.

\[w_i^{\text{EW}} = \frac{1}{N}\]

Implementation: create a vector of \(1/N\) and normalise (trivially already normalised).


Rule 2 — Risk Parity, estimated covariance (RP1)

Assumption: Expected returns are exactly proportional to volatility: \(\mu_i = c \cdot \sigma_i\).
Substituting into the MVE formula:

\[w^{\text{RP1}} \propto \Sigma^{-1} \sigma\]

where \(\sigma\) is the vector of sample standard deviations (replace ERe with df.std()).

Implementation: use the same MVE formula as Exercise 2 but replace ERe with the vector of standard deviations.

Intuition: You believe higher-vol factors earn proportionally higher returns (constant Sharpe ratio across factors), but you still use the full covariance matrix to exploit correlations.


Rule 3 — Risk Parity, diagonal covariance (RP2)

Assumption: Same as RP1 (\(\mu_i \propto \sigma_i\)) plus assume zero correlation across factors.

With a diagonal covariance matrix \(\Sigma = \text{diag}(\sigma_1^2, \ldots, \sigma_N^2)\):

\[\Sigma^{-1}\sigma = \text{diag}(1/\sigma_1^2, \ldots) \cdot \sigma = \left(\frac{1}{\sigma_1}, \ldots, \frac{1}{\sigma_N}\right)\]

So weights become inverse-volatility weights:

\[w_i^{\text{RP2}} \propto \frac{1}{\sigma_i}\]

Implementation: compute 1 / df.std() and normalise.

Intuition: Allocate more to lower-vol factors; ignore correlations entirely.


Rule 4 — Minimum Variance (MinVar)

Assumption: We have no view on expected returns — treat them as equal across all factors.
Setting \(\mu = \mathbf{1}\) in the MVE formula:

\[w^{\text{MinVar}} \propto \Sigma^{-1} \mathbf{1}\]

Implementation: use the MVE formula with ERe replaced by np.ones(N).

Intuition: You use the full covariance structure but ignore differences in expected returns. The portfolio minimises portfolio variance for a given sum of weights.

factors = ['Mkt-RF','SMB','HML','RMW','CMA']
N = len(factors)
Re = df[factors]          # returns in percent

sigma_vec = Re.std()      # standard deviations (used in RP rules)

def mve_weights(mu, Sigma):
    """Return unit-normalised MVE weights given mu and Sigma."""
    raw = np.linalg.inv(Sigma) @ mu
    return raw / raw.sum()

def leverage_to_vol(w, Sigma, target_ann_vol):
    """Scale w so annualised portfolio vol equals target_ann_vol (in percent)."""
    port_vol = (12 * w @ Sigma @ w) ** 0.5
    return w * target_ann_vol / port_vol

target_vol = (Re['Mkt-RF'].var() * 12) ** 0.5   # market annualised vol

# Rule 1 — Equal Weight
w_ew   =
W_ew   = leverage_to_vol(w_ew,   CovRe, target_vol)

# Rule 2 — RP1 (estimated covariance, vol-proportional mu)
w_rp1  =
W_rp1  = leverage_to_vol(w_rp1,  CovRe, target_vol)

# Rule 3 — RP2 (diagonal covariance, inverse-vol weights)
w_rp2  =
W_rp2  = leverage_to_vol(w_rp2,  CovRe, target_vol)

# Rule 4 — Minimum Variance
w_minvar =
W_minvar = leverage_to_vol(w_minvar, CovRe, target_vol)

# Collect in a DataFrame for comparison
Weights = pd.DataFrame({
    'MVE':    W_mve,
    'EW':     W_ew,
    'RP1':    W_rp1,
    'RP2':    W_rp2,
    'MinVar': W_minvar,
}, index=factors)

print(Weights.round(3))
Weights.plot.bar(figsize=(10,5), title='Weights by strategy')
plt.tight_layout()

Exercise 5 — In-sample comparison

Compute the full-sample annualised Sharpe Ratio for all five strategies (MVE, EW, RP1, RP2, MinVar) plus the market portfolio (Mkt-RF) as a benchmark.

Report them in a table and plot a bar chart.

Then discuss:

  1. Which strategy performs best in-sample? Is this surprising?

  2. What is the main problem with comparing in-sample Sharpe Ratios across strategies?

  3. Why is in-sample performance a biased measure when the weights were estimated from the same data?

# your code here

Your discussion here.

Exercise 6 — Rolling out-of-sample evaluation

In-sample performance is optimistic because the weights were estimated from the same data used to evaluate them.
A fairer test: estimate weights using only past data, then evaluate on the next month.

We split the sample at the midpoint: the first half is the initial estimation window; then we expand it one month at a time.

The code below implements this for the MVE strategy. Extend it for all four alternative strategies.

import numpy as np
Rp = pd.DataFrame([], index=[], columns=[], dtype=float)
Re = df[factors]
start_date = Re.index[len(Re) // 2]   # midpoint of sample

for date in Re[start_date:].index:
    # Estimation sample: everything strictly before this month
    est = Re[:date - pd.DateOffset(months=1)]
    ERe_t  = est.mean()
    CovRe_t = est.cov()
    target_vol = (est['Mkt-RF'].var() * 12) ** 0.5

    # MVE
    w_mve_t = mve_weights(ERe_t.values, CovRe_t.values)
    W_mve_t = leverage_to_vol(w_mve_t, CovRe_t.values, target_vol)
    Rp.at[date, 'MVE'] = W_mve_t @ Re.loc[date, factors]

    # --- add EW, RP1, RP2, MinVar below ---

Notes:

  • mve_weights and leverage_to_vol are the helper functions from Exercise 4.

  • The leverage target is re-estimated each month from the expanding window.

  • Store each strategy’s return for that month in Rp.

# your code here

Exercise 7 — Out-of-sample performance analysis

Using the rolling returns in Rp, compute for each strategy:

  • Annualised expected excess return: \(12 \times \bar{R}^e_p\)

  • Annualised volatility: \(\sqrt{12} \times \sigma(R^e_p)\)

  • Sharpe Ratio: ratio of the two

Also add the market factor (Mkt-RF) evaluated over the same out-of-sample period as a benchmark.

Report everything in a table and plot the cumulative returns of each strategy.

Then discuss:

  1. Is there a clear winner? Can you conclude one strategy is better?

  2. What statistical test could you use to check whether differences in expected returns are significant? Apply it.

  3. Even if expected returns differ significantly, what other sources of return differences might not interest a long-only investor? (Hint: think about what the factor portfolios are.)

  4. What would you want to see beyond Sharpe Ratios to fully evaluate these strategies?

# your code here

Your discussion here.