Replicating Bao, Pan & Wang (2011): The Illiquidity of Corporate Bonds¶
U.S.E. Finance Data Hub / WRDS Database: TRACE¶
Paper replicated: Bao, J., Pan, J. & Wang, J. (2011). The Illiquidity of Corporate Bonds. The Journal of Finance, 66(3), 911–946.
Database used: TRACE (Data Hub guide)
What this notebook does¶
Bao, Pan & Wang (2011) propose a simple and elegant measure of corporate bond illiquidity based on the negative autocovariance of consecutive price changes. The idea is that in a liquid market, price changes should not reverse systematically, but in illiquid markets, transitory price pressure causes negative serial correlation in returns. The paper shows that this illiquidity measure is priced in the cross-section of corporate bond returns.
TRACE (Trade Reporting and Compliance Engine) is the primary source for U.S. corporate bond transaction data.
- Pull transaction-level bond trade data from TRACE (
trace.trace_enhanced) via the WRDS API - Construct daily end-of-day prices from intraday transactions
- Compute the illiquidity measure (negative autocovariance of price changes) for each bond
- Examine the cross-sectional distribution of illiquidity across corporate bonds
- Compare our findings to the published 2011 results
Learning objectives¶
- Practice connecting to WRDS and querying transaction-level bond data from TRACE
- Understand the structure of TRACE data (trade execution dates, prices, volumes, report types)
- Learn basic TRACE data cleaning (removing cancelled/corrected trades)
- Build intuition for bond market microstructure and illiquidity measurement
Requirements to run this notebook¶
- A valid WRDS account with TRACE Enhanced 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)
Note on data size. TRACE contains hundreds of millions of transaction records. The query below restricts to a single recent quarter to keep the download manageable. For a full replication, you would expand the date range and loop through quarters.
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 TRACE variables we need¶
All variables come from the trace.trace_enhanced table.
| Paper concept | Description | TRACE field |
|---|---|---|
| Bond ID | CUSIP of the bond | cusip_id |
| Trade Date | Date the trade was executed | trd_exctn_dt |
| Trade Time | Time the trade was executed | trd_exctn_tm |
| Price | Reported price (% of par) | rptd_pr |
| Volume | Reported volume (face value) | entrd_vol_qt |
| Buy/Sell | Whether the reporting side bought or sold | rpt_side_cd |
| Trade Report Type | Whether the report is original, cancel, or correction | trc_st |
TRACE Enhanced provides cleaned transaction data with cancelled and corrected trades flagged, which simplifies the data cleaning process relative to TRACE Standard. We keep only trades where trc_st is not a cancellation or correction.
# Pull a sample quarter of TRACE Enhanced data.
# TRACE is very large, so we restrict to one quarter for this tutorial.
# For a full replication, loop through multiple quarters.
query = """
SELECT cusip_id, trd_exctn_dt, trd_exctn_tm,
rptd_pr, entrd_vol_qt, rpt_side_cd, trc_st
FROM trace.trace_enhanced
WHERE trd_exctn_dt BETWEEN '2023-01-01' AND '2023-03-31'
AND rptd_pr > 0
AND entrd_vol_qt > 0
"""
df = db.raw_sql(query, date_cols=["trd_exctn_dt"])
print(f"Rows pulled: {len(df):,}")
df.head()
3. Cleaning the sample¶
# Basic TRACE cleaning following Dick-Nielsen (2009) and the literature.
# Remove cancelled and corrected trades.
# In TRACE Enhanced, trc_st flags these records.
if "trc_st" in df.columns:
df = df[~df["trc_st"].isin(["C", "X", "D"])]
# Remove trades with unreasonable prices (below 1 or above 250 % of par)
df = df[(df["rptd_pr"] >= 1) & (df["rptd_pr"] <= 250)]
# For each bond-day, compute the end-of-day price
# (last trade of the day, weighted toward larger trades)
df = df.sort_values(["cusip_id", "trd_exctn_dt", "trd_exctn_tm"])
eod = df.groupby(["cusip_id", "trd_exctn_dt"]).last().reset_index()
eod = eod[["cusip_id", "trd_exctn_dt", "rptd_pr", "entrd_vol_qt"]]
eod.columns = ["cusip", "date", "price", "volume"]
# Require at least 20 trading days per bond in the quarter
trade_counts = eod.groupby("cusip").size()
active_bonds = trade_counts[trade_counts >= 20].index
eod = eod[eod["cusip"].isin(active_bonds)]
print(f"End-of-day observations: {len(eod):,}")
print(f"Active bonds (20+ trading days): {eod['cusip'].nunique():,}")
4. Constructing the Bao-Pan-Wang illiquidity measure¶
The illiquidity measure is the negative autocovariance of consecutive price changes.
For bond $i$, let $\Delta p_t = p_t - p_{t-1}$ be the price change on day $t$. The illiquidity measure is:
$$\gamma_i = -\text{Cov}(\Delta p_t, \Delta p_{t-1})$$
In a perfectly liquid market, price changes are serially uncorrelated and $\gamma = 0$. In an illiquid market, transitory price pressure causes price reversals (negative autocorrelation in price changes), so $\gamma > 0$ indicates illiquidity.
This measure has the elegance of requiring only price data (no bid-ask quotes needed) and being computable from a short time series.
# Compute daily price changes for each bond
eod = eod.sort_values(["cusip", "date"])
eod["dp"] = eod.groupby("cusip")["price"].diff()
eod["dp_lag"] = eod.groupby("cusip")["dp"].shift(1)
# Drop missing values
eod_clean = eod.dropna(subset=["dp", "dp_lag"])
# Compute the illiquidity measure for each bond
def compute_gamma(group):
if len(group) < 10:
return np.nan
return -np.cov(group["dp"], group["dp_lag"])[0, 1]
gamma = eod_clean.groupby("cusip").apply(compute_gamma).reset_index()
gamma.columns = ["cusip", "gamma"]
gamma = gamma.dropna()
# Winsorize at 1st and 99th percentiles
lo, hi = gamma["gamma"].quantile(0.01), gamma["gamma"].quantile(0.99)
gamma["gamma"] = gamma["gamma"].clip(lo, hi)
print(f"Bonds with illiquidity measure: {len(gamma):,}")
print(f"\nIlliquidity measure (gamma) summary:")
print(gamma["gamma"].describe())
5. Visualizing the results¶
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Distribution of the illiquidity measure
axes[0].hist(gamma["gamma"], bins=50, color="steelblue", edgecolor="white")
axes[0].axvline(0, color="red", linestyle="--", label="gamma = 0 (perfectly liquid)")
axes[0].set_title("Distribution of Bond Illiquidity (gamma)\n(TRACE Enhanced, Q1 2023)")
axes[0].set_xlabel("Illiquidity measure (gamma)")
axes[0].set_ylabel("Number of bonds")
axes[0].legend()
# Illiquidity vs. trading frequency
trade_freq = eod.groupby("cusip").size().reset_index(name="n_days")
merged = gamma.merge(trade_freq, on="cusip")
axes[1].scatter(merged["n_days"], merged["gamma"], alpha=0.3, s=10, color="steelblue")
axes[1].set_title("Illiquidity vs. Trading Frequency")
axes[1].set_xlabel("Number of Trading Days in Quarter")
axes[1].set_ylabel("Illiquidity (gamma)")
plt.tight_layout()
plt.show()
# What share of bonds have positive gamma (illiquid)?
pct_illiquid = (gamma["gamma"] > 0).mean()
print(f"Share of bonds with positive gamma (illiquid): {pct_illiquid:.1%}")
# Distribution by illiquidity quintile
gamma["quintile"] = pd.qcut(gamma["gamma"], 5, labels=["Q1 (liquid)", "Q2", "Q3", "Q4", "Q5 (illiquid)"])
quintile_stats = gamma.groupby("quintile")["gamma"].agg(["mean", "median", "count"])
print("Illiquidity by Quintile:\n")
print(quintile_stats)
6. Comparing to Bao, Pan & Wang (2011): What's the same, what's different¶
What replicates cleanly
The illiquidity measure construction maps directly onto TRACE transaction data. The end-of-day price extraction, consecutive price change computation, and negative autocovariance formula follow the paper exactly. The finding that a large majority of corporate bonds have positive gamma (indicating transitory price impact and illiquidity) should be clearly visible. The negative relationship between trading frequency and illiquidity is also a robust feature of the data.
Where a modern WRDS-based replication necessarily differs from the original
Sample period. Bao et al. use TRACE data from 2003 to 2009, which includes the financial crisis. Our single-quarter sample (Q1 2023) gives a snapshot of a more normal market environment. A full replication would loop through all available quarters and compute monthly or quarterly illiquidity measures.
TRACE cleaning. The paper applies the Dick-Nielsen (2009) cleaning procedure for TRACE Standard, which involves complex matching of cancellations and corrections. We use TRACE Enhanced, which has already applied some of these corrections via the
trc_stflag. The Enhanced dataset simplifies cleaning considerably but may not match the exact sample from the original paper.End-of-day price. We use the last trade of the day as the closing price. Bao et al. also experiment with volume-weighted averages and median prices. The choice of price aggregation can affect the illiquidity measure, particularly for thinly traded bonds.
Cross-sectional pricing test. The paper's main contribution is showing that gamma is priced in the cross-section of bond returns (higher illiquidity leads to higher expected returns). This requires merging with bond return data (e.g., from the WRDS Bond Returns database) and running Fama-MacBeth regressions, which is a natural extension.
References¶
- Bao, J., Pan, J. & Wang, J. (2011). The Illiquidity of Corporate Bonds. The Journal of Finance, 66(3), 911–946.
- WRDS TRACE guide: https://uufinance.github.io/data/wrds/databases/trace/
- WRDS TRACE access page: https://wrds-www.wharton.upenn.edu/pages/get-data/otc-corporate-bond-and-agency-debt-bond-transaction-data/trace-enhanced/bond-trades/
- 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.