import sys
print(sys.executable)C:\PROGRA~1\PYTHON~1\python.exe
print(sys.version)3.14.4 (tags/v3.14.4:23116f9, Apr 7 2026, 14:10:54) [MSC v.1944 64 bit (AMD64)]
M6 – Hedge Funds
This is a review workshop. Almost everything here you have already seen in previous courses; the purpose is to put all the pieces in the same language, the same notation and the same dataset, so that during the rest of the module we can talk about hedge funds without stopping to re-derive the basics.
You have to work in a new Google Colab notebook (or any other front-end a Quarto software such as VScode). Read each section, re-type and run the code yourself (do not only copy-paste; type it so you notice what each line does), take your own notes in text cells, and do the CHALLENGES.
You have to submit the .ipynb file of your Notebook.
Before reviewing the foundations of Hedge Funds, we need to be precise about the two important institutional vehicles: Mutual Funds and Hedge Funds.
A mutual fund pools money from many small investors and gives it to a professional manager, who buys a basket of assets — typically stocks, bonds, or both — on behalf of everybody. When you buy into the fund you do not own any particular stock: you own a fraction of the whole portfolio.
Think of it as a collective grocery order. Instead of each person buying one expensive item, everybody contributes, the group buys a full cart, and then the cart is split. The immediate benefit is diversification, which is exactly the mechanism we review in Part I.
Three features define a mutual fund in practice:
It is open to the public. Almost anybody can invest, usually with a small minimum. Shares are created and redeemed at the Net Asset Value (NAV), computed at the close of every business day, so your money can come out any day the market is open.
It is heavily regulated. In the US, the Investment Company Act of 1940 constrains what the manager may do: leverage is capped, short-selling and derivatives are restricted, and the holdings must be disclosed periodically. In practice the portfolio weights are forced to satisfy w_i \geq 0 and \sum_i w_i = 1.
It is judged relative to a benchmark. The manager’s mandate is to beat, or to track, an index. This is why a mutual fund is naturally evaluated with Jensen’s alpha and the Information ratio (Part III): both are measured against something else.
Fees are a small annual percentage of the assets under management — from about 0.05% for an index fund to about 1% for an actively managed one. The manager gets no share of the profits.
A hedge fund also pools money, and also hires a professional manager. A hedge fund is a private vehicle, open only to institutional and wealthy (“accredited”) investors, and precisely because it is private it escapes most of the restrictions above. The manager is free to:
The name is historical, not descriptive. A. W. Jones’s fund (1949) was long some stocks and short others, so the exposure to the market was hedged and what remained was the stock-picking skill. Today many funds labelled “hedge” take more risk, not less. What survives from the original idea is the ambition: to deliver a return that does not depend on the direction of the market. In the language of Part II, that is a fund with beta ≈ 0 and positive alpha.
The fee structure is different, and it matters because it shapes behaviour. The classic contract is “2 and 20”: 2% per year on the assets plus 20% of the profits, usually subject to a high-water mark, meaning no incentive fee is charged until previous losses have been recovered. Note what this implies: the performance fee is essentially a call option on the fund’s return, and an option is worth more when volatility is higher. Keep that in mind every time we compute a volatility, a VaR or a Sharpe ratio in this workshop.
Finally, the money is not free to leave. Hedge funds impose lock-up periods and redemption windows (often quarterly, with advance notice), because leveraged and illiquid positions cannot be unwound in a single day.
| Dimension | Mutual fund | Hedge fund |
|---|---|---|
| Who may invest | General public | Accredited and institutional investors only |
| Regulation | Heavy; holdings disclosed | Light; positions are private |
| Portfolio weights | w_i \geq 0: long only | Any sign: long and short |
| Leverage | Capped and small | Central to the strategy |
| Instruments | Mostly stocks and bonds | Anything: derivatives, illiquid, private |
| Investor liquidity | Daily, at NAV | Lock-ups and redemption windows |
| Fees | ≈ 0.05%–1% of assets | ≈ “2 and 20”, with a high-water mark |
| Objective | Relative return: beat the index | Absolute return: positive in any market |
| What is sold | Market exposure, \beta | Manager skill, \alpha |
One sentence to remember: a mutual fund is a regulated, public vehicle designed to give ordinary investors diversified exposure to the market; a hedge fund is a lightly regulated, private vehicle designed to give sophisticated investors exposure to a manager’s skill. Neither one is “better” — they are two different products, and each buys its advantage by giving up the other’s.
Two warnings before we start:
The frontier between the two is blurring. “Liquid alternative” mutual funds and UCITS (Undertakings for Collective Investment in Transferable Securities) funds now run hedge-fund-like strategies inside the regulated wrapper, with daily liquidity and lower fees. When you compare returns, always ask which constraints the manager was actually operating under.
The promise and the evidence are different things. A hedge fund is sold as alpha. Whether what it delivers is alpha, or simply exposure to a factor the client could have bought cheaply, is an empirical question — and answering it is the point of Part V.
The topics and exercises of this workshop will be covered in weeks 1, 2 and part of week 3 (10-12 class hours)
In this workshop we review the following foundation topics:
| Session | Sections | Topics |
|---|---|---|
| 1 | Setup + Part I + Part II | Returns, portfolio risk and return, diversification, GMV portfolio, tangency portfolio, efficient frontier, CML, CAPM, beta, systematic vs non-systematic risk |
| 2 | Part III + Part IV + Part V | HPR, time-weighted vs money-weighted returns, Sharpe / Treynor / Jensen / Information ratio, VaR and Expected Shortfall, Fama-French factor models, a first market-neutral portfolio |
Keep this map in mind while you review. Every foundation topic below is a tool we will use later in the module:
Portfolio variance and correlation → this is the machinery behind diversification, hedging and market-neutral positions. A hedge is just a portfolio weight that happens to be negative.
CAPM and beta → hedge funds are usually sold as “returns that do not depend on the market”. Beta is exactly the number that measures that dependence. A market-neutral fund is a fund with beta ≈ 0.
Alpha → the whole hedge fund industry charges 2% + 20% for alpha. We need to know how alpha is estimated and, more importantly, how easily it can be fake alpha (really just exposure to another factor).
Sharpe, Treynor, Information ratio → different ways of paying for risk. Which one you use changes the ranking of funds, and hedge funds are ranked with these numbers every day.
VaR and Expected Shortfall → hedge funds use leverage, so the relevant question is not only “how volatile is this?” but “how much can I lose in a bad month?”.
Factor models → the modern way of judging a hedge fund is style analysis: decomposing its returns into known factors and checking what is left. What is left is the real alpha.
import sys
print(sys.executable)C:\PROGRA~1\PYTHON~1\python.exe
print(sys.version)3.14.4 (tags/v3.14.4:23116f9, Apr 7 2026, 14:10:54) [MSC v.1944 64 bit (AMD64)]
import importlib.util
print(importlib.util.find_spec("pandas"))ModuleSpec(name='pandas', loader=<_frozen_importlib_external.SourceFileLoader object at 0x0000028B174E01D0>, origin='C:\\Users\\L00352955\\AppData\\Roaming\\Python\\Python314\\site-packages\\pandas\\__init__.py', submodule_search_locations=['C:\\Users\\L00352955\\AppData\\Roaming\\Python\\Python314\\site-packages\\pandas'])
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sb
import statsmodels.api as sm
import statsmodels.formula.api as smf
from scipy import optimize, stats
pd.set_option("display.float_format", lambda x: f"{x:,.4f}")
np.set_printoptions(suppress=True, precision=4)If you are in Google Colab and any library is missing, install it with !pip install <library>.
We will work with monthly data from January 2015 to July 2026 for 8 instruments. They were chosen so that we can reuse them later in the module for long/short and market-neutral examples:
| Ticker | Instrument | Why it is here |
|---|---|---|
SPY |
S&P 500 ETF | our proxy of the market portfolio |
AAPL |
Apple | large-cap growth, high beta |
JPM |
JP Morgan | financials, value-tilted |
WMT |
Walmart | defensive, low beta |
XOM |
Exxon Mobil | energy, value-tilted |
TSLA |
Tesla | very high beta and very high volatility |
GLD |
Gold ETF | almost zero correlation with the market |
HYG |
High-yield corporate bond ETF | credit risk, partial market exposure |
Plus two more files:
The three files are:
hf_prices_monthly.csv # month-end adjusted prices
hf_ff5_rf_monthly.csv # Mkt_RF, SMB, HML, RMW, CMA, RF (monthly, decimals)
hf_fund_flows.csv # NAV and flows of a hypothetical fund
The three CSV files distributed with this workshop are a teaching dataset: they are built so that every example below runs and produces sensible, stable numbers even without an internet connection, and so that everybody in class gets exactly the same results.
To work with real market data, run the script build_workshop1_data.py (included) once. It downloads the real prices from Yahoo Finance, the risk-free rate from FRED and the Fama-French factors from Kenneth French’s data library, and overwrites the three CSV files with exactly the same column structure. Everything in this workshop will then run unchanged on real data.
Alternatively, set USE_LOCAL_DATA = False in the next chunk and the workshop will download the data on the fly.
USE_LOCAL_DATA = True # set to False to download live data
TICKERS = ["AAPL", "GLD", "HYG", "JPM", "SPY", "TSLA", "WMT", "XOM"]
START, END = "2014-12-01", "2026-08-01"
if USE_LOCAL_DATA:
prices = pd.read_csv("hf_prices_monthly.csv", index_col="Date", parse_dates=True)
ff = pd.read_csv("hf_ff5_rf_monthly.csv", index_col="Date", parse_dates=True)
else:
import yfinance as yf
import pandas_datareader.data as web
prices = yf.download(tickers=" ".join(TICKERS), start=START, end=END,
interval="1mo", auto_adjust=True)["Close"]
ff = web.DataReader("F-F_Research_Data_5_Factors_2x3", "famafrench",
start=START, end=END)[0] / 100
ff.columns = ["Mkt_RF", "SMB", "HML", "RMW", "CMA", "RF"]
ff.index = ff.index.to_timestamp("M")
print(prices.tail()) AAPL GLD HYG ... TSLA WMT XOM
Date ...
2026-03-31 203.8982 201.1403 122.6572 ... 482.6151 62.5790 183.7714
2026-04-30 210.5237 196.2643 121.4220 ... 488.3655 62.9704 189.7668
2026-05-31 232.3629 203.1447 126.0029 ... 497.2885 63.7190 208.5857
2026-06-30 241.3409 199.7949 125.4371 ... 531.6093 65.5357 212.4029
2026-07-31 243.0294 212.4752 121.3404 ... 515.6143 65.5273 232.8697
[5 rows x 8 columns]
Two comments on the data:
We use adjusted prices (auto_adjust=True in yfinance). An adjusted price already includes dividends and stock splits, so the return we compute from it is a total return. If you use raw closing prices you would be throwing away the dividend, which for XOM or HYG is a large part of the return.
yfinance returns the columns sorted alphabetically, not in the order you asked for them. Always check prices.columns before you build a vector of weights by hand — this is one of the most common (and most silent) errors in portfolio code.
A simple return between t-1 and t is the percentage change of the price:
R_t=\frac{Price_t-Price_{t-1}}{Price_{t-1}}=\frac{Price_t}{Price_{t-1}}-1
A continuously compounded return (also called cc return or log return) is the natural logarithm of the growth factor:
r_t=\ln\left(\frac{Price_t}{Price_{t-1}}\right)=\ln(Price_t)-\ln(Price_{t-1})
Both are related by:
R_t=e^{r_t}-1 \qquad \text{and} \qquad r_t=\ln(1+R_t)
Why do we bother with two definitions? Because each one has one property that the other does not:
cc returns add up over time. The cc return of a whole year is the sum of the 12 monthly cc returns. This is what makes them the right variable for computing means, volatilities and regressions.
simple returns add up across assets. The return of a portfolio is the weighted average of the simple returns of its assets. This is what makes them the right variable for portfolio aggregation.
We compute both:
R = np.log(prices).diff().dropna() # cc (log) returns
Rs = prices.pct_change().dropna() # simple returns
# align the returns with the factor file (same months):
R, ff = R.align(ff, join="inner", axis=0)
Rs, _ = Rs.align(ff, join="inner", axis=0)
print(R.shape, ff.shape)(139, 8) (139, 6)
R.head() AAPL GLD HYG JPM SPY TSLA WMT XOM
Date
2015-01-31 0.0217 0.0098 0.0055 0.0331 0.0288 0.1317 0.0469 0.0267
2015-02-28 0.0543 0.0120 0.0230 0.0705 0.0457 -0.1122 0.0854 -0.0278
2015-03-31 -0.0111 -0.0305 -0.0142 -0.1553 0.0164 -0.0894 -0.0300 -0.0019
2015-04-30 -0.0464 0.0353 -0.0299 -0.1009 -0.0390 -0.1545 0.0502 -0.0564
2015-05-31 0.0617 0.0664 0.0107 0.1148 0.0327 -0.1183 0.0737 0.0799
Portfolio theory should be written in simple returns, while means, volatilities and regressions work better with cc returns. The rule that keeps us out of trouble is:
What you must not do is apply portfolio weights to cc returns and call the result the expected portfolio return. Let’s see how much that costs, with an equally weighted portfolio of the 8 instruments:
w_eq = np.repeat(1/len(TICKERS), len(TICKERS))
port_simple = Rs @ w_eq # exact portfolio simple return
port_log = R @ w_eq # portfolio built from cc returns
ER_assets_a = np.exp(12*R.mean()) - 1 # expected annual simple return of each asset
print("(a) Actual compound annual return of the portfolio : %.4f"
% ((1 + port_simple).prod()**(12/len(port_simple)) - 1))(a) Actual compound annual return of the portfolio : 0.1793
print("(b) Weighted average of the assets' E[R] (our rule): %.4f" % (w_eq @ ER_assets_a))(b) Weighted average of the assets' E[R] (our rule): 0.1521
print("(c) Applying the weights to cc returns (the trap): %.4f" % (np.exp(12*port_log.mean()) - 1))(c) Applying the weights to cc returns (the trap): 0.1485
print()print("Annualized volatility, from simple returns : %.4f" % (port_simple.std()*np.sqrt(12)))Annualized volatility, from simple returns : 0.1654
print("Annualized volatility, from cc returns : %.4f" % (port_log.std()*np.sqrt(12)))Annualized volatility, from cc returns : 0.1601
The volatilities agree to a few basis points — that approximation is harmless, and it is the one we will use for the covariance matrix.
The expected returns do not agree, and the gap is interesting. Numbers (a) and (b) differ by roughly:
\frac{1}{2}\left(\sum_i w_i\sigma_i^2-\sigma_P^2\right)
print("Diversification / rebalancing bonus: %.4f"
% (0.5*(12*(w_eq @ R.var().values) - 12*port_log.var())))Diversification / rebalancing bonus: 0.0260
That term is positive whenever the assets are not perfectly correlated, and it has a name: the rebalancing premium (or volatility harvesting). A monthly-rebalanced portfolio compounds faster than the weighted average of the compound returns of its parts, because rebalancing systematically sells what went up and buys what went down. It is a real, mechanical source of return, and several hedge fund strategies (risk parity, volatility harvesting) are built on nothing else. Keep it in mind: it is the first example in this course of “return that comes from the structure of the portfolio, not from picking the right asset”.
Prices are not comparable across instruments (Tesla is not “cheaper” than Apple because its price is lower). To compare them we build a growth index: how much would $1 invested at the beginning be worth at each month?
def plot_time_series(df, title, y_label, legend_title="Legend"):
"""Time plot of every column of a data frame with a datetime index."""
plt.figure(figsize=(12, 6))
ax = plt.gca()
df.plot(ax=ax)
plt.title(title)
plt.xlabel("Date")
plt.ylabel(y_label)
plt.grid(True, alpha=0.3)
plt.legend(title=legend_title)
plt.show()
growth = prices / prices.iloc[0]
plot_time_series(growth, "Value of $1 invested at the beginning of the sample",
"Investment value", "Instrument")plot_time_series(R, "Monthly cc returns", "Monthly return", "Instrument")Even before computing anything, the second plot already tells you the story of this course: TSLA moves in a band that is several times wider than HYG. Any comparison of returns that ignores this is meaningless.
Compute the Holding Period Return (HPR) of each of the 8 instruments over the whole sample, using prices directly, and check that you get the same result using the sum of cc returns.
Which instrument had the highest HPR? Was it the one you would have wanted to own? Explain in your own words what information the HPR hides.
(Optional) Compute, for each instrument, the percentage of months with a negative return. Rank the instruments by this measure and compare the ranking with the ranking by HPR. Comment.
A portfolio is a set of 2 or more financial assets (stocks, bonds, ETFs, derivatives, commodities, currencies). The owner of the portfolio must decide how much money to allocate to each asset. Those allocations, expressed as fractions of the total invested, are the weights:
w_i=\frac{\text{money invested in asset } i}{\text{total money invested}}, \qquad \sum_{i=1}^{N}w_i=1
Two remarks that will matter a lot in this module:
The weights must add up to 1, but nothing says they must be positive. A negative weight is a short position: you borrow the asset, sell it, and you owe it back. A portfolio with weights w=[1.3,\ -0.3] is a portfolio where you invested 130% of your money in the first asset, financed by short-selling 30% in the second. Mutual funds are typically forbidden to do this; hedge funds are not. This single difference generates most of the strategies we will study.
The main advantage of a portfolio is the possibility to diversify the non-systematic (idiosyncratic) risk while keeping the expected return.
The simplest estimator of the expected return of an asset is the geometric mean of its historical returns. The geometric mean is the constant periodic return that would have produced the same final wealth as the actual series of returns:
GeomMean(R)=\sqrt[N]{(1+R_{1})(1+R_{2})\cdots(1+R_{N})}-1
There is a much easier way to compute it: take the arithmetic mean of the cc returns and convert it back to a simple return:
GeomMean(R)=e^{\bar{r}}-1, \qquad \bar{r}=\frac{r_{1}+r_{2}+\cdots+r_{N}}{N}
This is exactly why we work with cc returns.
To move from a monthly to an annual expected return we use the fact that cc returns add up over time:
E[R_{annual}]=e^{12\bar{r}}-1
mean_m = R.mean() # mean monthly cc return
ER_m = np.exp(mean_m) - 1 # expected monthly simple return
ER_a = np.exp(12 * mean_m) - 1 # expected annual simple return
pd.DataFrame({"mean cc monthly": mean_m,
"E[R] monthly": ER_m,
"E[R] annual": ER_a}).sort_values("E[R] annual", ascending=False) mean cc monthly E[R] monthly E[R] annual
TSLA 0.0256 0.0260 0.3603
AAPL 0.0159 0.0161 0.2108
JPM 0.0125 0.0126 0.1620
XOM 0.0103 0.0103 0.1309
WMT 0.0096 0.0097 0.1224
SPY 0.0095 0.0096 0.1213
GLD 0.0045 0.0046 0.0560
HYG 0.0043 0.0043 0.0526
The historical mean is a terrible predictor of the future expected return. It is extremely noisy: with 139 months of data, the standard error of the annual mean return of a stock with 30% volatility is about 0.30/\sqrt{139/12}\approx 8.8\%. That means the 95% confidence interval for the expected return is roughly ±17 percentage points.
We use it because it is simple, but keep in mind that most of the “alpha” reported in the industry is inside that confidence interval. Later we will see better estimators: the single-index model, the CAPM, factor models, and shrinkage / EWMA methods.
An easy improvement is the Exponentially Weighted Moving Average (EWMA), which gives more weight to the most recent months:
\bar{r}^{EWMA}=\frac{\sum_{k=0}^{N-1}\lambda^{k}r_{t-k}}{\sum_{k=0}^{N-1}\lambda^{k}}
with 0<\lambda<1. A small \lambda forgets the past quickly; RiskMetrics uses \lambda=0.94 for daily data and \lambda=0.97 for monthly data.
def ewma_mean(x, lam=0.97):
"""EWMA mean giving more weight to the most recent observations."""
x = np.asarray(x)
k = np.arange(len(x))[::-1] # 0 for the last observation
wts = lam ** k
return np.sum(wts * x) / np.sum(wts)
ewma_ann = R.apply(lambda c: np.exp(12*ewma_mean(c, 0.97)) - 1)
pd.DataFrame({"E[R] annual (simple mean)": ER_a,
"E[R] annual (EWMA, lambda=0.97)": ewma_ann}) E[R] annual (simple mean) E[R] annual (EWMA, lambda=0.97)
AAPL 0.2108 0.0873
GLD 0.0560 0.1081
HYG 0.0526 0.0256
JPM 0.1620 0.2351
SPY 0.1213 0.0887
TSLA 0.3603 0.2480
WMT 0.1224 0.0847
XOM 0.1309 0.1749
The variance of returns is the arithmetic mean of the squared deviations from the mean:
VAR(r)=\frac{(r_{1}-\bar{r})^{2}+(r_{2}-\bar{r})^{2}+\cdots+(r_{N}-\bar{r})^{2}}{N-1}
We square the deviations for a simple reason: deviations above and below the mean would cancel out otherwise. Squaring turns all of them positive, at the cost of producing a number in “squared returns”, which nobody can interpret. That is why we take the square root and obtain the standard deviation, which in finance is called volatility:
SD(r)=\sqrt{VAR(r)}
Volatility can be read as the average distance between a monthly return and its mean. It is expressed in the same units as the return, so it is interpretable.
Since cc returns add up over time and (assuming returns are uncorrelated across months) the variance of a sum is the sum of the variances:
VAR_{annual}=12\cdot VAR_{monthly} \quad\Longrightarrow\quad SD_{annual}=\sqrt{12}\cdot SD_{monthly}
This is the famous square-root-of-time rule. Note the crucial assumption behind it: returns must not be autocorrelated. Keep this in mind — when we get to hedge funds you will see that many funds report smoothed returns (illiquid assets valued by models instead of by market prices), which creates positive autocorrelation, which makes the square-root-of-time rule understate their true risk. This is one of the classic criticisms of hedge fund performance reporting.
vol_m = R.std()
vol_a = vol_m * np.sqrt(12)
risk_table = pd.DataFrame({"VAR monthly": R.var(),
"SD monthly": vol_m,
"SD annual": vol_a}).sort_values("SD annual", ascending=False)
risk_table VAR monthly SD monthly SD annual
TSLA 0.0294 0.1714 0.5938
AAPL 0.0063 0.0792 0.2744
XOM 0.0050 0.0710 0.2459
JPM 0.0045 0.0671 0.2325
WMT 0.0024 0.0488 0.1690
SPY 0.0020 0.0447 0.1550
GLD 0.0016 0.0402 0.1394
HYG 0.0006 0.0242 0.0838
The covariance of two returns is the arithmetic mean of the product of their deviations:
COV(r_{1},r_{2})=\frac{\sum_{t=1}^{N}(r_{1,t}-\bar{r_{1}})(r_{2,t}-\bar{r_{2}})}{N-1}
If both returns tend to be above their mean at the same time, each product is positive and the covariance is positive: the two assets move together. If one tends to be above its mean when the other is below, the products are negative and the covariance is negative.
Covariance measures linear co-movement, but its magnitude is impossible to interpret (it depends on the units). Standardizing it by the two volatilities gives the correlation, which is always between −1 and +1:
CORR(r_{1},r_{2})=\frac{COV(r_{1},r_{2})}{SD(r_{1})\,SD(r_{2})}, \qquad -1\le CORR \le +1
COV = R.cov() # variance-covariance matrix (monthly)
CORR = R.corr()
plt.figure(figsize=(8, 6))<Figure size 800x600 with 0 Axes>
sb.heatmap(CORR, annot=True, fmt=".2f", cmap="coolwarm", center=0, vmin=-1, vmax=1)<Axes: >
plt.title("Correlation matrix of monthly cc returns")Text(0.5, 1.0, 'Correlation matrix of monthly cc returns')
plt.show()Read this matrix carefully, because it is the raw material of every hedge strategy we will study:
The equity names are strongly correlated with SPY. That common movement is systematic risk and it cannot be diversified away by adding more stocks.
GLD has a correlation close to zero with everything. That is what makes gold a diversifier: adding it reduces portfolio risk much more than adding another stock.
HYG sits in between: high-yield bonds carry credit risk, and credit risk is partly equity risk in disguise.
The expected return of a portfolio is the weighted average of the expected returns of its assets:
E[R_P]=w_1E[R_1]+w_2E[R_2]+\cdots+w_NE[R_N]=\sum_{i=1}^{N}w_iE[R_i]
Let’s build a simple 2-asset portfolio: 60% AAPL and 40% GLD.
w_aapl, w_gld = 0.60, 0.40
ER_port = w_aapl*ER_a["AAPL"] + w_gld*ER_a["GLD"]
print("Expected annual return of the 60/40 AAPL-GLD portfolio: %.4f" % ER_port)Expected annual return of the 60/40 AAPL-GLD portfolio: 0.1489
Matrix multiplication is a machine for computing sums of products, so the same calculation is:
E[R_P]=\mathbf{w}'\mathbf{E[R]}=\begin{bmatrix}w_{1} & w_{2}\end{bmatrix}\begin{bmatrix}E[R_{1}]\\E[R_{2}]\end{bmatrix}=w_1E[R_1]+w_2E[R_2]
where \mathbf{w}' (read “w transpose”) is the weight vector rotated 90 degrees, from a column into a row.
This looks like unnecessary notation for 2 assets, but for 200 assets it is the only practical way to write it — and it is one line of code:
w = np.array([0.60, 0.40])
mu = ER_a[["AAPL", "GLD"]].values
print("Sum of products :", w[0]*mu[0] + w[1]*mu[1])Sum of products : 0.14891305223288256
print("Matrix algebra :", w @ mu)Matrix algebra : 0.14891305223288256
Here is the real contribution of Harry Markowitz (1952). The variance of a 2-asset portfolio is not the weighted average of the variances:
VAR(R_P)=w_{1}^{2}VAR(r_{1})+w_{2}^{2}VAR(r_{2})+2w_{1}w_{2}COV(r_{1},r_{2})
And since COV(r_1,r_2)=CORR(r_1,r_2)\sigma_1\sigma_2, we can write it in the form that shows what is going on:
VAR(R_P)=w_{1}^{2}\sigma_1^2+w_{2}^{2}\sigma_2^2+2w_{1}w_{2}\,\rho_{12}\,\sigma_1\sigma_2
The third term is diversification. The first two terms are always positive; the third one takes the sign of the correlation. If \rho_{12}<0, the portfolio variance is smaller than the weighted average of the individual variances. This is the only free lunch in finance.
For N assets the formula becomes a double sum with N variances and N(N-1) covariance terms:
VAR(R_P)=\sum_{i=1}^{N}\sum_{j=1}^{N}w_iw_jCOV(r_i,r_j)
For 10 assets that is 100 terms; for 100 assets, 10,000 terms. Nobody writes that by hand. In matrix form it is:
VAR(R_P)=\mathbf{w}'\,\boldsymbol{\Sigma}\,\mathbf{w}
where \boldsymbol{\Sigma} is the variance-covariance matrix, which has the variances on the diagonal and the covariances off the diagonal:
\boldsymbol{\Sigma}=\begin{bmatrix}VAR(r_{1}) & COV(r_{1},r_{2})\\ COV(r_{2},r_{1}) & VAR(r_{2})\end{bmatrix}
And the portfolio risk is simply:
SD(R_P)=\sqrt{\mathbf{w}'\boldsymbol{\Sigma}\mathbf{w}}
COV_a = 12 * R[["AAPL", "GLD"]].cov() # annualized cov matrix
S = COV_a.values
# Method 1: sum of products
var1 = (w[0]**2)*S[0,0] + (w[1]**2)*S[1,1] + 2*w[0]*w[1]*S[0,1]
# Method 2: matrix algebra
var2 = w @ S @ w
print("Portfolio variance (sum of products): %.6f" % var1)Portfolio variance (sum of products): 0.032042
print("Portfolio variance (matrix algebra) : %.6f" % var2)Portfolio variance (matrix algebra) : 0.032042
print("Portfolio volatility (annual) : %.4f" % np.sqrt(var2))Portfolio volatility (annual) : 0.1790
print()print("Volatility of AAPL alone : %.4f" % np.sqrt(S[0,0]))Volatility of AAPL alone : 0.2744
print("Volatility of GLD alone : %.4f" % np.sqrt(S[1,1]))Volatility of GLD alone : 0.1394
print("Weighted average of the volatilities: %.4f" % (w[0]*np.sqrt(S[0,0]) + w[1]*np.sqrt(S[1,1])))Weighted average of the volatilities: 0.2204
Look at the last two lines. The portfolio volatility is lower than the weighted average of the individual volatilities. Nothing was given up in expected return — the expected return is exactly the weighted average. That gap is diversification, and it exists only because the correlation is below 1.
Diversification is driven by two things:
N, the number of assets. More assets, more diversification — but with decreasing returns, and only up to a floor.
The correlations between the assets. The lower the average correlation, the more diversification. This driver is far more powerful than the first one.
Let’s see the first driver. We build equally weighted portfolios of N=1,2,\ldots,8 instruments and plot their volatility:
order = list(R.columns)
vols = []
for n in range(1, len(order)+1):
sub = R[order[:n]]
wn = np.repeat(1/n, n)
vols.append(np.sqrt(wn @ (12*sub.cov().values) @ wn))
plt.figure(figsize=(9, 5))<Figure size 900x500 with 0 Axes>
plt.plot(range(1, len(order)+1), vols, marker="o")[<matplotlib.lines.Line2D object at 0x0000028B4DD69400>]
plt.xlabel("Number of assets in the equally weighted portfolio")Text(0.5, 0, 'Number of assets in the equally weighted portfolio')
plt.ylabel("Annualized portfolio volatility")Text(0, 0.5, 'Annualized portfolio volatility')
plt.title("Diversification: risk falls, but it does not fall to zero")Text(0.5, 1.0, 'Diversification: risk falls, but it does not fall to zero')
plt.grid(alpha=0.3)
plt.show()The curve falls and then flattens out. The part that disappears is the non-systematic (idiosyncratic, diversifiable, firm-specific) risk. The floor it converges to is the systematic (market) risk, which no amount of diversification within the same market can remove. Removing that risk requires something else: short positions, derivatives, or assets from a different market. That is what hedge funds do.
Now the second driver. Let’s simulate the frontier of a 2-asset portfolio under different hypothetical correlations:
sd1, sd2 = vol_a["AAPL"], vol_a["GLD"]
m1, m2 = ER_a["AAPL"], ER_a["GLD"]
wa = np.linspace(0, 1, 101)
plt.figure(figsize=(9, 6))<Figure size 900x600 with 0 Axes>
for rho in [-1, -0.5, 0, 0.5, 1]:
port_sd = np.sqrt((wa**2)*sd1**2 + ((1-wa)**2)*sd2**2 + 2*wa*(1-wa)*rho*sd1*sd2)
port_er = wa*m1 + (1-wa)*m2
plt.plot(port_sd, port_er, label=f"correlation = {rho}")[<matplotlib.lines.Line2D object at 0x0000028B4DF1F0E0>]
[<matplotlib.lines.Line2D object at 0x0000028B4DF1F230>]
[<matplotlib.lines.Line2D object at 0x0000028B4DF1F380>]
[<matplotlib.lines.Line2D object at 0x0000028B4DF1F4D0>]
[<matplotlib.lines.Line2D object at 0x0000028B4DF1F620>]
plt.scatter([sd1, sd2], [m1, m2], color="black", zorder=5)<matplotlib.collections.PathCollection object at 0x0000028B4DF1EF90>
plt.annotate("AAPL", (sd1, m1), textcoords="offset points", xytext=(8, 0))Text(8, 0, 'AAPL')
plt.annotate("GLD", (sd2, m2), textcoords="offset points", xytext=(8, 0))Text(8, 0, 'GLD')
plt.xlabel("Portfolio risk (annual volatility)")Text(0.5, 0, 'Portfolio risk (annual volatility)')
plt.ylabel("Portfolio expected return (annual)")Text(0, 0.5, 'Portfolio expected return (annual)')
plt.title("The effect of correlation on the set of feasible portfolios")Text(0.5, 1.0, 'The effect of correlation on the set of feasible portfolios')
plt.legend(); plt.grid(alpha=0.3)<matplotlib.legend.Legend object at 0x0000028B4DF1F8C0>
plt.show()Read the plot from right to left:
With correlation = +1 the frontier is a straight line. There is no diversification at all: combining two assets that move identically is the same as owning one of them.
As correlation decreases, the line bends to the left into a hyperbola. Bending to the left means: for the same expected return, less risk. That is diversification, and you can literally see it.
With correlation = −1 the curve touches the vertical axis: there exists a combination of the two assets with zero risk. It is the roulette bet where you put half your money on red and half on black — you know exactly what will happen.
Using the same dataset:
Build a portfolio of 50% TSLA and 50% HYG and compute its expected annual return and annual volatility with both methods (sum of products and matrix algebra).
Compare its volatility with the weighted average of the individual volatilities. How many percentage points did diversification save you?
(optional) Repeat (a) and (b) for 50% AAPL and 50% JPM. Which of the two portfolios diversified more, and why? Connect your answer to the correlation matrix.
(Optional, Hard) Find, analytically, the weight w in TSLA that minimizes the variance of the TSLA/HYG portfolio. (Hint: derive VAR(R_P) with respect to w, set it to zero, and you get w^{*}=\frac{\sigma_2^2-COV_{12}}{\sigma_1^2+\sigma_2^2-2COV_{12}}.) Verify your result numerically with a grid of weights.
From now on we work with a universe of 6 risky assets (we leave out SPY, which is our market benchmark, and HYG):
ASSETS = ["AAPL", "JPM", "WMT", "XOM", "TSLA", "GLD"]
mu_a = np.exp(12*R[ASSETS].mean()).values - 1 # annual expected returns
COV_A = (12 * R[ASSETS].cov()).values # annual cov matrix
RF = 0.035 # annual risk-free rate assumption
pd.DataFrame({"E[R] annual": mu_a,
"SD annual": np.sqrt(np.diag(COV_A))}, index=ASSETS) E[R] annual SD annual
AAPL 0.2108 0.2744
JPM 0.1620 0.2325
WMT 0.1224 0.1690
XOM 0.1309 0.2459
TSLA 0.3603 0.5938
GLD 0.0560 0.1394
With 2 assets the feasible portfolios form a curve. With 3 or more assets they form a region: for the same level of risk there are many possible portfolios, some clearly worse than others. Let’s see it by simulating 5,000 random portfolios:
rng = np.random.default_rng(123)
n_port = 5000
W = rng.random((n_port, len(ASSETS)))
W = W / W.sum(axis=1, keepdims=True) # each row sums to 1 (long-only)
port_ret = W @ mu_a
port_vol = np.sqrt(np.einsum("ij,jk,ik->i", W, COV_A, W))
port_sharpe = (port_ret - RF) / port_vol
plt.figure(figsize=(10, 6))<Figure size 1000x600 with 0 Axes>
sc = plt.scatter(port_vol, port_ret, c=port_sharpe, cmap="viridis", s=8, alpha=0.7)
plt.colorbar(sc, label="Sharpe ratio")<matplotlib.colorbar.Colorbar object at 0x0000028B54081FD0>
plt.scatter(np.sqrt(np.diag(COV_A)), mu_a, color="red", marker="D", s=60)<matplotlib.collections.PathCollection object at 0x0000028B5410B610>
for i, a in enumerate(ASSETS):
plt.annotate(a, (np.sqrt(COV_A[i,i]), mu_a[i]), textcoords="offset points", xytext=(8,0))Text(8, 0, 'AAPL')
Text(8, 0, 'JPM')
Text(8, 0, 'WMT')
Text(8, 0, 'XOM')
Text(8, 0, 'TSLA')
Text(8, 0, 'GLD')
plt.xlabel("Annual portfolio risk (volatility)")Text(0.5, 0, 'Annual portfolio risk (volatility)')
plt.ylabel("Annual portfolio expected return")Text(0, 0.5, 'Annual portfolio expected return')
plt.title("5,000 random long-only portfolios")Text(0.5, 1.0, '5,000 random long-only portfolios')
plt.grid(alpha=0.3)
plt.show()Notice two things. First, most individual assets (red diamonds) lie inside the cloud: almost any portfolio beats almost any single stock. Second, the cloud has an upper-left boundary. Portfolios on that boundary are efficient; every portfolio strictly inside is dominated — there is another portfolio with the same risk and more return, or the same return and less risk.
The GMV portfolio is the least risky portfolio that can be built with a given set of assets. Formally:
\min_{\mathbf{w}} \ \mathbf{w}'\boldsymbol{\Sigma}\mathbf{w} \quad \text{subject to} \quad \mathbf{w}'\mathbf{1}=1
Solving this with a Lagrange multiplier gives a closed-form solution that you can compute in one line:
\mathbf{w}_{GMV}=\frac{\boldsymbol{\Sigma}^{-1}\mathbf{1}}{\mathbf{1}'\boldsymbol{\Sigma}^{-1}\mathbf{1}}
Note what is not in the formula: the expected returns. The GMV portfolio does not care about them. This is a big practical advantage, because expected returns are exactly the input we estimate worst.
ones = np.ones(len(ASSETS))
Sinv = np.linalg.inv(COV_A)
w_gmv = Sinv @ ones / (ones @ Sinv @ ones)
def port_perf(w, mu=mu_a, S=COV_A, rf=RF):
"""Return (expected return, volatility, Sharpe ratio) of a portfolio."""
r = w @ mu
s = np.sqrt(w @ S @ w)
return r, s, (r - rf)/s
r_gmv, s_gmv, sh_gmv = port_perf(w_gmv)
print(pd.Series(w_gmv, index=ASSETS, name="GMV weights").round(4))AAPL 0.0381
JPM 0.0286
WMT 0.3294
XOM 0.0689
TSLA -0.0067
GLD 0.5416
Name: GMV weights, dtype: float64
print("\nGMV expected return: %.4f | volatility: %.4f | Sharpe: %.4f" % (r_gmv, s_gmv, sh_gmv))
GMV expected return: 0.0899 | volatility: 0.1066 | Sharpe: 0.5155
Compare that volatility with the volatility of the least volatile individual asset in the table above. The GMV portfolio is less risky than any of the assets it is made of.
The GMV portfolio has minimum risk, but minimum risk is not the goal — the goal is the best risk-return trade-off. That trade-off is measured by the Sharpe ratio:
SharpeRatio=\frac{E[R_P]-R_f}{SD(R_P)}
which reads: how much expected return above the risk-free rate do I get for each unit of volatility I accept?
The tangency portfolio (also called the optimal or market portfolio) is the portfolio of risky assets with the highest possible Sharpe ratio. Its closed-form solution is:
\mathbf{w}_{TAN}=\frac{\boldsymbol{\Sigma}^{-1}(\boldsymbol{\mu}-R_f\mathbf{1})}{\mathbf{1}'\boldsymbol{\Sigma}^{-1}(\boldsymbol{\mu}-R_f\mathbf{1})}
where \boldsymbol{\mu}-R_f\mathbf{1} is the vector of expected premium returns (excess returns over the risk-free rate).
z = Sinv @ (mu_a - RF*ones)
w_tan = z / (ones @ z)
r_tan, s_tan, sh_tan = port_perf(w_tan)
print(pd.Series(w_tan, index=ASSETS, name="Tangency weights").round(4))AAPL 0.2509
JPM 0.1368
WMT 0.3898
XOM 0.0122
TSLA 0.1034
GLD 0.1069
Name: Tangency weights, dtype: float64
print("\nTangency expected return: %.4f | volatility: %.4f | Sharpe: %.4f" % (r_tan, s_tan, sh_tan))
Tangency expected return: 0.1676 | volatility: 0.1656 | Sharpe: 0.8008
Both closed-form solutions allow short selling (negative weights). If you re-run the tangency formula adding HYG to the universe, you will get weights like +250% in one asset financed by a −640% short in another (try it — it is challenge 3a). The optimizer is not crazy: it is doing exactly what you asked, which is to exploit every last digit of the estimated expected returns and covariances. The problem is that those digits are noise.
Mean-variance optimization is often called an “error maximizer”. The standard fixes are: (i) constrain the weights (long-only, or maximum position size), (ii) shrink the covariance matrix (Ledoit-Wolf), (iii) estimate expected returns with a model (CAPM, factor model, Black-Litterman) instead of the historical mean. When we get to hedge fund replication and risk management, this problem will come back.
Here is the same optimization but constrained to long-only weights, solved numerically. In this particular universe the unconstrained solution happened to have no negative weights already, so the constraint does not bind and both answers coincide — which is a good way to check that your numerical optimizer agrees with the closed-form solution. Add HYG and the two answers will diverge dramatically:
def neg_sharpe(w):
r, s, _ = port_perf(w)
return -(r - RF)/s
cons = ({"type": "eq", "fun": lambda w: w.sum() - 1},)
bnds = [(0, 1)]*len(ASSETS)
x0 = np.repeat(1/len(ASSETS), len(ASSETS))
opt = optimize.minimize(neg_sharpe, x0, method="SLSQP", bounds=bnds, constraints=cons)
w_tan_lo = opt.x
print(pd.Series(w_tan_lo, index=ASSETS, name="Tangency, long-only").round(4))AAPL 0.2510
JPM 0.1368
WMT 0.3899
XOM 0.0121
TSLA 0.1035
GLD 0.1066
Name: Tangency, long-only, dtype: float64
print("\nReturn: %.4f | Vol: %.4f | Sharpe: %.4f" % port_perf(w_tan_lo))
Return: 0.1677 | Vol: 0.1657 | Sharpe: 0.8008
The efficient frontier is the set of portfolios that offer the maximum expected return for each level of risk (equivalently, the minimum risk for each level of expected return).
There is a beautiful result that makes it trivial to compute, called two-fund separation: every portfolio on the frontier can be written as a combination of any two frontier portfolios. Since we already have two of them (GMV and tangency), the whole frontier is:
\mathbf{w}(a)=a\cdot\mathbf{w}_{GMV}+(1-a)\cdot\mathbf{w}_{TAN}, \qquad a\in\mathbb{R}
alphas = np.linspace(-1.0, 2.0, 200)
frontier = np.array([a*w_gmv + (1-a)*w_tan for a in alphas])
f_ret = frontier @ mu_a
f_vol = np.sqrt(np.einsum("ij,jk,ik->i", frontier, COV_A, frontier))
plt.figure(figsize=(10, 6))<Figure size 1000x600 with 0 Axes>
plt.scatter(port_vol, port_ret, c="lightgrey", s=6, label="random long-only portfolios")<matplotlib.collections.PathCollection object at 0x0000028B541E6FD0>
plt.plot(f_vol, f_ret, "b-", lw=2, label="Efficient frontier (short sales allowed)")[<matplotlib.lines.Line2D object at 0x0000028B542C4980>]
plt.scatter(s_gmv, r_gmv, marker="D", s=140, color="green", zorder=5, label="GMV portfolio")<matplotlib.collections.PathCollection object at 0x0000028B541E7110>
plt.scatter(s_tan, r_tan, marker="*", s=320, color="red", zorder=5, label="Tangency portfolio")<matplotlib.collections.PathCollection object at 0x0000028B541E7250>
plt.scatter(np.sqrt(np.diag(COV_A)), mu_a, color="black", marker="x", s=60, label="individual assets")<matplotlib.collections.PathCollection object at 0x0000028B541E7390>
plt.xlabel("Annual risk (volatility)"); plt.ylabel("Annual expected return")Text(0.5, 0, 'Annual risk (volatility)')
Text(0, 0.5, 'Annual expected return')
plt.title("The efficient frontier")Text(0.5, 1.0, 'The efficient frontier')
plt.legend(); plt.grid(alpha=0.3)<matplotlib.legend.Legend object at 0x0000028B542C4830>
plt.xlim(0, max(port_vol)*1.5)(0.0, 0.5756134555856285)
plt.show()Strictly speaking, only the part of the hyperbola above the GMV portfolio is efficient. The lower branch has, for each level of risk, a twin portfolio above it with the same risk and a higher expected return, so nobody rational would hold it.
Now we add a risk-free asset to the picture. A risk-free asset has \sigma=0 and, by definition, zero correlation with everything.
Suppose you invest a fraction y of your money in the tangency portfolio and (1-y) in the risk-free asset. Then:
E[R_C]=R_f+y\left(E[R_{TAN}]-R_f\right) \qquad SD(R_C)=y\cdot SD(R_{TAN})
Solving for y in the second equation and substituting into the first gives a straight line in the risk-return space:
E[R_C]=R_f+\left[\frac{E[R_{TAN}]-R_f}{SD(R_{TAN})}\right]SD(R_C)
This line is the Capital Market Line, and its slope is exactly the Sharpe ratio of the tangency portfolio.
The key insight: once the risk-free asset is available, the CML becomes the new efficient frontier, and it is strictly better than the old hyperbola (it lies above it everywhere except at the tangency point). Every investor, regardless of risk aversion, should hold the same portfolio of risky assets — the tangency portfolio — and then adjust their risk by moving along the line:
That last point is the one to remember for this course. Leverage does not change the Sharpe ratio; it moves you up the same line. A hedge fund that reports a spectacular return with 5× leverage has not necessarily created any value — it has just moved to the right along a line you could have walked yourself.
x = np.linspace(0, max(port_vol)*1.4, 100)
cml = RF + sh_tan * x
plt.figure(figsize=(10, 6))<Figure size 1000x600 with 0 Axes>
plt.scatter(port_vol, port_ret, c="lightgrey", s=6)<matplotlib.collections.PathCollection object at 0x0000028B54367750>
plt.plot(f_vol, f_ret, "b-", lw=2, label="Efficient frontier of risky assets")[<matplotlib.lines.Line2D object at 0x0000028B54371A90>]
plt.plot(x, cml, "r--", lw=2, label=f"CML (slope = Sharpe = {sh_tan:.3f})")[<matplotlib.lines.Line2D object at 0x0000028B54371BE0>]
plt.scatter(0, RF, marker="o", s=110, color="black", zorder=5, label=f"Risk-free asset ({RF:.1%})")<matplotlib.collections.PathCollection object at 0x0000028B54367890>
plt.scatter(s_tan, r_tan, marker="*", s=320, color="red", zorder=5, label="Tangency portfolio")<matplotlib.collections.PathCollection object at 0x0000028B543679D0>
plt.scatter(s_gmv, r_gmv, marker="D", s=140, color="green", zorder=5, label="GMV portfolio")<matplotlib.collections.PathCollection object at 0x0000028B54367B10>
plt.xlabel("Annual risk (volatility)"); plt.ylabel("Annual expected return")Text(0.5, 0, 'Annual risk (volatility)')
Text(0, 0.5, 'Annual expected return')
plt.title("The Capital Market Line becomes the new efficient frontier")Text(0.5, 1.0, 'The Capital Market Line becomes the new efficient frontier')
plt.legend(); plt.grid(alpha=0.3)<matplotlib.legend.Legend object at 0x0000028B543717F0>
plt.ylim(0, max(f_ret)*1.05)(0.0, 0.2575512618838345)
plt.show()Recompute the GMV portfolio, the tangency portfolio and the efficient frontier including HYG in the universe of risky assets. Report the weights. What happened, and why?
Recompute the tangency portfolio using an annual risk-free rate of 1% and then of 6%. How do the weights change? Explain intuitively why the risk-free rate changes the composition of the optimal portfolio of risky assets.
(Optional, hard) Build the long-only efficient frontier by solving, for a grid of target returns, the problem \min \mathbf{w}'\boldsymbol{\Sigma}\mathbf{w} subject to \mathbf{w}'\boldsymbol{\mu}=\mu^{target}, \mathbf{w}'\mathbf{1}=1 and w_i\ge0. Plot it together with the unconstrained frontier. Which one dominates, and does that mean constraints are bad?
(Optional) An investor wants an annual volatility of exactly 10% using the
The Capital Asset Pricing Model says that the expected return of an asset is the risk-free rate plus its beta times the market premium return:
E[R_i]=R_f+\beta_i\left(E[R_M]-R_f\right)
or, in premium terms:
E[R_i]-R_f=\beta_i\left(E[R_M]-R_f\right)
The CAPM was built directly on top of what we just did. The logic chain is:
\beta_i=\frac{COV(R_i,R_M)}{VAR(R_M)}
The intuition of beta: how much does this asset amplify or dampen the movements of the market?
We estimate beta by running an OLS regression of the asset premium return on the market premium return:
(R_{i,t}-R_{f,t})=b_0+b_1(R_{M,t}-R_{f,t})+\varepsilon_t
where:
Before running the regression, always look at the data:
x_mkt = mkt_prem
y_tsla = premR["TSLA_prem"]
plt.figure(figsize=(8, 7))<Figure size 800x700 with 0 Axes>
sb.regplot(x=x_mkt, y=y_tsla, line_kws={"color": "red"})<Axes: xlabel='Mkt_RF', ylabel='TSLA_prem'>
plt.xlabel("Market premium return (monthly)")Text(0.5, 0, 'Market premium return (monthly)')
plt.ylabel("TSLA premium return (monthly)")Text(0, 0.5, 'TSLA premium return (monthly)')
plt.title("The CAPM regression is the line that best fits this cloud")Text(0.5, 1.0, 'The CAPM regression is the line that best fits this cloud')
lim = max(abs(x_mkt).max(), abs(y_tsla).max())*1.05
plt.xlim(-lim, lim); plt.ylim(-lim, lim) # same scale on both axes(-0.5961443609689072, 0.5961443609689072)
(-0.5961443609689072, 0.5961443609689072)
plt.axhline(0, color="grey", lw=0.6); plt.axvline(0, color="grey", lw=0.6)<matplotlib.lines.Line2D object at 0x0000028B5445E510>
<matplotlib.lines.Line2D object at 0x0000028B5445E3C0>
plt.grid(alpha=0.3)
plt.show()Making both axes have the same scale is not cosmetic: with different scales, a beta of 2 and a beta of 0.5 can be made to look identical.
Now the regression:
data_reg = pd.DataFrame({"stock_prem": premR["TSLA_prem"], "mkt_prem": mkt_prem})
capm_tsla = smf.ols("stock_prem ~ mkt_prem", data=data_reg).fit()
print(capm_tsla.summary()) OLS Regression Results
==============================================================================
Dep. Variable: stock_prem R-squared: 0.257
Model: OLS Adj. R-squared: 0.252
Method: Least Squares F-statistic: 47.48
Date: Tue, 11 Aug 2026 Prob (F-statistic): 1.85e-10
Time: 09:49:02 Log-Likelihood: 69.038
No. Observations: 139 AIC: -134.1
Df Residuals: 137 BIC: -128.2
Df Model: 1
Covariance Type: nonrobust
==============================================================================
coef std err t P>|t| [0.025 0.975]
------------------------------------------------------------------------------
Intercept 0.0088 0.013 0.691 0.491 -0.016 0.034
mkt_prem 1.9333 0.281 6.890 0.000 1.378 2.488
==============================================================================
Omnibus: 5.970 Durbin-Watson: 2.052
Prob(Omnibus): 0.051 Jarque-Bera (JB): 9.472
Skew: -0.043 Prob(JB): 0.00877
Kurtosis: 4.276 Cond. No. 22.3
==============================================================================
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
And the same beta computed directly from its definition, to convince yourself that OLS is not magic:
beta_formula = np.cov(premR["TSLA_prem"], mkt_prem, ddof=1)[0,1] / np.var(mkt_prem, ddof=1)
alpha_formula = premR["TSLA_prem"].mean() - beta_formula*mkt_prem.mean()
print("beta from the covariance formula: %.4f" % beta_formula)beta from the covariance formula: 1.9333
print("beta from the OLS regression : %.4f" % capm_tsla.params.iloc[1])beta from the OLS regression : 1.9333
print("alpha from the formula : %.6f" % alpha_formula)alpha from the formula : 0.008823
print("alpha from the OLS regression : %.6f" % capm_tsla.params.iloc[0])alpha from the OLS regression : 0.008823
Every coefficient comes with four numbers you must be able to interpret:
def capm_table(prem_col, mkt=mkt_prem, prem=premR):
d = pd.DataFrame({"y": prem[prem_col].values, "x": mkt.values}, index=mkt.index)
m = smf.ols("y ~ x", data=d).fit()
ci = m.conf_int()
return pd.Series({
"alpha (monthly)": m.params.iloc[0],
"alpha annualized": m.params.iloc[0]*12,
"alpha p-value": m.pvalues.iloc[0],
"beta": m.params.iloc[1],
"beta SE": m.bse.iloc[1],
"beta t-stat": m.tvalues.iloc[1],
"beta CI low": ci.iloc[1, 0],
"beta CI high": ci.iloc[1, 1],
"R-squared": m.rsquared,
"resid SD (monthly)": np.sqrt(m.mse_resid)})
capm_all = pd.DataFrame({c.replace("_prem",""): capm_table(c) for c in premR.columns}).T
capm_all.sort_values("beta") alpha (monthly) alpha annualized ... R-squared resid SD (monthly)
GLD 0.0024 0.0290 ... 0.0030 0.0402
HYG -0.0005 -0.0060 ... 0.5230 0.0168
WMT 0.0037 0.0439 ... 0.2487 0.0425
XOM 0.0017 0.0198 ... 0.3108 0.0592
SPY 0.0001 0.0011 ... 0.9823 0.0060
JPM 0.0027 0.0318 ... 0.4885 0.0481
AAPL 0.0046 0.0556 ... 0.4837 0.0572
TSLA 0.0088 0.1059 ... 0.2574 0.1483
[8 rows x 10 columns]
Take your time with this table. Things you should be able to say out loud:
SPY has a beta of essentially 1 and an R² very close to 1. Of course: it is the market (the tiny difference is because the Fama-French market factor is a broader index than the S&P 500). This is the sanity check for your code — if your SPY beta is not close to 1, something is wrong with your data alignment.GLD has a beta near zero and an R² near zero. Gold’s returns have essentially nothing to do with the stock market. That is precisely why it diversifies so well.TSLA has the highest beta. It is roughly twice as sensitive to the market as the average stock.WMT has a low beta: a defensive stock, as expected.Look at the confidence intervals for beta: they are typically ±0.2 or ±0.3 wide. And alpha is far worse: with monthly data, detecting a statistically significant alpha of 3% per year requires decades of data.
This is the single most important statistical fact about hedge fund evaluation. When a fund shows 5 years of positive alpha, the honest statement is usually “we cannot reject that this fund has zero alpha” — not “this manager is skilled”. We will return to this when we discuss performance measurement and survivorship bias.
The regression splits the asset’s variance into two pieces. Taking variances on both sides of the CAPM regression, and using the fact that \varepsilon is uncorrelated with the market by construction:
\underbrace{\sigma_i^2}_{\text{total risk}}=\underbrace{\beta_i^2\sigma_M^2}_{\text{systematic}}+\underbrace{\sigma_{\varepsilon_i}^2}_{\text{non-systematic}}
And the R² of the regression is exactly the fraction of the total risk that is systematic:
R^2=\frac{\beta_i^2\sigma_M^2}{\sigma_i^2}
var_m = mkt_prem.var(ddof=1)
decomp = pd.DataFrame({
"total variance": R[capm_all.index].var(ddof=1),
"systematic": (capm_all["beta"]**2)*var_m,
"non-systematic": capm_all["resid SD (monthly)"]**2,
"R2 (% systematic)": capm_all["R-squared"]})
decomp["check total"] = decomp["systematic"] + decomp["non-systematic"]
decomp.sort_values("R2 (% systematic)", ascending=False) total variance systematic ... R2 (% systematic) check total
SPY 0.0020 0.0020 ... 0.9823 0.0020
HYG 0.0006 0.0003 ... 0.5230 0.0006
JPM 0.0045 0.0022 ... 0.4885 0.0045
AAPL 0.0063 0.0030 ... 0.4837 0.0063
XOM 0.0050 0.0016 ... 0.3108 0.0051
TSLA 0.0294 0.0076 ... 0.2574 0.0296
WMT 0.0024 0.0006 ... 0.2487 0.0024
GLD 0.0016 0.0000 ... 0.0030 0.0016
[8 rows x 5 columns]
plt.figure(figsize=(10, 5))<Figure size 1000x500 with 0 Axes>
d = decomp.sort_values("R2 (% systematic)", ascending=False)
plt.bar(d.index, d["systematic"], label="Systematic risk")<BarContainer object of 8 artists>
plt.bar(d.index, d["non-systematic"], bottom=d["systematic"], label="Non-systematic risk")<BarContainer object of 8 artists>
plt.ylabel("Monthly variance")Text(0, 0.5, 'Monthly variance')
plt.title("Decomposition of total risk")Text(0.5, 1.0, 'Decomposition of total risk')
plt.legend(); plt.grid(alpha=0.3, axis="y")<matplotlib.legend.Legend object at 0x0000028B5454FA10>
plt.show()TSLA is interesting: it has by far the largest systematic risk in absolute terms (high beta), but most of its total variance is still idiosyncratic — its R² is low. A portfolio manager who buys Tesla is making a bet on Tesla, not on the market.
Beta is a linear measure, and linearity has a very convenient consequence: the beta of a portfolio is simply the weighted average of the betas of its components.
\beta_P=\sum_{i=1}^{N}w_i\beta_i
Note the contrast with risk: portfolio variance is not the weighted average of the variances (that was the whole point of Markowitz), but portfolio beta is the weighted average of the betas. The reason is that beta measures a covariance with a common factor, and covariance is a linear operator.
This is not a curiosity, it is a tool. It means you can engineer any beta you want:
betas = capm_all["beta"]
w_demo = pd.Series({"AAPL": 0.40, "WMT": 0.35, "GLD": 0.25})
# Method 1: weighted average of betas
beta_wavg = (w_demo * betas[w_demo.index]).sum()
# Method 2: run the CAPM regression on the actual portfolio return series
port_ret_series = (R[w_demo.index] * w_demo.values).sum(axis=1)
dpb = pd.DataFrame({"y": (port_ret_series - rf_m).values, "x": mkt_prem.values}, index=mkt_prem.index)
beta_reg = smf.ols("y ~ x", data=dpb).fit().params.iloc[1]
print("Portfolio beta, weighted average of betas : %.4f" % beta_wavg)Portfolio beta, weighted average of betas : 0.6924
print("Portfolio beta, estimated by regression : %.4f" % beta_reg)Portfolio beta, estimated by regression : 0.6924
The two numbers agree, as they must.
Now the punchline of this section. If the beta of a portfolio is a weighted average, and weights can be negative, then we can choose the portfolio’s beta. In particular, we can set it to zero.
Suppose you like Apple and Walmart as businesses but you do not want to bet on the direction of the stock market. Buy the two stocks, and short the market in the exact amount needed to cancel the beta:
\beta_P = \beta_{long\ leg} - h\cdot\beta_{market}= \beta_{long\ leg} - h = 0 \quad\Longrightarrow\quad h=\beta_{long\ leg}
long_leg = 0.5*R["AAPL"] + 0.5*R["WMT"]
beta_long = 0.5*betas["AAPL"] + 0.5*betas["WMT"]
# short SPY for an amount equal to beta_long; the proceeds earn the risk-free rate
mkt_neutral = long_leg - beta_long*R["SPY"] + beta_long*rf_m
hedged = pd.DataFrame({"y": (mkt_neutral - rf_m).values, "x": mkt_prem.values}, index=mkt_prem.index)
check = smf.ols("y ~ x", data=hedged).fit()
print("Hedge ratio (short in SPY): %.4f" % beta_long)Hedge ratio (short in SPY): 0.8840
print("Beta of the hedged portfolio: %.4f (p-value %.3f)" % (check.params.iloc[1], check.pvalues.iloc[1]))Beta of the hedged portfolio: 0.0104 (p-value 0.871)
print("R-squared of the hedged portfolio vs the market: %.4f" % check.rsquared)R-squared of the hedged portfolio vs the market: 0.0002
print("\nAnnualized return - long only: %.4f | hedged: %.4f" % (long_leg.mean()*12, mkt_neutral.mean()*12))
Annualized return - long only: 0.1534 | hedged: 0.0706
print("Annualized volatility - long only: %.4f | hedged: %.4f" % (long_leg.std()*np.sqrt(12), mkt_neutral.std()*np.sqrt(12)))Annualized volatility - long only: 0.1798 | hedged: 0.1163
You have just built your first market-neutral position. Its beta is statistically indistinguishable from zero and its R² against the market is essentially zero: whatever this portfolio earns, it does not come from the market going up. We will come back to this portfolio at the end of the workshop.
Estimate the CAPM regression for JPM and for XOM. Interpret, in your own words and in full sentences: b_0, b_1, their standard errors, their t-statistics, their p-values and their 95% confidence intervals.
For JPM, test the hypothesis that its true beta is equal to 1 (not equal to zero). Build the test statistic t=\frac{b_1-1}{SE(b_1)} and conclude. Why is this a more interesting hypothesis than \beta=0?
Split the sample in two halves and estimate beta in each half for all 8 instruments. Are betas stable over time? What does your answer imply for anyone hedging with a beta estimated on past data?
Build a portfolio with a target beta of exactly 0.5 using TSLA and the risk-free asset only. What weights do you need? Verify by regression.
Using the risk decomposition, answer: if the CAPM is right and only systematic risk is paid, why does anybody hold a single stock instead of the market portfolio?
We now have returns and risk. The remaining question is the one that pays our salaries: was this manager good?
The HPR is the total return of an investment over the whole holding period:
HPR=\frac{P_{end}-P_{begin}+CashFlows}{P_{begin}}=\frac{P_{end}}{P_{begin}}-1
(using adjusted prices, the dividends are already inside P).
With cc returns it is even easier, since they add up:
HPR=e^{\sum_{t=1}^{N}r_t}-1
hpr_prices = prices.iloc[-1]/prices.iloc[0] - 1
hpr_cc = np.exp(R.sum()) - 1
pd.DataFrame({"HPR from prices": hpr_prices,
"HPR from cc returns": hpr_cc}).sort_values("HPR from prices", ascending=False) HPR from prices HPR from cc returns
TSLA 34.3160 34.3160
AAPL 8.1709 8.1709
JPM 4.6932 4.6932
XOM 3.1584 3.1584
WMT 2.8097 2.8097
SPY 2.7676 2.7676
GLD 0.8803 0.8803
HYG 0.8111 0.8111
The HPR answers “how much did I make?” but not “was it worth it?” and not “per year?”. For that we need averages and risk adjustment.
\text{Arithmetic average}=\frac{1}{N}\sum_{t=1}^{N}R_t \qquad\qquad \text{Geometric average}=\left[\prod_{t=1}^{N}(1+R_t)\right]^{1/N}-1
The arithmetic average is the best estimate of the return of a single, future, randomly chosen period. The geometric average is the constant rate that reproduces the actual accumulated wealth.
The geometric average is always lower than the arithmetic one (unless all returns are identical), and the gap grows with volatility — approximately:
R_{geom}\approx R_{arith}-\frac{\sigma^2}{2}
This is the mathematical reason why volatility destroys compounded wealth: +50% followed by −50% is not zero, it is −25%.
arith = Rs.mean()*12
geom = np.exp(12*R.mean()) - 1
comp = pd.DataFrame({"Arithmetic avg (annual)": arith,
"Geometric avg (annual)": geom})
comp["Gap"] = comp.iloc[:,0] - comp.iloc[:,1]
comp["Half variance"] = (R.std()**2 * 12)/2
comp.sort_values("Gap", ascending=False) Arithmetic avg (annual) Geometric avg (annual) Gap Half variance
TSLA 0.4958 0.3603 0.1355 0.1763
XOM 0.1536 0.1309 0.0227 0.0302
AAPL 0.2311 0.2108 0.0203 0.0377
JPM 0.1782 0.1620 0.0161 0.0270
GLD 0.0644 0.0560 0.0084 0.0097
WMT 0.1302 0.1224 0.0078 0.0143
SPY 0.1270 0.1213 0.0057 0.0120
HYG 0.0549 0.0526 0.0023 0.0035
Look at TSLA: the gap between the two averages is more than 13 percentage points per year. That is the cost of volatility, and it is invisible if you only look at the arithmetic average. (The \sigma^2/2 rule is a first-order approximation and, as the table shows, it becomes rough exactly for the most volatile assets — but the direction and the order of magnitude are right.)
Whenever a fund advertises its “average monthly return”, the first question is: which average?
This distinction matters enormously for hedge funds, because hedge fund investors move money in and out.
The time-weighted return (TWR) is the geometric average of the period returns of the fund. It is not affected by cash flows, so it measures the manager’s performance.
The money-weighted return (MWR), also called the dollar-weighted return, is the Internal Rate of Return (IRR) of the actual cash flows. It answers: what return did the investors as a group actually earn? It is affected by the timing and size of the flows.
0=\sum_{t=0}^{T}\frac{CF_t}{(1+IRR)^t}
Let’s use the fund file. Alpha Partners LP had four quarters:
flows = pd.read_csv("hf_fund_flows.csv")
flows["QuarterReturn"] = flows["EndingNAV"]/(flows["BeginningNAV"] + flows["NetFlow"]) - 1
flows Quarter QuarterEnd BeginningNAV NetFlow EndingNAV QuarterReturn
0 2025Q3 2025-09-30 100.0000 0.0000 110.0000 0.1000
1 2025Q4 2025-12-31 110.0000 90.0000 180.0000 -0.1000
2 2026Q1 2026-03-31 180.0000 20.0000 224.0000 0.1200
3 2026Q2 2026-06-30 224.0000 -24.0000 216.0000 0.0800
The fund returned +10%, −10%, +12% and +8% in the four quarters. Note when the money arrived: a large 90 million inflow right before the bad quarter.
# ---- Time-weighted return: geometric average of the quarterly returns
q = flows["QuarterReturn"].values
twr_q = np.prod(1 + q)**(1/len(q)) - 1
twr_a = (1 + twr_q)**4 - 1
# ---- Money-weighted return: IRR of the investors' cash flows
# t=0 : initial capital + flow at the start of Q1 (money going IN = negative)
# t=1..3 : the flows at the start of each quarter
# t=4 : the final NAV is returned to the investors (positive)
cf = [-(flows["BeginningNAV"].iloc[0] + flows["NetFlow"].iloc[0])]
cf += [-flows["NetFlow"].iloc[i] for i in range(1, len(flows))]
cf += [flows["EndingNAV"].iloc[-1]]
npv = lambda r: sum(c/(1+r)**i for i, c in enumerate(cf))
mwr_q = optimize.brentq(npv, -0.9, 1.0)
mwr_a = (1 + mwr_q)**4 - 1
print("Cash flows of the investors:", np.round(cf, 2))Cash flows of the investors: [-100. -90. -20. 24. 216.]
print("\nTime-weighted return : %.4f per quarter -> %.4f annual" % (twr_q, twr_a))
Time-weighted return : 0.0461 per quarter -> 0.1975 annual
print("Money-weighted return : %.4f per quarter -> %.4f annual" % (mwr_q, mwr_a))Money-weighted return : 0.0415 per quarter -> 0.1764 annual
The two numbers are different, and the difference is not an error: it is information.
This gap has a name in the industry: the behavior gap or return gap. It is systematically negative for volatile funds, because investors chase performance — they buy after good quarters and redeem after bad ones.
To evaluate the manager: TWR — the manager does not control when investors send money. This is why GIPS (Global Investment Performance Standards) requires time-weighted returns for reporting.
To evaluate the investor experience, or a manager who does control the flows (private equity, capital-call funds, a fund that decides when to draw and return capital): MWR/IRR. This is why private equity reports IRR and hedge funds report TWR.
Raw returns are meaningless without risk. There are four classic ways to divide return by risk, and they differ in which risk goes in the denominator. This is the whole point: the ranking of funds changes depending on which measure you choose, so you have to know what each one assumes.
T_P=\frac{E[R_P]-R_f}{\beta_P}
Premium return per unit of systematic risk.
Use it when the portfolio is one piece of a well-diversified overall portfolio, because then the idiosyncratic risk of this particular sleeve will be diversified away by the rest, and only its beta matters.
Note the trap: if \beta_P\approx0 — exactly the case of a market-neutral hedge fund — the Treynor ratio explodes. It is not an appropriate measure for market-neutral strategies.
\alpha_P=E[R_P]-\left[R_f+\beta_P\left(E[R_M]-R_f\right)\right]
The return the manager delivered above what the CAPM said they should deliver given the risk they took. It is the intercept b_0 of the CAPM regression, so we already know how to compute it — and, crucially, we know how to test whether it is statistically different from zero.
Jensen’s alpha is a number, not a ratio: it is in return units, so it cannot be compared across funds of different sizes of risk without scaling. That is what the next measure does.
IR_P=\frac{\alpha_P}{\sigma(\varepsilon_P)} \qquad \text{or, versus a benchmark:} \qquad IR_P=\frac{E[R_P-R_B]}{\sigma(R_P-R_B)}
Alpha per unit of the risk taken to obtain it. The denominator \sigma(R_P-R_B) is the tracking error: the volatility of the return difference against the benchmark.
The IR is the natural measure for active management: it tells you how much active return you get for each unit of active risk. As a rule of thumb in the industry, an IR above 0.5 is good and above 1.0 is exceptional (and usually too good to be true).
M^2_P=\left(S_P-S_M\right)\sigma_M
M² takes the portfolio, mixes it with the risk-free asset until its volatility exactly equals the market’s volatility, and then reports how much more (or less) it would have returned than the market. Its advantage is that the answer comes back in percentage points, which is much easier to explain to a client than “a Sharpe ratio of 0.83”.
def performance(ret_series, name, benchmark=R["SPY"], rf=rf_m, mkt=mkt_prem):
"""Full risk-adjusted performance report for a monthly cc return series."""
ex = ret_series - rf # premium returns
d = pd.DataFrame({"y": ex.values, "x": mkt.values}, index=mkt.index)
m = smf.ols("y ~ x", data=d).fit()
alpha_m, beta = m.params.iloc[0], m.params.iloc[1]
active = ret_series - benchmark
sharpe = ex.mean()/ex.std()*np.sqrt(12)
sharpe_m = (benchmark - rf).mean()/(benchmark - rf).std()*np.sqrt(12)
sd_mkt_a = (benchmark - rf).std()*np.sqrt(12)
return pd.Series({
"Annual return": np.exp(12*ret_series.mean()) - 1,
"Annual volatility": ret_series.std()*np.sqrt(12),
"Beta": beta,
"Sharpe": sharpe,
"Treynor": ex.mean()*12/beta,
"Jensen alpha (ann)": alpha_m*12,
"Alpha p-value": m.pvalues.iloc[0],
"Info ratio": np.nan if active.std()==0 else active.mean()/active.std()*np.sqrt(12),
"Tracking error": active.std()*np.sqrt(12),
"M2": (sharpe - sharpe_m)*sd_mkt_a}, name=name)
w_tan_series = R[ASSETS] @ w_tan_lo # the long-only tangency portfolio
candidates = {
"SPY (market)": R["SPY"],
"Equally weighted": R[ASSETS].mean(axis=1),
"Tangency (LO)": w_tan_series,
"GMV": R[ASSETS] @ w_gmv,
"TSLA": R["TSLA"],
"GLD": R["GLD"],
"Market-neutral": mkt_neutral}
perf = pd.DataFrame([performance(v, k) for k, v in candidates.items()])
perf.round(4) Annual return Annual volatility ... Tracking error M2
SPY (market) 0.1213 0.1550 ... 0.0000 0.0000
Equally weighted 0.1700 0.1835 ... 0.1119 0.0216
Tangency (LO) 0.1650 0.1657 ... 0.0966 0.0299
GMV 0.0893 0.1066 ... 0.1384 0.0008
TSLA 0.3603 0.5938 ... 0.5337 -0.0186
GLD 0.0560 0.1394 ... 0.2006 -0.0560
Market-neutral 0.0732 0.1163 ... 0.1941 -0.0272
[7 rows x 10 columns]
Now compare the rankings. This table is the whole point of the section:
rank = pd.DataFrame({
"by Annual return": perf["Annual return"].rank(ascending=False),
"by Sharpe": perf["Sharpe"].rank(ascending=False),
"by Treynor": perf["Treynor"].rank(ascending=False),
"by Jensen alpha": perf["Jensen alpha (ann)"].rank(ascending=False),
"by Info ratio": perf["Info ratio"].rank(ascending=False)}).astype(float)
rank by Annual return by Sharpe ... by Jensen alpha by Info ratio
SPY (market) 4.0000 4.0000 ... 7.0000 NaN
Equally weighted 2.0000 2.0000 ... 4.0000 2.0000
Tangency (LO) 3.0000 1.0000 ... 2.0000 1.0000
GMV 5.0000 3.0000 ... 5.0000 4.0000
TSLA 1.0000 5.0000 ... 1.0000 3.0000
GLD 7.0000 7.0000 ... 6.0000 6.0000
Market-neutral 6.0000 6.0000 ... 3.0000 5.0000
[7 rows x 5 columns]
Things to notice, and to remember for the rest of the module:
The rankings disagree. A fund’s position in a league table depends on which measure the league table uses. When a hedge fund puts a ratio in its marketing material, the first question is always: why that one?
GLD has the worst Sharpe ratio of the group but one of the best Treynor ratios, and the market-neutral portfolio has an absurd Treynor ratio — because its beta is almost zero and the denominator collapses. This is the failure mode of Treynor for low-beta strategies, and it is not a small detail: a manager who wanted to look good could simply hedge their beta and quote their Treynor ratio.
The market-neutral portfolio has a modest Sharpe ratio, a positive Jensen alpha and, above all, a beta of zero. Whether that is “good” depends entirely on what else the investor owns — and that is exactly the argument the hedge fund industry makes for itself.
Now look at the alpha p-values: not one of them is below 0.05. Every “alpha” in this table is statistically indistinguishable from zero over 139 months. This column is the sanity check that almost never appears in a fund’s marketing material, and you should ask for it every time.
Finally, a plot that shows what Sharpe ratios mean geometrically:
plt.figure(figsize=(10, 6))<Figure size 1000x600 with 0 Axes>
for k, v in candidates.items():
sd, r = v.std()*np.sqrt(12), np.exp(12*v.mean())-1
plt.scatter(sd, r, s=90)
plt.annotate(k, (sd, r), textcoords="offset points", xytext=(8, 4))<matplotlib.collections.PathCollection object at 0x0000028B54851E50>
Text(8, 4, 'SPY (market)')
<matplotlib.collections.PathCollection object at 0x0000028B548520D0>
Text(8, 4, 'Equally weighted')
<matplotlib.collections.PathCollection object at 0x0000028B54852350>
Text(8, 4, 'Tangency (LO)')
<matplotlib.collections.PathCollection object at 0x0000028B548525D0>
Text(8, 4, 'GMV')
<matplotlib.collections.PathCollection object at 0x0000028B54852850>
Text(8, 4, 'TSLA')
<matplotlib.collections.PathCollection object at 0x0000028B54852AD0>
Text(8, 4, 'GLD')
<matplotlib.collections.PathCollection object at 0x0000028B54852D50>
Text(8, 4, 'Market-neutral')
xg = np.linspace(0, 0.65, 50)
sm_ = perf.loc["SPY (market)", "Sharpe"]
plt.plot(xg, RF + sm_*xg, "r--", label=f"Line through the market (Sharpe={sm_:.2f})")[<matplotlib.lines.Line2D object at 0x0000028B54874440>]
plt.scatter(0, RF, color="black", s=70)<matplotlib.collections.PathCollection object at 0x0000028B54852FD0>
plt.xlabel("Annual volatility"); plt.ylabel("Annual return")Text(0.5, 0, 'Annual volatility')
Text(0, 0.5, 'Annual return')
plt.title("Anything above the red line beat the market on a risk-adjusted basis")Text(0.5, 1.0, 'Anything above the red line beat the market on a risk-adjusted basis')
plt.legend(); plt.grid(alpha=0.3)<matplotlib.legend.Legend object at 0x0000028B548741A0>
plt.show()Compute all the performance measures for a portfolio of 70% SPY and 30% GLD, rebalanced monthly. Does adding gold help or hurt? Answer with at least three of the measures and explain why they may disagree.
A hedge fund reports an annual return of 12% with an annual volatility of 6% while the market returned 11% with 16% volatility, and the risk-free rate is 3.5%. Compute the Sharpe ratio of both and the fund’s M². Explain to a client, in one paragraph and without formulas, what the M² number means.
Using the fund flow file, change the 90 million inflow so that it arrives one quarter later (at the start of Q1-2026 instead of Q4-2025), keeping all quarterly returns the same. Recompute the TWR and the MWR. Which one changed and why? What does this tell you about which measure a manager would rather report?
Explain, in your own words, why the Treynor ratio is a bad measure for a market-neutral hedge fund and which measure you would use instead.
Volatility treats a +8% month and a −8% month as equally “risky”. Investors do not. Downside risk measures look only at the left tail, and they are the standard language of risk management in hedge funds — partly because hedge funds use leverage, and leverage kills through the left tail.
VaR is the loss that will not be exceeded with a given probability, over a given horizon.
Every VaR statement has three ingredients, and it is meaningless without all three: (1) a horizon (one month), (2) a confidence level (95%), and (3) a currency or percentage amount.
“The monthly 95% VaR of this portfolio is 6.8%” means: in 95 out of 100 months we expect to lose less than 6.8% (or gain); in about 1 month out of 20, we expect to lose more than 6.8%.
Formally, VaR at confidence level c is the negative of the (1-c) quantile of the return distribution:
VaR_c=-\,Quantile_{1-c}(R_P)
Just sort the historical returns and read off the percentile. No distributional assumption at all.
port = R[ASSETS] @ w_tan_lo # our long-only tangency portfolio
def var_historical(r, c=0.95):
return -np.percentile(r, (1-c)*100)
for c in [0.90, 0.95, 0.99]:
print("Historical monthly VaR at %.0f%%: %.4f" % (c*100, var_historical(port, c)))Historical monthly VaR at 90%: 0.0485
Historical monthly VaR at 95%: 0.0725
Historical monthly VaR at 99%: 0.0922
Pros: no assumption about the shape of the distribution; captures the fat tails that actually happened. Cons: it can only tell you about events that occurred in your sample. If the sample contains no crisis, your VaR will say a crisis is impossible.
Assume returns are normally distributed with mean \mu and standard deviation \sigma. Then:
VaR_c=-\left(\mu+z_{1-c}\,\sigma\right)
where z_{1-c} is the quantile of the standard normal (e.g. z_{0.05}=-1.645, z_{0.01}=-2.326).
def var_parametric(r, c=0.95):
return -(r.mean() + stats.norm.ppf(1-c)*r.std())
comp_var = pd.DataFrame({
"Historical VaR": [var_historical(port, c) for c in [0.90, 0.95, 0.99]],
"Parametric VaR": [var_parametric(port, c) for c in [0.90, 0.95, 0.99]]},
index=["90%", "95%", "99%"])
comp_var Historical VaR Parametric VaR
90% 0.0485 0.0486
95% 0.0725 0.0659
99% 0.0922 0.0985
Compare the two columns. They are close at 90% and they separate as we move into the tail — which is exactly where the assumption starts to matter. Two warnings before you trust either number:
The parametric VaR is only as good as the normality assumption. Real returns have more extreme observations than a normal distribution predicts, so the parametric VaR tends to underestimate deep-tail losses.
The historical VaR at 99% with 139 monthly observations is computed from barely one or two observations. It is an extremely noisy estimate. Whether it comes out above or below the parametric one in a particular sample is largely luck.
Neither problem disappears by choosing the other method; you have to know both and report both.
plt.figure(figsize=(10, 6))<Figure size 1000x600 with 0 Axes>
plt.hist(port, bins=30, density=True, alpha=0.6, edgecolor="white", label="Historical returns")(array([ 0.7265, 0. , 0. , 0.7265, 2.9059, 0.7265, 1.453 ,
2.9059, 2.9059, 4.3589, 2.1794, 5.0853, 13.803 , 7.2648,
7.9912, 9.4442, 6.5383, 5.8118, 9.4442, 6.5383, 2.1794,
2.9059, 1.453 , 2.1794, 0.7265, 0. , 0. , 0. ,
0. , 0.7265]), array([-0.1318, -0.1219, -0.112 , -0.1021, -0.0922, -0.0823, -0.0724,
-0.0625, -0.0525, -0.0426, -0.0327, -0.0228, -0.0129, -0.003 ,
0.0069, 0.0168, 0.0267, 0.0366, 0.0465, 0.0564, 0.0663,
0.0762, 0.0861, 0.096 , 0.1059, 0.1158, 0.1257, 0.1356,
0.1455, 0.1554, 0.1653]), <BarContainer object of 30 artists>)
xs = np.linspace(port.min()*1.2, port.max()*1.2, 300)
plt.plot(xs, stats.norm.pdf(xs, port.mean(), port.std()), "k-", lw=2, label="Normal distribution")[<matplotlib.lines.Line2D object at 0x0000028B54911D30>]
plt.axvline(-var_historical(port, 0.95), color="orange", ls="--", lw=2, label="95% Historical VaR")<matplotlib.lines.Line2D object at 0x0000028B54911A90>
plt.axvline(-var_parametric(port, 0.95), color="red", ls=":", lw=2, label="95% Parametric VaR")<matplotlib.lines.Line2D object at 0x0000028B54911BE0>
plt.xlabel("Monthly return"); plt.ylabel("Density")Text(0.5, 0, 'Monthly return')
Text(0, 0.5, 'Density')
plt.title("Where does VaR sit in the distribution?")Text(0.5, 1.0, 'Where does VaR sit in the distribution?')
plt.legend(); plt.grid(alpha=0.3)<matplotlib.legend.Legend object at 0x0000028B54911E80>
plt.show()We can also check whether the normality assumption is reasonable:
def normality_report(r, name):
jb = stats.jarque_bera(r)
return pd.Series({"Skewness": stats.skew(r),
"Excess kurtosis": stats.kurtosis(r),
"Jarque-Bera stat": jb[0],
"JB p-value": jb[1],
"Worst month": r.min(),
"Worst month / SD": r.min()/r.std()}, name=name)
pd.DataFrame([normality_report(port, "Tangency portfolio"),
normality_report(R["SPY"], "SPY (the market)"),
normality_report(R["WMT"], "WMT"),
normality_report(R["TSLA"], "TSLA")]).round(4) Skewness Excess kurtosis ... Worst month Worst month / SD
Tangency portfolio -0.1647 0.4051 ... -0.1318 -2.7556
SPY (the market) -0.5119 1.3009 ... -0.1283 -2.8679
WMT -0.6457 0.7083 ... -0.1546 -3.1681
TSLA 0.2766 0.8558 ... -0.4285 -2.5000
[4 rows x 6 columns]
(For a normal distribution, skewness and excess kurtosis are 0, and the Jarque-Bera test does not reject.)
Look at the market: negative skewness plus positive excess kurtosis, and the Jarque-Bera test rejects normality. This is the classic signature of financial returns — frequent small gains and rare large losses — and it is also the payoff profile of many hedge fund strategies (selling volatility, merger arbitrage, carry trades), which is why judging them by volatility alone is dangerous.
Now look at the diversified tangency portfolio: its tail is noticeably less extreme, and the normality test no longer rejects. Diversifying across weakly correlated assets averages away part of the idiosyncratic tail. Be careful with the lesson, though: it does not remove the common crash risk, and correlations themselves tend to jump towards 1 precisely in the months when everything falls. Diversification thins the tail; it does not delete it.
VaR has a serious limitation: it tells you the threshold, but not how bad things are beyond it. A 95% VaR of 7% is compatible with a worst case of −8% and with a worst case of −80%.
Expected Shortfall (ES), also called Conditional VaR (CVaR) or expected tail loss, fixes this. It is the average loss in the cases where the loss exceeded the VaR:
ES_c=-E\left[R_P \mid R_P\le Quantile_{1-c}(R_P)\right]
def expected_shortfall(r, c=0.95):
cutoff = np.percentile(r, (1-c)*100)
return -r[r <= cutoff].mean()
tail = pd.DataFrame({
"VaR (historical)": [var_historical(port, c) for c in [0.90, 0.95, 0.99]],
"Expected Shortfall": [expected_shortfall(port, c) for c in [0.90, 0.95, 0.99]]},
index=["90%", "95%", "99%"])
tail["ES / VaR"] = tail["Expected Shortfall"]/tail["VaR (historical)"]
tail VaR (historical) Expected Shortfall ES / VaR
90% 0.0485 0.0769 1.5856
95% 0.0725 0.0934 1.2885
99% 0.0922 0.1124 1.2183
ES is always larger than VaR (it is an average of things worse than the VaR). Beyond that, ES has a technical property that VaR lacks: subadditivity. The ES of a combined portfolio can never exceed the sum of the ES of its parts, i.e. ES always rewards diversification. VaR does not guarantee this — you can build examples where merging two portfolios increases VaR. This is why Basel III moved bank capital requirements from VaR to Expected Shortfall.
risk_tbl = pd.DataFrame({
"Volatility (monthly)": R.std(),
"95% VaR": R.apply(lambda c: var_historical(c, 0.95)),
"95% ES": R.apply(lambda c: expected_shortfall(c, 0.95)),
"Worst month": R.min(),
"Skewness": R.apply(stats.skew)}).sort_values("95% ES", ascending=False)
risk_tbl Volatility (monthly) 95% VaR 95% ES Worst month Skewness
TSLA 0.1714 0.2195 0.3212 -0.4285 0.2766
XOM 0.0710 0.0912 0.1553 -0.2196 -0.5442
AAPL 0.0792 0.1063 0.1431 -0.1779 0.2166
JPM 0.0671 0.1127 0.1310 -0.1553 -0.2399
WMT 0.0488 0.0732 0.1120 -0.1546 -0.6457
SPY 0.0447 0.0643 0.1028 -0.1283 -0.5119
GLD 0.0402 0.0541 0.0729 -0.1068 0.3701
HYG 0.0242 0.0343 0.0455 -0.0573 -0.0793
To convert a VaR from one horizon to another, we use the same square-root-of-time rule:
VaR_{T}\approx VaR_{1}\cdot\sqrt{T}
v95 = var_historical(port, 0.95)
print("1-month 95%% VaR : %.4f" % v95)1-month 95% VaR : 0.0725
print("3-month 95%% VaR : %.4f (approximation)" % (v95*np.sqrt(3)))3-month 95% VaR : 0.1255 (approximation)
print("1-year 95%% VaR: %.4f (approximation)" % (v95*np.sqrt(12)))1-year 95% VaR: 0.2511 (approximation)
And remember the assumptions this rule needs: no autocorrelation, no volatility clustering, constant portfolio composition. All three are violated in practice, and all three are violated more by hedge funds than by mutual funds (they trade actively, they hold illiquid assets, and their volatility is regime-dependent). Treat the scaled numbers as an order of magnitude, not a measurement.
A mutual fund that loses 20% has a bad year. A hedge fund that loses 20% may face investor redemptions, margin calls from its prime broker, and a high-water mark that means it earns no incentive fee until it recovers. Tail risk is not just unpleasant for a hedge fund; it is existential. This is why we will spend time on drawdowns, leverage and liquidity later in the module.
Compute the 95% and 99% historical VaR and Expected Shortfall for each of the 8 instruments, at a monthly horizon. Rank them by ES and compare that ranking with the ranking by volatility. Are they the same? Where do they differ most, and why?
Compute the parametric VaR assuming a Student’s t distribution with the degrees of freedom estimated from the data (stats.t.fit), and compare with the normal and the historical VaR at 99%. Which one would you report to a risk committee?
(Optional) Compute the maximum drawdown of the tangency portfolio and of SPY. (Drawdown at time t = current value divided by the running maximum, minus 1.) Plot the drawdown series. Why is maximum drawdown arguably a more relevant measure than VaR for a hedge fund investor with a lock-up period?
A fund with 500 million under management reports a monthly 99% VaR of 4%. Translate this into dollars and explain what it does not tell you.
The CAPM is a one-factor model: the only source of systematic risk is the market. It was a monumental achievement, and it is also empirically wrong in a very specific and useful way.
Researchers found groups of stocks that systematically earned returns the CAPM could not explain — persistent positive alphas over decades. These are called anomalies. If an anomaly persists, the honest interpretation is not “free money”; it is “there is another source of systematic risk that we forgot to include”.
In 1993, Eugene Fama and Kenneth French proposed adding two of them:
R_{i,t}-R_{f,t}=\alpha_i+\beta_{M}(R_{M,t}-R_{f,t})+\beta_{SMB}SMB_t+\beta_{HML}HML_t+\varepsilon_{i,t}
Market factor (Mkt-RF): the market premium return, as in the CAPM.
Size factor (SMB, “Small Minus Big”): small-capitalization firms have historically earned more than large ones. The factor is constructed as the return of a portfolio long small firms and short big firms. A positive \beta_{SMB} means the asset behaves like a small-cap stock.
Value factor (HML, “High Minus Low”): firms with a high book-to-market ratio (“value” firms) have historically earned more than firms with a low one (“growth” firms). The factor is a portfolio long high-BtM firms and short low-BtM firms. A positive \beta_{HML} means the asset behaves like a value stock; a negative one, like a growth stock.
BtM_t=\frac{BookValue_t}{MarketValue_t}, \qquad BookValue_t=TotalAssets_t-TotalLiabilities_t
MarketValue_t=(StockPrice_t)(SharesOutstanding_t)
Notice how the factors are built: each one is a long/short portfolio, financed to cost zero. That is not a coincidence, and it is a very useful thing to notice in a hedge funds course. The academic “factors” are hedge fund strategies — this is exactly why so much of what hedge funds sell as alpha turns out, on inspection, to be a factor exposure that anyone can buy cheaply.
In 2015 Fama and French added two more factors, giving the 5-factor model:
A sixth factor is often added, momentum (WML/UMD): past winners keep winning over horizons of 3-12 months.
The factors are computed and published by Kenneth French: https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html
print(ff.describe().T[["mean", "std", "min", "max"]]) mean std min max
Mkt_RF 0.0078 0.0450 -0.1333 0.1381
SMB 0.0006 0.0280 -0.0905 0.0626
HML 0.0012 0.0300 -0.1032 0.0814
RMW 0.0026 0.0200 -0.0604 0.0659
CMA 0.0016 0.0180 -0.0454 0.0500
RF 0.0017 0.0015 0.0000 0.0044
print("\nAnnualized mean of each factor:")
Annualized mean of each factor:
print((ff[["Mkt_RF","SMB","HML","RMW","CMA"]].mean()*12).round(4))Mkt_RF 0.0936
SMB 0.0072
HML 0.0144
RMW 0.0312
CMA 0.0192
dtype: float64
plot_time_series((1 + ff[["Mkt_RF","SMB","HML","RMW","CMA"]]).cumprod(),
"Cumulative value of $1 invested in each factor",
"Cumulative value", "Factor")plt.figure(figsize=(7, 5))<Figure size 700x500 with 0 Axes>
sb.heatmap(ff[["Mkt_RF","SMB","HML","RMW","CMA"]].corr(), annot=True, fmt=".2f",
cmap="coolwarm", center=0)<Axes: >
plt.title("Correlation between the Fama-French factors")Text(0.5, 1.0, 'Correlation between the Fama-French factors')
plt.show()The factors are designed to be weakly correlated with each other. That is what makes them useful as explanatory variables: each one adds information the others do not have.
Going from the CAPM to the 3-factor model means going from a simple regression to a multiple regression. Everything you know about interpreting coefficients carries over, with one addition: each coefficient is now the effect of that factor holding the other factors constant.
def factor_model(ret_series, factors=("Mkt_RF",), rf=rf_m, data=ff):
"""OLS of the premium return of a series on a set of factors."""
d = pd.DataFrame({"y": ret_series - rf}) # premium return
d = d.join(data[list(factors)], how="inner").dropna()
formula = "y ~ " + " + ".join(factors)
return smf.ols(formula, data=d).fit()
capm_x = factor_model(R["XOM"])
ff3_x = factor_model(R["XOM"], ("Mkt_RF", "SMB", "HML"))
ff5_x = factor_model(R["XOM"], ("Mkt_RF", "SMB", "HML", "RMW", "CMA"))
print(ff3_x.summary()) OLS Regression Results
==============================================================================
Dep. Variable: y R-squared: 0.400
Model: OLS Adj. R-squared: 0.387
Method: Least Squares F-statistic: 30.01
Date: Tue, 11 Aug 2026 Prob (F-statistic): 6.24e-15
Time: 09:49:07 Log-Likelihood: 206.43
No. Observations: 139 AIC: -404.9
Df Residuals: 135 BIC: -393.1
Df Model: 3
Covariance Type: nonrobust
==============================================================================
coef std err t P>|t| [0.025 0.975]
------------------------------------------------------------------------------
Intercept 0.0002 0.005 0.042 0.966 -0.009 0.010
Mkt_RF 0.9500 0.110 8.617 0.000 0.732 1.168
SMB 0.1000 0.175 0.571 0.569 -0.246 0.446
HML 0.7000 0.162 4.317 0.000 0.379 1.021
==============================================================================
Omnibus: 45.344 Durbin-Watson: 1.712
Prob(Omnibus): 0.000 Jarque-Bera (JB): 141.994
Skew: -1.207 Prob(JB): 1.47e-31
Kurtosis: 7.324 Cond. No. 39.5
==============================================================================
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
Now the important comparison — what happened to alpha when we added factors?
def model_row(m, name):
p = m.params
return pd.Series({
"alpha (annual)": p.iloc[0]*12,
"alpha p-value": m.pvalues.iloc[0],
"Mkt beta": p.get("Mkt_RF", np.nan),
"SMB beta": p.get("SMB", np.nan),
"HML beta": p.get("HML", np.nan),
"RMW beta": p.get("RMW", np.nan),
"CMA beta": p.get("CMA", np.nan),
"R-squared": m.rsquared,
"Adj R-squared": m.rsquared_adj}, name=name)
pd.DataFrame([model_row(capm_x, "CAPM"),
model_row(ff3_x, "FF 3-factor"),
model_row(ff5_x, "FF 5-factor")]).round(4) alpha (annual) alpha p-value ... R-squared Adj R-squared
CAPM 0.0198 0.7463 ... 0.3108 0.3057
FF 3-factor 0.0024 0.9664 ... 0.4001 0.3868
FF 5-factor -0.0007 0.9904 ... 0.4013 0.3788
[3 rows x 9 columns]
Read the first column downwards. As we add factors, the R² rises (the model explains more of the stock’s movements) and the alpha shrinks — part of what the CAPM was calling “abnormal return” was really compensation for the value tilt of an energy company. That is the whole idea: alpha is whatever your model cannot explain, so alpha depends on your model.
Let’s do it for every instrument:
rows = []
for t in R.columns:
m1 = factor_model(R[t])
m3 = factor_model(R[t], ("Mkt_RF", "SMB", "HML"))
rows.append(pd.Series({
"CAPM alpha (ann)": m1.params.iloc[0]*12,
"CAPM alpha p": m1.pvalues.iloc[0],
"CAPM R2": m1.rsquared,
"FF3 alpha (ann)": m3.params.iloc[0]*12,
"FF3 alpha p": m3.pvalues.iloc[0],
"FF3 R2": m3.rsquared,
"SMB beta": m3.params["SMB"],
"HML beta": m3.params["HML"]}, name=t))
ff_table = pd.DataFrame(rows)
ff_table.round(4) CAPM alpha (ann) CAPM alpha p CAPM R2 ... FF3 R2 SMB beta HML beta
AAPL 0.0556 0.3486 0.4837 ... 0.5198 -0.2000 -0.4500
GLD 0.0290 0.4858 0.0030 ... 0.0059 -0.0500 -0.0500
HYG -0.0060 0.7295 0.5230 ... 0.5627 0.0500 0.1500
JPM 0.0318 0.5226 0.4885 ... 0.5490 0.0500 0.5500
SPY 0.0011 0.8553 0.9823 ... 0.9823 0.0061 -0.0044
TSLA 0.1059 0.4908 0.2574 ... 0.2851 0.5500 -0.9000
WMT 0.0439 0.3190 0.2487 ... 0.2609 -0.1500 -0.1000
XOM 0.0198 0.7463 0.3108 ... 0.4001 0.1000 0.7000
[8 rows x 8 columns]
Check that the signs of the factor loadings make economic sense:
JPM and XOM (banks, energy — classic value sectors) should have positive HML betas.AAPL and TSLA (growth) should have negative HML betas.GLD should load on essentially nothing. It is not an equity.If the loadings did not make sense, you would suspect your data, not the theory.
Here is the single most useful idea for the rest of this module.
Suppose a hedge fund reports 8% annual alpha against the CAPM. Before writing a check, you run the return series through a factor model and find that its alpha against a 5-factor model is 1% and not statistically significant, with a large positive loading on SMB and a large negative loading on HML.
You have just learned that this “hedge fund” is a small-cap growth portfolio with leverage. You do not need to pay 2-and-20 for that; you can buy it with two ETFs for a few basis points.
This procedure has a name in the hedge fund literature — style analysis (Sharpe, 1992) — and it is one of the topics of this module. The general principle is:
\underbrace{\text{Reported return}}_{\text{what they show you}}=\underbrace{R_f + \sum_k \beta_k F_k}_{\text{factor exposures you can buy cheaply}}+\underbrace{\alpha}_{\text{what you are actually paying for}}
And the flip side of the same equation is portable alpha: if you can isolate a genuine alpha, you can strip out its unwanted factor exposures with futures and “port” that alpha onto any benchmark you like. We will do that later.
Let’s apply style analysis to our own market-neutral portfolio:
mn_capm = factor_model(mkt_neutral)
mn_ff3 = factor_model(mkt_neutral, ("Mkt_RF", "SMB", "HML"))
mn_ff5 = factor_model(mkt_neutral, ("Mkt_RF", "SMB", "HML", "RMW", "CMA"))
pd.DataFrame([model_row(mn_capm, "Market-neutral: CAPM"),
model_row(mn_ff3, "Market-neutral: FF3"),
model_row(mn_ff5, "Market-neutral: FF5")]).round(4) alpha (annual) alpha p-value ... R-squared Adj R-squared
Market-neutral: CAPM 0.0488 0.1640 ... 0.0002 -0.0071
Market-neutral: FF3 0.0547 0.1061 ... 0.0885 0.0682
Market-neutral: FF5 0.0589 0.0894 ... 0.0915 0.0573
[3 rows x 9 columns]
Our little market-neutral portfolio does have a market beta of approximately zero — but the factor model reveals that it is not exposure-free: it carries residual tilts on the other factors. Being “market-neutral” is not the same as being “factor-neutral”. Hedge funds that claim the first are frequently exposed to the second, and that exposure shows up exactly when factors move together — in crises.
cum = pd.DataFrame({
"Long-only (AAPL/WMT)": np.exp(long_leg.cumsum()),
"Market-neutral": np.exp(mkt_neutral.cumsum()),
"SPY": np.exp(R["SPY"].cumsum())})
plot_time_series(cum, "Value of $1: long-only vs market-neutral vs the market",
"Cumulative value", "Strategy")dd = cum/cum.cummax() - 1
plot_time_series(dd, "Drawdowns", "Drawdown", "Strategy")print("Maximum drawdown:")Maximum drawdown:
print(dd.min().round(4))Long-only (AAPL/WMT) -0.2983
Market-neutral -0.1207
SPY -0.2738
dtype: float64
The market-neutral line is flatter — it gives up much of the upside. But look at the drawdown plot: it also gives up much of the downside. That trade is the product hedge funds sell. Whether it is worth the fees is the question this module will try to answer.
Estimate the CAPM, the FF 3-factor and the FF 5-factor model for AAPL and for TSLA. Build the comparison table and interpret every coefficient. What happens to alpha and to R² as you add factors?
For TSLA, test the joint hypothesis that the SMB and HML coefficients are both zero (use m.f_test("SMB = 0, HML = 0")). What do you conclude, and why is a joint test better than looking at the two p-values separately?
Take the tangency portfolio you built in Part I and run a style analysis on it with the 5-factor model. What is it really exposed to? Would you describe it as an “alpha strategy”?
Build a portfolio that is long the 3 stocks with the highest HML beta and short the 3 with the lowest, equally weighted. Estimate its factor model. Did you accidentally reconstruct HML? Compare your portfolio’s returns with the actual HML factor (correlation and a scatter plot).
In one paragraph: a fund manager tells you that their strategy “has no correlation with the market”. Which regression would you run, which numbers would you look at, and what would convince you?
You now have every tool we will need. Here is the map again, with what we will do with each piece:
| Foundation reviewed here | Where it comes back in this module |
|---|---|
| Portfolio weights, including negative ones | Long/short strategies, market-neutral positions, pure plays |
| Portfolio variance and correlation | Hedge ratios, pairs trading, statistical arbitrage |
| GMV, tangency, efficient frontier, CML | Hedge funds as an “asset class”: do they expand the frontier? |
| CAPM, beta | Directional vs non-directional strategies; beta neutrality |
| Jensen’s alpha | The product hedge funds sell; performance attribution |
| Sharpe, Treynor, Information ratio, M² | Hedge fund league tables and their biases |
| Time-weighted vs money-weighted return | Why fund returns and investor returns differ |
| VaR, Expected Shortfall, drawdowns | Leverage, margin, high-water marks, tail risk |
| Fama-French factors | Style analysis, factor replication, portable alpha |
Three ideas to carry into next week:
A hedge is a negative weight. Everything exotic in this course is Markowitz with the sign constraint removed.
Alpha is model-dependent. Change the benchmark model and the alpha changes. Anybody who quotes an alpha without saying against which model is either confused or selling something.
Volatility is not risk. For a leveraged, illiquid, negatively-skewed strategy, the volatility number is the least informative statistic you can compute.
Each week (from week 1 to week 3), depending on how much we covered, you have to submit a version of your Workshop 1 (code, your notes, and Challenges)
The .ipynb file of your Notebook. You easily can generate it from Google Colab or any other front-end.
Your notebook must contain your Notes for each Topic (in Text Chunks), replicate and run the Pyhon code of the Workshop, and for each Challenges: the code, the output, and a written interpretation in your own words. Code without interpretation gets no credit; the interpretation is the part that will be on the exam.
The file build_workshop1_data.py (posted in Canvas) regenerates the three CSV files from live sources. Run it once from the same folder as this document:
pip install yfinance pandas-datareader
python build_workshop1_data.pyIt downloads:
yfinance),TB3MS from FRED (pandas-datareader),and writes hf_prices_monthly.csv and hf_ff5_rf_monthly.csv with exactly the same column names used above, so every chunk in this workshop runs unchanged.