Solution - Open Final Exam#

FINM 36700 - 2025#

UChicago Financial Mathematics#


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 DataFrame with 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 pandas rounding 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 .zip file.

Naming#

Your submitted file (ipynb or .zip) must be named in the format…

  • final-LASTNAME-FIRSTNAME.ipynb

  • final-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 fundamentals

  • FFF.csv - Fama-French factors (daily)

The stock data contains daily observations from May 2017 to October 2024 with:

  • price - stock price

  • market_cap - market capitalization (in millions)

  • debt_to_market_cap - debt divided by market cap

  • return_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 factor

  • HML - high minus low (value) factor

  • RF - 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_cap

  • return_on_investment

  • price_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:

  1. 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”)

  2. 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:

\[\text{quality\_raw}_{i,t} = \alpha_t + \beta_t \cdot \ln(\text{market\_cap}_{i,t}) + \varepsilon_{i,t}\]

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:

\[\text{quality\_score}_{i,t} = \frac{\text{quality\_pure}_{i,t} - \bar{\text{quality\_pure}}_t}{\sigma_t^{\text{quality\_pure}}}\]

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:

  1. Compute daily returns for each stock from prices.

  2. Each day, sort stocks into deciles based on quality_pure (using pd.qcut).

  3. 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:

  1. \(X_{\text{Vol}}\) (Fear): Rolling 21-day annualized volatility of Mkt-RF

  2. \(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:

\[r^{\text{QMJ}}_{t+1} = \alpha + \beta_1 X_{\text{Spread},t} + \beta_2 X_{\text{Vol},t} + \epsilon_{t+1}\]

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:

  1. Set portfolio weight: \(w_t = 10 \times \hat{r}^{\text{QMJ}}_{t+1}\) (scaled forecast)

  2. 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:

\[r^{\text{strat}}_{t} = \alpha + \beta \cdot r^{\text{Mkt-RF}}_{t} + \epsilon_t\]

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):

  1. Is the in-sample \(R^2\) from Question 2.2 meaningful for real-world forecasting? Why or why not?

  2. What does the OOS \(R^2\) from Question 2.3 indicate about the predictability of quality factor returns?

  3. In evaluating the strategy’s performance, do you care more about the OOS \(\alpha\) or the OOS \(R^2\)?

Solutions:

  1. In-sample \(R^2\) is misleading because it overfits to the training data and doesn’t reflect true predictive ability on unseen data.

  2. A negative OOS \(R^2\) means the model performs worse than a naive historical mean forecast—the signal.

  3. 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:

  1. Compute the Historical VaR (5%) for both strategies

  2. 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:

\[r^{\text{QMJ}}_t = \alpha_t + \beta^{\text{Mkt}}_t \cdot r^{\text{Mkt-RF}}_t + \beta^{\text{SMB}}_t \cdot r^{\text{SMB}}_t + \beta^{\text{HML}}_t \cdot r^{\text{HML}}_t + \epsilon_t\]

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:

\[r^{\text{Hedged}}_t = r^{\text{QMJ}}_t - \left(\hat{\beta}^{\text{Mkt}}_{t-1} \cdot r^{\text{Mkt-RF}}_t + \hat{\beta}^{\text{SMB}}_{t-1} \cdot r^{\text{SMB}}_t + \hat{\beta}^{\text{HML}}_{t-1} \cdot r^{\text{HML}}_t\right)\]

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:

  1. Levered market exposure: \(r^{\text{LevMkt-RF}}_t = 1.5 \times r^{\text{Mkt-RF}}_t\)

  2. Hedged QMJ overlay: \(r^{\text{Hedged}}_t\)

\[r^{\text{Product}}_t = r^{\text{LevMkt-RF}}_t + 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