Parametric Portfolio Policies
Parametric portfolio policies (Brandt, Santa-Clara, and Valkanov) sidestep estimating expected returns and a huge covariance matrix by modelling portfolio weights directly as a function of firm characteristics. Start from a benchmark weight and tilt it by characteristics (momentum, size); estimate the tilt coefficients by maximizing investor utility. Use the R | Python toggle to switch.
library(tidyverse)
library(tidyfinance)
library(scales)
import pandas as pd
import numpy as np
import sqlite3
from scipy.optimize import minimize
Data and characteristics
We load the monthly panel (excluding financials) and build the two sorting characteristics — lagged size (market cap) and momentum (the market cap 13 months prior serves to construct the momentum signal). These are the inputs the weights will tilt on.
crsp_monthly <- tbl(tidy_finance, "crsp_monthly") |>
filter(industry != "Finance") |>
select(permno, date, ret_excess, mktcap, mktcap_lag) |>
collect()
crsp_monthly_lags <- crsp_monthly |>
transmute(permno, date_13 = date %m+% months(13), mktcap)
data_portfolios <- crsp_monthly |>
inner_join(crsp_monthly_lags,
join_by(permno, date == date_13), suffix = c("", "_13")) |>
mutate(
momentum_lag = mktcap_lag / mktcap_13,
size_lag = log(mktcap_lag)
) |>
drop_na()
tidy_finance = sqlite3.connect(database="data/tidy_finance_python.sqlite")
crsp_monthly = (pd.read_sql_query(
"SELECT permno, date, industry, ret_excess, mktcap, mktcap_lag FROM crsp_monthly",
tidy_finance, parse_dates={"date"})
.query("industry != 'Finance'")
.drop(columns=["industry"]))
crsp_monthly_lags = (crsp_monthly
.assign(date_13=lambda x: x["date"] + pd.DateOffset(months=13))
.get(["permno", "date_13", "mktcap"])
.rename(columns={"mktcap": "mktcap_13", "date_13": "date"}))
data_portfolios = (crsp_monthly
.merge(crsp_monthly_lags, how="inner", on=["permno", "date"])
.assign(
momentum_lag=lambda x: x["mktcap_lag"] / x["mktcap_13"],
size_lag=lambda x: np.log(x["mktcap_lag"]))
.dropna())
Computing tilted weights
The policy starts from a benchmark weight (value weights) and adds a tilt that is linear in the cross-sectionally standardized characteristics, scaled by 1/n and the parameter vector theta. A positive theta on a characteristic means "overweight stocks with more of it."
compute_portfolio_weights <- function(theta, data, value_weighting = TRUE) {
data |>
group_by(date) |>
mutate(
n = n(),
relative_mktcap = mktcap_lag / sum(mktcap_lag),
characteristic_momentum = (momentum_lag - mean(momentum_lag)) / sd(momentum_lag),
characteristic_size = (size_lag - mean(size_lag)) / sd(size_lag),
weight_tilt = (1 / n) * (
theta["momentum_lag"] * characteristic_momentum +
theta["size_lag"] * characteristic_size
),
weight = if_else(value_weighting, relative_mktcap, 1 / n) + weight_tilt
) |>
ungroup()
}
def compute_portfolio_weights(theta, data, value_weighting=True):
def per_date(g):
n = len(g)
rel = g["mktcap_lag"] / g["mktcap_lag"].sum()
c_mom = (g["momentum_lag"] - g["momentum_lag"].mean()) / g["momentum_lag"].std()
c_size = (g["size_lag"] - g["size_lag"].mean()) / g["size_lag"].std()
tilt = (1 / n) * (theta[0] * c_mom + theta[1] * c_size)
base = rel if value_weighting else 1 / n
return g.assign(weight=base + tilt)
return data.groupby("date", group_keys=False).apply(per_date)
The utility objective
We evaluate a weight vector by the investor's average realized utility (here CRRA / power utility of the portfolio return). The optimizer minimizes the negative of this — only a handful of parameters (one per characteristic), so it scales to thousands of stocks without estimating any covariance matrix.
power_utility <- function(r, gamma = 5) {
(1 + r)^(1 - gamma) / (1 - gamma)
}
compute_objective_function <- function(theta, data, value_weighting = TRUE) {
weights <- compute_portfolio_weights(theta, data, value_weighting)
obj <- weights |>
group_by(date) |>
summarize(portfolio_return = weighted.mean(ret_excess, weight)) |>
summarize(obj = -mean(power_utility(portfolio_return))) |>
pull(obj)
obj
}
def power_utility(r, gamma=5):
return (1 + r)**(1 - gamma) / (1 - gamma)
def compute_objective_function(theta, data, value_weighting=True):
weights = compute_portfolio_weights(theta, data, value_weighting)
portfolio_returns = (weights
.groupby("date")
.apply(lambda g: np.average(g["ret_excess"], weights=g["weight"])))
return -power_utility(portfolio_returns).mean()
Optimizing the tilt parameters
A general-purpose optimizer (Nelder–Mead) finds the theta that maximizes expected utility. The resulting coefficients are directly interpretable: their sign and size say how strongly to tilt toward momentum and away from (or toward) size.
theta <- c(momentum_lag = 1.5, size_lag = 1.5)
optimal_theta <- optim(
par = theta,
fn = compute_objective_function,
data = data_portfolios,
value_weighting = TRUE,
method = "Nelder-Mead"
)
optimal_theta$par
theta_init = np.array([1.5, 1.5])
optimal_theta = minimize(
fun=compute_objective_function,
x0=theta_init,
args=(data_portfolios, True),
method="Nelder-Mead"
)
optimal_theta.x
Study notes following the Tidy Finance curriculum by Scheuch, Voigt, Weiss, and Frey. Prose is my own; the R/Python code is reproduced from the book's open-source source, licensed CC BY-NC-SA 4.0.