Replicating Dickerson, Mueller & Robotti (2023): Priced Risk in Corporate Bonds¶
U.S.E. Finance Data Hub / WRDS Database: Bond Returns¶
Paper replicated: Dickerson, A., Mueller, P. & Robotti, C. (2023). Priced Risk in Corporate Bonds. Journal of Financial Economics, 150(2), 103707.
Database used: WRDS Bond Returns (Data Hub guide)
What this notebook does¶
Dickerson, Mueller & Robotti (2023) study which risk factors are priced in the cross-section of corporate bond returns. They find that credit risk and downside risk are the primary drivers of expected bond returns, while traditional equity factors (size, value) have limited explanatory power in bond markets.
WRDS Bond Returns provides pre-computed monthly corporate bond returns derived from TRACE transaction data, making it the ideal database for bond asset pricing research without having to process raw trades.
- Pull monthly bond return data from WRDS Bond Returns (
wrdsapps_bondret.wrds_bond_returns) via the WRDS API - Construct portfolio sorts based on bond characteristics (rating, maturity)
- Examine the cross-section of average bond returns across portfolios
- Compare our findings to the published 2023 results
Learning objectives¶
- Practice connecting to WRDS and querying the Bond Returns database
- Understand how monthly bond returns are computed and what the key fields represent
- Learn how to construct characteristic-sorted bond portfolios
- Build intuition for the risk-return tradeoff in corporate bond markets
Requirements to run this notebook¶
- A valid WRDS account with WRDS Bond Returns access (ask the Finance Data Hub if you don't have one yet)
pip install wrds pandas numpy matplotlib seaborn- You will be prompted for your WRDS username/password the first time you connect (or set up a
.pgpassfile, see the WRDS Python guide)
1. Setup and WRDS connection¶
import wrds
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid", palette="deep")
pd.set_option("display.max_columns", 50)
db = wrds.Connection()
2. Identifying the WRDS Bond Returns variables we need¶
All variables come from the wrdsapps_bondret.wrds_bond_returns table, which provides monthly bond-level return data.
| Paper concept | Description | Bond Returns field |
|---|---|---|
| Bond ID | CUSIP identifier | cusip |
| Date | End-of-month date | date |
| Bond Return | Monthly total return | bond_ret |
| Price | End-of-month clean price | price_eom |
| Volume | Monthly trading volume | t_volume |
| Rating | Credit rating (numeric scale) | rating |
| Maturity | Time to maturity | tmt |
| Coupon | Annual coupon rate | coupon |
| Amount Outstanding | Face value outstanding | amount_outstanding |
The rating field uses a numeric scale where lower numbers indicate higher credit quality (e.g., 1 = AAA, 10 = BBB, 21 = D). The bond_ret field is the total return including both price changes and accrued interest.
# Discover the correct table name
desc = db.describe_table(library="wrdsapps_bondret", table="bondret")
print(desc[["name"]].to_string())
# Pull monthly bond returns.
# The table provides pre-computed returns so we do not need to process raw TRACE transactions ourselves.
query = """
SELECT cusip, date, ret_eom, price_eom, t_volume,
rating_num, rating_cat, tmt, coupon, amount_outstanding
FROM wrdsapps_bondret.bondret
WHERE date >= '2005-01-01'
AND date <= '2023-12-31'
"""
df = db.raw_sql(query, date_cols=["date"])
print(f"Rows pulled: {len(df):,}")
print(f"Date range: {df['date'].min()} to {df['date'].max()}")
df.head()
3. Cleaning the sample¶
# Drop observations with missing returns or ratings
df = df.dropna(subset=["ret_eom", "rating_num"])
# Remove extreme returns (likely data errors)
df = df[(df["ret_eom"] > -0.5) & (df["ret_eom"] < 0.5)]
# Keep only bonds with positive time to maturity
df = df[df["tmt"] > 0]
# Classify bonds into rating groups
def rating_group(r):
if r <= 7:
return "Investment Grade (AAA to A)"
elif r <= 10:
return "Investment Grade (BBB)"
elif r <= 16:
return "High Yield (BB to B)"
else:
return "Distressed (CCC and below)"
df["rating_group"] = df["rating_num"].apply(rating_group)
# Extract year-month for time aggregation
df["ym"] = df["date"].dt.to_period("M")
print(f"Observations after cleaning: {len(df):,}")
print(f"Unique bonds: {df['cusip'].nunique():,}")
print(f"\nRating group distribution:")
print(df["rating_group"].value_counts())
4. Constructing portfolio sorts¶
Following the corporate bond asset pricing literature, we sort bonds into portfolios based on their credit rating and time to maturity. The key prediction is that riskier bonds (lower rating, longer maturity) should earn higher average returns to compensate investors for bearing credit and interest rate risk.
We compute equal-weighted average monthly returns for each portfolio.
# Portfolio sorts by rating group
rating_rets = df.groupby(["ym", "rating_group"])["ret_eom"].mean().unstack()
avg_ret_by_rating = rating_rets.mean() * 12 * 100 # Annualized, in percent
std_by_rating = rating_rets.std() * np.sqrt(12) * 100
sharpe_by_rating = avg_ret_by_rating / std_by_rating
print("Annualized Average Returns by Rating Group (%):\n")
summary = pd.DataFrame({
"Mean Return (%)": avg_ret_by_rating,
"Volatility (%)": std_by_rating,
"Sharpe Ratio": sharpe_by_rating,
"N bonds (avg)": df.groupby(["ym", "rating_group"]).size().unstack().mean()
}).round(3)
print(summary)
5. Visualizing the results¶
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Average returns by rating group
order = ["Investment Grade (AAA to A)", "Investment Grade (BBB)",
"High Yield (BB to B)", "Distressed (CCC and below)"]
colors = ["seagreen", "steelblue", "darkorange", "firebrick"]
vals = [avg_ret_by_rating.get(g, 0) for g in order]
axes[0].bar(range(len(order)), vals, color=colors)
axes[0].set_xticks(range(len(order)))
axes[0].set_xticklabels(["AAA-A", "BBB", "BB-B", "CCC+"], fontsize=10)
axes[0].set_title("Annualized Average Bond Return by Credit Rating")
axes[0].set_ylabel("Average Return (%)")
# Cumulative return of IG vs. HY portfolios
if "Investment Grade (BBB)" in rating_rets.columns and "High Yield (BB to B)" in rating_rets.columns:
cum_ig = (1 + rating_rets["Investment Grade (BBB)"]).cumprod()
cum_hy = (1 + rating_rets["High Yield (BB to B)"]).cumprod()
axes[1].plot(cum_ig.index.to_timestamp(), cum_ig.values, color="steelblue", label="BBB (Investment Grade)")
axes[1].plot(cum_hy.index.to_timestamp(), cum_hy.values, color="darkorange", label="BB-B (High Yield)")
axes[1].set_title("Cumulative Returns: Investment Grade vs. High Yield")
axes[1].set_ylabel("Cumulative Return ($1 invested)")
axes[1].legend()
plt.tight_layout()
plt.show()
# Average return by maturity bucket
df["mat_bucket"] = pd.cut(df["tmt"], bins=[0, 3, 5, 10, 30], labels=["0-3yr", "3-5yr", "5-10yr", "10-30yr"])
mat_rets = df.groupby(["ym", "mat_bucket"])["ret_eom"].mean().unstack()
avg_mat = mat_rets.mean() * 12 * 100
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(avg_mat.index.astype(str), avg_mat.values, color="steelblue")
ax.set_title("Annualized Average Bond Return by Maturity Bucket")
ax.set_ylabel("Average Return (%)")
ax.set_xlabel("Time to Maturity")
plt.tight_layout()
plt.show()
6. Comparing to Dickerson, Mueller & Robotti (2023): What's the same, what's different¶
What replicates cleanly
The monotonically increasing pattern of average returns across credit rating groups (from AAA to CCC) should be clearly visible in the bar chart, confirming that credit risk is priced in corporate bond markets. The WRDS Bond Returns database makes this analysis straightforward because the returns are pre-computed from cleaned TRACE data, so students do not need to implement their own return calculation or TRACE cleaning pipeline. The maturity structure of returns (longer maturity earning higher returns on average) should also be visible, reflecting term premium compensation.
Where a modern WRDS-based replication necessarily differs from the original
Factor construction. Dickerson et al. construct novel bond market factors (credit, downside risk, liquidity) using sophisticated portfolio sorting and Fama-MacBeth regressions. We show the simpler single-sort portfolios, which are the building blocks for the full factor analysis. Constructing the paper's specific factors would require double sorts and cross-sectional regressions.
Return frequency. The paper uses monthly returns and examines various holding periods. Our analysis uses the monthly returns directly from the WRDS Bond Returns database, which is the correct frequency.
Sample coverage. WRDS Bond Returns begins in 2002 (when TRACE started) and covers the same universe. The paper uses a similar sample period, so the coverage should be comparable.
Risk adjustment. The paper's contribution is identifying which factors are priced after controlling for other risks. Our portfolio sorts show unconditional average returns, which combine the risk premium with any factor exposures. The full risk-adjustment exercise would require estimating factor betas and running cross-sectional regressions.
References¶
- Dickerson, A., Mueller, P. & Robotti, C. (2023). Priced Risk in Corporate Bonds. Journal of Financial Economics, 150(2), 103707.
- WRDS Bond Returns guide: https://uufinance.github.io/data/wrds/databases/bond-returns/
- WRDS Bond Returns access page: https://wrds-www.wharton.upenn.edu/pages/get-data/wrds-bond-returns/wrds-bond-returns/
- WRDS Python/API setup guide: https://uufinance.github.io/data/wrds/notebook/
Prepared for the U.S.E. Finance Data Hub as a database-tutorial template.