Solution - Open Final Exam#
FINM 36700 - 2025#
UChicago Financial Mathematics#
Mark Hendricks
Your Name#
List your name and CNetID
Name:
CNetID:
Citations#
AI#
List any AI tools used in the exam. No need to list prompts, but rather just AI models or IDE integrations.
I expect most students will have something to list here.
Other resources#
Please list any other resources aside from course materials from which you used substantially. (No need to list every Google search; just materials from which you used substantially or for specific, original content.)
I expect most students will not have anything to list here.
Instructions#
For every minute late you submit the exam, you will lose one point.
The exam is open-material, closed-communication.
If you find any question to be unclear, state your interpretation and proceed. We will only answer questions of interpretation if there is a typo, error, etc.
Answer Format#
Conceptual answers: Use at most 2 sentences (≈40 words) per conceptual prompt. Graders will only read the first 2 sentences.
Output format: When asked for tables, return a small
DataFramewith the rows/columns exactly as specified. When asked for a scalar, print it with a clear label.Plots: Plots/figures are not required and will not be graded; focus on numeric outputs and short text.
Code organization: You may create additional code cells, but keep your final answers clearly associated with each numbered sub-problem.
Numeric answers: Round all reported numbers to 6 decimal places unless noted otherwise.
NOTE: The default
pandasrounding is 6 decimal places, so if you display a DataFrame, it will likely already be rounded correctly.
Submission#
Type#
You should submit a single Jupyter notebook (
.ipynb) file containing all of your code and answers to Canvas.Note: If any other files are required to run your notebook, please include them and only them in a single
.zipfile.
Naming#
Your submitted file (ipynb or .zip) must be named in the format…
final-LASTNAME-FIRSTNAME.ipynbfinal-LASTNAME-FIRSTNAME.zip
Scoring#
Problem |
Points |
|---|---|
1 |
40 |
2 |
45 |
3 |
15 |
Total |
Numbered problems are worth 5 points, unless specified otherwise.
Data#
All data files are found at the course web-book.
https://markhendricks.github.io/finm-portfolio/
The exam uses the following data files:
quality_factor_final_dataset.csv- stock-level panel data with fundamentalsFFF.csv- Fama-French factors (daily)
The stock data contains daily observations from May 2017 to October 2024 with:
price- stock pricemarket_cap- market capitalization (in millions)debt_to_market_cap- debt divided by market capreturn_on_investment- return on investment (%)price_to_earnings- P/E ratio
The Fama-French data contains:
Mkt-RF- market excess return (daily, in percentage points)SMB- small minus big factorHML- high minus low (value) factorRF- risk-free rate
Annualization: Use 252 trading days per year for daily data.
# Load packages
import warnings
import numpy as np
import pandas as pd
import statsmodels.api as sm
warnings.filterwarnings("ignore")
pd.set_option('display.precision', 6)
ANN_FACTOR = 252 # daily data annualization
# Load data
DATA_PATH = 'quality_factor_final_dataset.csv'
FF_PATH = 'FFF.csv'
df = pd.read_csv(DATA_PATH).drop(columns=['Unnamed: 0'])
df['date'] = pd.to_datetime(df['date'])
display(df.tail())
FF = pd.read_csv(FF_PATH).drop(columns=['Unnamed: 0'])
FF['date'] = pd.to_datetime(FF['date'])
FF.set_index('date', inplace=True)
FF = FF / 100 # Convert from percentage points to decimals
display(FF.tail())
| date | ticker | price | market_cap | debt_to_market_cap | return_on_investment | price_to_earnings | |
|---|---|---|---|---|---|---|---|
| 360179 | 2024-06-24 | ZTS | 171.013575 | 78630.989405 | 1.280037 | 5.071555 | 130.544714 |
| 360180 | 2024-06-25 | ZTS | 167.172139 | 76864.720671 | 1.309451 | 5.178927 | 127.612319 |
| 360181 | 2024-06-26 | ZTS | 170.078095 | 78200.861786 | 1.287077 | 5.097290 | 129.830607 |
| 360182 | 2024-06-27 | ZTS | 175.611356 | 80745.020893 | 1.246523 | 4.948753 | 134.054470 |
| 360183 | 2024-06-28 | ZTS | 172.526265 | 79326.514916 | 1.268814 | 5.030485 | 131.699439 |
| Mkt-RF | SMB | HML | RF | |
|---|---|---|---|---|
| date | ||||
| 2024-06-24 | -0.0025 | 0.0062 | 0.0109 | 0.00022 |
| 2024-06-25 | 0.0031 | -0.0082 | -0.0122 | 0.00022 |
| 2024-06-26 | 0.0016 | -0.0001 | -0.0019 | 0.00022 |
| 2024-06-27 | 0.0014 | 0.0053 | -0.0039 | 0.00022 |
| 2024-06-28 | -0.0035 | 0.0100 | 0.0128 | 0.00022 |
1. Quality Factor Construction & Factor Pricing#
In this problem, you will construct a “quality” factor from fundamental data and test its pricing implications using factor models from chapters 4-5.
1.1 Cross-Sectional Z-Scores#
For each date \(t\), compute cross-sectional z-scores for each of the three subfactors:
debt_to_market_capreturn_on_investmentprice_to_earnings
The z-score for stock \(i\) on date \(t\) for variable \(x\) is: $\(z_{i,t}^x = \frac{x_{i,t} - \bar{x}_t}{\sigma_t^x}\)$
where \(\bar{x}_t\) and \(\sigma_t^x\) are the cross-sectional mean and standard deviation on date \(t\).
Report the z-scores for ticker AAPL for each subfactor on the last 5 dates in the sample. Round to 6 decimal places.
def cs_zscore_series(s):
mu = s.mean()
sd = s.std()
if sd == 0 or np.isclose(sd, 0):
return pd.Series(np.zeros(len(s)), index=s.index)
return (s - mu) / sd
def return_result(df, tick, cols_report):
"""Return last 5 observations for a ticker."""
return (
df.loc[df['ticker'] == tick, cols_report]
.dropna()
.sort_values('date')
.tail()
)
for col in ['debt_to_market_cap', 'return_on_investment', 'price_to_earnings']:
df[f'z_{col}'] = df.groupby('date')[col].transform(cs_zscore_series)
sol_11 = return_result(df, 'AAPL', ['date', 'ticker',
'z_debt_to_market_cap',
'z_return_on_investment',
'z_price_to_earnings'])
display(sol_11.round(6))
| date | ticker | z_debt_to_market_cap | z_return_on_investment | z_price_to_earnings | |
|---|---|---|---|---|---|
| 1795 | 2024-06-24 | AAPL | 0.018392 | 2.237696 | -0.220387 |
| 1796 | 2024-06-25 | AAPL | 0.016726 | 2.207526 | -0.220445 |
| 1797 | 2024-06-26 | AAPL | 0.010573 | 2.133680 | -0.220363 |
| 1798 | 2024-06-27 | AAPL | 0.009745 | 2.105812 | -0.220373 |
| 1799 | 2024-06-28 | AAPL | 0.011635 | 2.143678 | -0.218725 |
1.2 Quality Score Construction#
Create a composite quality score by:
Applying economic direction so that “good is high”:
debt_to_market_cap→ multiply by −1 (lower debt is better)return_on_investment→ multiply by +1 (higher ROI is better)price_to_earnings→ multiply by −1 (lower P/E is better, i.e., “value”)
Average the three signed z-scores to create
quality_raw.
Report quality_raw for ticker AAPL on the last 5 dates. Round to 6 decimal places.
# Apply economic direction
df['z_d2m_signed'] = -1.0 * df['z_debt_to_market_cap']
df['z_roi_signed'] = +1.0 * df['z_return_on_investment']
df['z_pe_signed'] = -1.0 * df['z_price_to_earnings']
# Compute quality_raw as average of signed z-scores
df['quality_raw'] = df[['z_d2m_signed', 'z_roi_signed', 'z_pe_signed']].mean(axis=1)
sol_12 = return_result(df, 'AAPL', ['date', 'ticker', 'quality_raw'])
display(sol_12.round(6))
| date | ticker | quality_raw | |
|---|---|---|---|
| 1795 | 2024-06-24 | AAPL | 0.813230 |
| 1796 | 2024-06-25 | AAPL | 0.803748 |
| 1797 | 2024-06-26 | AAPL | 0.781157 |
| 1798 | 2024-06-27 | AAPL | 0.772147 |
| 1799 | 2024-06-28 | AAPL | 0.783589 |
1.3 Size Neutralization#
Quality scores may be correlated with size. To obtain a “pure” quality signal, for each date \(t\), regress quality_raw on the log of market cap:
The residual \(\varepsilon_{i,t}\) is the size-neutral quality signal, quality_pure.
Report quality_pure for ticker AAPL on the last 5 dates. Round to 6 decimal places.
df['log_market_cap'] = np.log(df['market_cap'])
def calculate_pure_residual(g):
valid_data = g[['quality_raw', 'log_market_cap']].copy()
valid_data = valid_data.astype(float)
valid_data = valid_data.replace([np.inf, -np.inf], np.nan).dropna()
if len(valid_data) < 3:
return pd.Series(np.nan, index=g.index, name='quality_pure')
Y = valid_data['quality_raw']
X = sm.add_constant(valid_data['log_market_cap'])
model = sm.OLS(Y, X).fit()
resid = model.resid
resid = resid.reindex(g.index)
resid.name = 'quality_pure'
return resid
df['quality_pure'] = df.groupby('date', group_keys=False).apply(calculate_pure_residual)
sol_13 = return_result(df, 'AAPL', ['date', 'ticker', 'quality_pure'])
display(sol_13.round(6))
| date | ticker | quality_pure | |
|---|---|---|---|
| 1795 | 2024-06-24 | AAPL | 0.794082 |
| 1796 | 2024-06-25 | AAPL | 0.789333 |
| 1797 | 2024-06-26 | AAPL | 0.768976 |
| 1798 | 2024-06-27 | AAPL | 0.763955 |
| 1799 | 2024-06-28 | AAPL | 0.774949 |
1.4 Final Quality Score#
Re-standardize quality_pure cross-sectionally by date to obtain quality_score:
Report quality_score for ticker AAPL on the last 5 dates. Round to 6 decimal places.
df['quality_score'] = df.groupby('date')['quality_pure'].transform(cs_zscore_series)
sol_14 = return_result(df, 'AAPL', ['date', 'ticker', 'quality_score'])
display(sol_14.round(6))
| date | ticker | quality_score | |
|---|---|---|---|
| 1795 | 2024-06-24 | AAPL | 1.189091 |
| 1796 | 2024-06-25 | AAPL | 1.182050 |
| 1797 | 2024-06-26 | AAPL | 1.151226 |
| 1798 | 2024-06-27 | AAPL | 1.144098 |
| 1799 | 2024-06-28 | AAPL | 1.164499 |
1.5 (10pts) Long-Short Factor Portfolio#
Construct a long-short quality factor portfolio:
Compute daily returns for each stock from prices.
Each day, sort stocks into deciles based on
quality_pure(usingpd.qcut).Using quality_score as of date t as a signal for return from t to t+1, form portfolios:
Long: Equal-weight average return of Decile 10 (highest quality)
Short: Equal-weight average return of Decile 1 (lowest quality, “junk”)
Factor Return: Long − Short
Report the last 5 daily returns of the Long, Short, and Factor portfolios. Round to 6 decimal places.
# Sort by date, ticker to make time-first ordering
df = df.sort_values(['date', 'ticker'])
# Compute daily returns
df['ret'] = df.groupby('ticker')['price'].pct_change()
# Deciles at time t (signal) - handle NaN values
def assign_deciles(s):
valid = s.dropna()
if len(valid) < 10:
return pd.Series(np.nan, index=s.index)
ranks = valid.rank(method='first')
deciles = pd.qcut(ranks, 10, labels=False, duplicates='drop') + 1
return deciles.reindex(s.index)
df['decile'] = df.groupby('date', group_keys=False)['quality_pure'].apply(assign_deciles)
# Use decile_t as signal for ret_{t+1}: lag decile within ticker
df['decile_lag'] = df.groupby('ticker')['decile'].shift(1)
# Form factor portfolios using decile_lag
portfolio_returns = (
df.groupby(['date', 'decile_lag'])['ret']
.mean()
.unstack()
)
factor_performance = pd.DataFrame(index=portfolio_returns.index)
factor_performance['Long'] = portfolio_returns[10]
factor_performance['Short'] = portfolio_returns[1]
factor_performance['Factor_Return'] = factor_performance['Long'] - factor_performance['Short']
sol_15 = factor_performance[['Long', 'Short', 'Factor_Return']].dropna().tail()
display(sol_15.round(6))
| Long | Short | Factor_Return | |
|---|---|---|---|
| date | |||
| 2024-06-24 | 0.004575 | 0.010702 | -0.006126 |
| 2024-06-25 | -0.008056 | -0.004739 | -0.003316 |
| 2024-06-26 | -0.005586 | -0.005711 | 0.000125 |
| 2024-06-27 | -0.003355 | 0.003650 | -0.007006 |
| 2024-06-28 | -0.004201 | -0.001097 | -0.003103 |
1.6 Factor Summary Statistics and Higher Moments#
For the Factor Return (QMJ = Quality Minus Junk), report the following annualized statistics:
Mean Return
Volatility
Sharpe Ratio
Also report the skewness and excess kurtosis of the Factor Return.
factor_perf_clean = factor_performance.dropna()
merged = factor_perf_clean.merge(FF[['Mkt-RF', 'SMB', 'HML']], left_index=True, right_index=True, how='inner')
qmj_ret = merged['Factor_Return']
sol_16_stats = pd.DataFrame({
'Annualized Mean': qmj_ret.mean() * ANN_FACTOR,
'Annualized Volatility': qmj_ret.std() * np.sqrt(ANN_FACTOR),
'Annualized Sharpe Ratio': (qmj_ret.mean() / qmj_ret.std()) * np.sqrt(ANN_FACTOR),
'Skewness': qmj_ret.skew(),
'Excess Kurtosis': qmj_ret.kurtosis() # pandas kurtosis is already excess kurtosis
}, index=['Factor_Return'])
print("Quality Factor (QMJ) Summary Statistics:")
display(sol_16_stats.round(6))
Quality Factor (QMJ) Summary Statistics:
| Annualized Mean | Annualized Volatility | Annualized Sharpe Ratio | Skewness | Excess Kurtosis | |
|---|---|---|---|---|---|
| Factor_Return | 0.061064 | 0.143076 | 0.426796 | -1.512095 | 21.932634 |
1.7.#
In 1–2 sentences, comment on whether the QMJ factor appears to have fatter tails than a normal distribution, based on the higher moments.
Interpretation (1-2 sentences max):
QMJ has strong negative skew and very high positive excess kurtosis, implying that large downside shocks are more frequent and more extreme than in a normal distribution.
2. Forecasting Returns#
In this problem, you will use fundamentals-based signals to forecast returns, following the approach from Chapter 7 (GMO-style forecasting).
Use the QMJ factor return (Factor_Return) constructed in Problem 1.5 for the forecasting analysis below.
2.1 Feature Engineering#
Construct the following predictors for forecasting the quality factor return:
\(X_{\text{Vol}}\) (Fear): Rolling 21-day annualized volatility of Mkt-RF
\(X_{\text{Spread}}\) (Quality Gap): Daily spread in quality scores between Decile 10 and Decile 1: \(\bar{\text{quality\_score}}_{\text{D10},t} - \bar{\text{quality\_score}}_{\text{D1},t}\)
Report the last 5 values for both \(X_{\text{Vol}}\) and \(X_{\text{Spread}}\).
# 2.1.1: X_Vol (Fear) - Rolling 21-day annualized volatility of Mkt-RF
FF_sorted = FF.sort_index()
FF_sorted['X_Vol'] = FF_sorted['Mkt-RF'].rolling(21).std() * np.sqrt(ANN_FACTOR)
# 2.1.2: X_Spread (Quality Gap)
quality_by_decile = df.groupby(['date', 'decile'])['quality_score'].mean().unstack()
predictors = pd.DataFrame(index=quality_by_decile.index)
predictors['X_Spread'] = quality_by_decile[10] - quality_by_decile[1]
predictors['X_Vol'] = FF_sorted['X_Vol']
sol_21 = predictors[['X_Spread', 'X_Vol']].dropna().tail()
display(sol_21.round(6))
| X_Spread | X_Vol | |
|---|---|---|
| date | ||
| 2024-06-24 | 3.626793 | 0.085916 |
| 2024-06-25 | 3.626808 | 0.079196 |
| 2024-06-26 | 3.629921 | 0.076327 |
| 2024-06-27 | 3.630243 | 0.076135 |
| 2024-06-28 | 3.627203 | 0.071184 |
2.2 In-Sample Forecasting Regression#
Estimate the following forecasting regression with predictors lagged one period:
Report:
\(R^2\) of the regression
Estimated \(\alpha\) (constant)
Estimated \(\beta_1\) (X_Spread coefficient)
Estimated \(\beta_2\) (X_Vol coefficient)
reg_df = predictors.merge(factor_performance[['Factor_Return']], left_index=True, right_index=True, how='inner')
reg_df['Ret_Next'] = reg_df['Factor_Return'].shift(-1)
reg_data = reg_df[['Ret_Next', 'X_Spread', 'X_Vol']].dropna()
X = sm.add_constant(reg_data[['X_Spread', 'X_Vol']])
Y = reg_data['Ret_Next']
model_22 = sm.OLS(Y, X).fit()
sol_22 = pd.DataFrame({
'R-squared': model_22.rsquared,
'Alpha (const)': model_22.params['const'],
'Beta_1 (X_Spread)': model_22.params['X_Spread'],
'Beta_2 (X_Vol)': model_22.params['X_Vol']
}, index=['Value'])
display(sol_22.T.round(6))
| Value | |
|---|---|
| R-squared | 0.000215 |
| Alpha (const) | 0.000479 |
| Beta_1 (X_Spread) | -0.000131 |
| Beta_2 (X_Vol) | 0.001121 |
2.3 Out-of-Sample R²#
Compute out-of-sample (OOS) statistics using a rolling window approach, using the expanding mean (from time 0 to \(t\))as the benchmark/null forecast at time \(t+1\).
Start at \(t =\) 126 (minimum window of 126 days)
oos_df = reg_df[['Factor_Return', 'X_Spread', 'X_Vol']].dropna()
min_window = 126
forecasts = []
actuals = []
null_forecasts = []
for t in range(min_window, len(oos_df) - 1):
train_df = oos_df.iloc[:t+1].copy()
train_df['Ret_Next'] = train_df['Factor_Return'].shift(-1)
train_clean = train_df[['Ret_Next', 'X_Spread', 'X_Vol']].dropna()
if len(train_clean) < 20:
continue
X_train = sm.add_constant(train_clean[['X_Spread', 'X_Vol']])
Y_train = train_clean['Ret_Next']
model = sm.OLS(Y_train, X_train).fit()
X_test = pd.DataFrame({
'const': 1.0,
'X_Spread': oos_df.iloc[t]['X_Spread'],
'X_Vol': oos_df.iloc[t]['X_Vol']
}, index=[0])
pred = model.predict(X_test)[0]
actual = oos_df.iloc[t+1]['Factor_Return']
null_pred = oos_df.iloc[:t+1]['Factor_Return'].mean()
forecasts.append(pred)
actuals.append(actual)
null_forecasts.append(null_pred)
forecast_errors = np.array(actuals) - np.array(forecasts)
null_errors = np.array(actuals) - np.array(null_forecasts)
sse_forecast = np.sum(forecast_errors ** 2)
sse_null = np.sum(null_errors ** 2)
oos_r2 = 1 - (sse_forecast / sse_null)
print(f"Out-of-Sample R-squared: {oos_r2:.6f}")
Out-of-Sample R-squared: -0.006663
2.4 Trading Strategy from Forecasts#
Build a trading strategy using the OOS forecasts:
Set portfolio weight: \(w_t = 10 \times \hat{r}^{\text{QMJ}}_{t+1}\) (scaled forecast)
Strategy return: \(r^{\text{strat}}_{t+1} = w_t \times r^{\text{QMJ}}_{t+1}\)
Report the following annualized statistics for the strategy:
Mean Return
Volatility
Sharpe Ratio
Also report the same annualized statistics for a buy-and-hold position in the QMJ factor.
Round your answers to 6 decimal places.
weights = np.array(forecasts) * 10
strategy_returns = weights * np.array(actuals)
def compute_stats(returns, name):
returns = pd.Series(returns)
return pd.Series({
'Annualized Mean': returns.mean() * ANN_FACTOR,
'Annualized Volatility': returns.std() * np.sqrt(ANN_FACTOR),
'Annualized Sharpe': (returns.mean() / returns.std()) * np.sqrt(ANN_FACTOR) if returns.std() > 0 else 0
}, name=name)
strat_stats = compute_stats(strategy_returns, 'Forecast Strategy')
buyhold_stats = compute_stats(actuals, 'Buy-and-Hold QMJ')
sol_24 = pd.DataFrame([strat_stats, buyhold_stats]).T
display(sol_24.round(6))
| Forecast Strategy | Buy-and-Hold QMJ | |
|---|---|---|
| Annualized Mean | 0.000173 | 0.058070 |
| Annualized Volatility | 0.001994 | 0.146864 |
| Annualized Sharpe | 0.086893 | 0.395399 |
2.5 Strategy Attribution#
Regress the forecast strategy returns on Mkt-RF:
Report:
Alpha (annualized)
Beta
R-squared
Annualized Information Ratio
Round your answers to 6 decimal places.
oos_dates = oos_df.index[min_window+1:len(oos_df)]
mkt_oos = FF_sorted.loc[oos_dates[:len(strategy_returns)], 'Mkt-RF']
strat_series = pd.Series(strategy_returns, index=mkt_oos.index[:len(strategy_returns)])
aligned = pd.DataFrame({'strat': strat_series, 'mkt': mkt_oos}).dropna()
X = sm.add_constant(aligned['mkt'])
Y = aligned['strat']
model_25 = sm.OLS(Y, X).fit()
alpha_ann = model_25.params['const'] * ANN_FACTOR
beta = model_25.params['mkt']
r2 = model_25.rsquared
resid_std = model_25.resid.std()
info_ratio = (model_25.params['const'] / resid_std) * np.sqrt(ANN_FACTOR)
sol_25 = pd.DataFrame({
'Alpha (Annualized)': alpha_ann,
'Beta': beta,
'R-squared': r2,
'Information Ratio': info_ratio
}, index=['Value'])
display(sol_25.T.round(6))
| Value | |
|---|---|
| Alpha (Annualized) | 0.000196 |
| Beta | -0.000181 |
| R-squared | 0.000342 |
| Information Ratio | 0.098269 |
2.6. (10pts) Interpretation for Questions 2.2–2.5#
Answer the following in 1–2 sentences each (total combined length still subject to the global 2-sentence guideline per part):
Is the in-sample \(R^2\) from Question 2.2 meaningful for real-world forecasting? Why or why not?
What does the OOS \(R^2\) from Question 2.3 indicate about the predictability of quality factor returns?
In evaluating the strategy’s performance, do you care more about the OOS \(\alpha\) or the OOS \(R^2\)?
Solutions:
In-sample \(R^2\) is misleading because it overfits to the training data and doesn’t reflect true predictive ability on unseen data.
A negative OOS \(R^2\) means the model performs worse than a naive historical mean forecast—the signal.
We far and away care more about the \(\alpha\). The OOS \(R^2\) is very sensitive to the magnitude of the prediction and not just the direction (see TA Review 7 for an example). We should probably use something like information coefficient instead of only OOS \(R^2\).
2.7 Strategy Risk#
Compare the tail risk of the Forecast Strategy to the Buy-and-Hold QMJ using the OOS period data:
Compute the Historical VaR (5%) for both strategies
Compute the Maximum Drawdown for both strategies
Report all values. Round to 6 decimal places.
In 1–2 sentences, comment on whether the forecast-based strategy has better or worse tail risk than buy-and-hold.
strat_var = np.percentile(strategy_returns, 5)
buyhold_var = np.percentile(actuals, 5)
strat_wealth = (1 + pd.Series(strategy_returns)).cumprod()
strat_running_max = strat_wealth.cummax()
strat_drawdown = (strat_wealth - strat_running_max) / strat_running_max
strat_max_dd = strat_drawdown.min()
bh_wealth = (1 + pd.Series(actuals)).cumprod()
bh_running_max = bh_wealth.cummax()
bh_drawdown = (bh_wealth - bh_running_max) / bh_running_max
bh_max_dd = bh_drawdown.min()
sol_27 = pd.DataFrame({
'VaR (5%)': [strat_var, buyhold_var],
'Max Drawdown': [strat_max_dd, bh_max_dd]
}, index=['Forecast Strategy', 'Buy-and-Hold QMJ'])
display(sol_27.round(6))
| VaR (5%) | Max Drawdown | |
|---|---|---|
| Forecast Strategy | -0.000062 | -0.005704 |
| Buy-and-Hold QMJ | -0.014965 | -0.295054 |
2.8#
In 1–2 sentences, comment on whether the forecast-based strategy has better or worse tail risk than buy-and-hold.
Interpretation (1-2 sentences): In this sample, the forecast‑based strategy has much better tail risk than buy‑and‑hold: both its 5% VaR and max drawdown are far less severe in magnitude.
3. Dynamic Hedging & Portable Alpha#
In this problem, you will construct a dynamically hedged quality factor and build a “portable alpha” product, following concepts from Chapters 8-9 (LTCM, managed funds).
3.1 Rolling Factor Regression#
Using a 126-day rolling window**, regress the QMJ factor return on the Fama-French factors:
Report the rolling betas (Mkt, SMB, HML) for the last 5 dates.
hedging_df = factor_performance[['Factor_Return']].merge(
FF[['Mkt-RF', 'SMB', 'HML']],
left_index=True,
right_index=True,
how='inner'
).dropna()
window_size = 126
rolling_results = []
for t in range(window_size, len(hedging_df) + 1):
window_data = hedging_df.iloc[t-window_size:t]
Y = window_data['Factor_Return']
X = sm.add_constant(window_data[['Mkt-RF', 'SMB', 'HML']])
model = sm.OLS(Y, X).fit()
rolling_results.append({
'date': window_data.index[-1],
'Alpha': model.params['const'],
'Beta_MKT': model.params['Mkt-RF'],
'Beta_SMB': model.params['SMB'],
'Beta_HML': model.params['HML']
})
rolling_metrics = pd.DataFrame(rolling_results).set_index('date')
sol_31 = rolling_metrics[['Beta_MKT', 'Beta_SMB', 'Beta_HML']].tail()
display(sol_31.round(6))
| Beta_MKT | Beta_SMB | Beta_HML | |
|---|---|---|---|
| date | |||
| 2024-06-24 | 0.155949 | -0.336880 | -0.277515 |
| 2024-06-25 | 0.158921 | -0.331855 | -0.255972 |
| 2024-06-26 | 0.158739 | -0.330548 | -0.256174 |
| 2024-06-27 | 0.162946 | -0.336033 | -0.245961 |
| 2024-06-28 | 0.160143 | -0.330823 | -0.235267 |
3.2 Hedged Returns#
Construct hedged returns by using the lagged rolling betas as hedge ratios:
Report the annualized performance statistics (Mean, Volatility, Sharpe) of the hedged return stream.
betas = rolling_metrics[['Beta_MKT', 'Beta_SMB', 'Beta_HML']].shift(1)
hedged_df = hedging_df.merge(betas, left_index=True, right_index=True, how='inner').dropna()
hedge_component = (
hedged_df['Beta_MKT'] * hedged_df['Mkt-RF'] +
hedged_df['Beta_SMB'] * hedged_df['SMB'] +
hedged_df['Beta_HML'] * hedged_df['HML']
)
hedged_df['Hedged_Return'] = hedged_df['Factor_Return'] - hedge_component
hedged_ret = hedged_df['Hedged_Return']
sol_32 = pd.DataFrame({
'Annualized Mean': hedged_ret.mean() * ANN_FACTOR,
'Annualized Volatility': hedged_ret.std() * np.sqrt(ANN_FACTOR),
'Annualized Sharpe': (hedged_ret.mean() / hedged_ret.std()) * np.sqrt(ANN_FACTOR)
}, index=['Hedged QMJ'])
display(sol_32.T.round(6))
| Hedged QMJ | |
|---|---|
| Annualized Mean | 0.087293 |
| Annualized Volatility | 0.124918 |
| Annualized Sharpe | 0.698801 |
3.3 Portable Alpha Product#
Construct a portable alpha product that combines:
Levered market exposure: \(r^{\text{LevMkt-RF}}_t = 1.5 \times r^{\text{Mkt-RF}}_t\)
Hedged QMJ overlay: \(r^{\text{Hedged}}_t\)
Regress the product return on the market factor. Report:
Beta
Alpha (annualized)
Rounded to 6 decimal places.
portable_df = hedged_df[['Hedged_Return']].merge(
FF_sorted[['Mkt-RF']],
left_index=True,
right_index=True,
how='inner'
).dropna()
portable_df['Lev_Mkt_RF'] = 1.5 * portable_df['Mkt-RF']
portable_df['Product_Return'] = portable_df['Lev_Mkt_RF'] + portable_df['Hedged_Return']
Y = portable_df['Product_Return']
X = sm.add_constant(portable_df['Mkt-RF'])
model_33 = sm.OLS(Y, X).fit()
beta_portable = model_33.params['Mkt-RF']
alpha_portable = model_33.params['const'] * ANN_FACTOR
sol_33 = pd.DataFrame({
'Beta': beta_portable,
'Alpha (Annualized)': alpha_portable
}, index=['Portable Alpha Product'])
display(sol_33.T.round(6))
| Portable Alpha Product | |
|---|---|
| Beta | 1.484452 |
| Alpha (Annualized) | 0.089239 |