Which part of shareholder yield pays?
In 2013, Meb Faber wrote “Shareholder Yield,” which outlines three ways a company can return cash to investors: dividends, net buybacks, and debt paydown. Combining those and using them as a scoring system to pick stocks that will beat the market has been a profitable strategy according to Meb, but is it still? As practical as I am, I like testing what I read against the data I have. So let us begin!
Data and method
From LSEG, I get the monthly returns of US stocks from January 2000 to February 2026. Needless to say, I include the delisted stocks. I also get fundamental data and the S&P 500 total return from LSEG. The fundamental data come from annual reports, and I use each value only from its publication date.
Then I calculate the three ways a company can return cash, as yields, and the shareholder yield is the sum of all three.
\[\text{dividend yield} = \frac{\text{dividends paid in the fiscal year}}{\text{market cap at the end of June}}\]
\[\begin{aligned}&\text{buyback yield} \\ &= \frac{\text{cash spent on buybacks} - \text{cash from new shares}}{\text{market cap at the end of June}}\end{aligned}\]
\[\begin{aligned}&\text{debt paydown yield} \\ &= \frac{\text{debt repaid} - \text{debt issued}}{\text{market cap at the end of June}}\end{aligned}\]
\[\begin{aligned}\text{shareholder yield} = {} & \text{cap}(\text{dividend yield}) \\ & + \text{cap}(\text{buyback yield}) \\ & + \text{cap}(\text{debt paydown yield}) \\[4pt] \text{cap}(y) = {} & y \text{ kept between } {-30\%} \text{ and } {+30\%}\end{aligned}\]
I rebalance the portfolios once a year, at the end of June. Each time, I look at a universe similar to the S&P 500, the 500 largest stocks with the figures the yields need, and compute the yields. Then I create a portfolio for each yield separately, and one for all of them together, the shareholder yield. Each portfolio holds the 30 stocks that score the highest on each yield, weighted by market cap, which makes the S&P 500 a more relevant benchmark. I repeat this procedure every year of the sample period and compute the annual return of each portfolio. The returns are after trading costs of half the bid-ask spread plus a 0.05% commission on each trade. I have attached the code in the appendix.
Results
From July 2001 to February 2026, after costs, the dividend portfolio returned 10.9% a year, the buyback portfolio 9.8%, the shareholder yield portfolio 9.5%, and the debt paydown portfolio 8.1%, against 9.3% for the S&P 500. The buyback portfolio has given a return similar to the S&P 500. It was furthest ahead of the S&P 500 between 2012 and 2020, while the dividend portfolio has outperformed it by about 1.7 percentage points a year. The debt paydown portfolio has really underperformed the S&P 500, which may explain why the shareholder yield portfolio has been underperforming compared to before, so that around 2020, its value of $1 became almost the same as investing in the S&P 500.
To see how the premium changes over time, I also compute a rolling five-year premium each month, from the returns of the last 60 months.
\[\begin{aligned}\text{return a year} &= \Big(\prod_{i=0}^{59} (1 + r_{t-i})\Big)^{12/60} - 1 \\[4pt] r_{t-i} &= \text{the return in month } t-i \\ t &= \text{the latest month}\end{aligned}\]
\[\begin{aligned}\text{five-year premium} = {} & \text{portfolio's return a year} \\ & - \text{S\&P 500's return a year}\end{aligned}\]
| Return a year since July 2001 | Premium a year, last five years |
|---|
The rolling premium shows the same. The shareholder yield portfolio has mostly trailed the S&P 500 since the end of 2017, except for a few months, mostly between 2022 and 2025, and it is now back to a negative five-year premium, which may be because of the debt paydown component. Since January 2018, the five-year premium has been negative in 74% of the months for shareholder yield, 73% for debt paydown, 66% for buybacks, and 57% for dividends.
Limitations
The returns are after trading costs, but before taxes. The difference from the S&P 500 does not adjust for risk. The four portfolios have volatility of 17.1% to 20.5% a year, against 14.9% for the S&P 500. Even though I pick from the same kind of stocks the S&P 500 holds, a portfolio of the 30 top-scoring stocks is still a different list.
I only look at the 500 largest stocks each June. Faber uses the last 12 months of cash flows, while I use the last fiscal year, and I cap each yield at ±30%, while he uses them as they are. I also rebalance only once a year, while companies report quarterly, so quarterly rebalancing as new information comes in is another way to do it.
Some companies may correct their figures later, and the data may reflect the corrected values. If a stock gets delisted, I use the last value that I get from LSEG.
Faber holds about 125 stocks, while I limit it to 30. The choice of 30 stocks is a bit arbitrary. 50 or 100 stocks could work too, but I chose 30 because that is what I normally use, and I think it is enough for diversification. I also ran the test with 50 and 100 stocks. With 30 stocks, dividends lead with 1.7 points a year over the S&P 500. With 50 and 100 stocks, buybacks lead with 1.3 and 2.0 points, and dividends fall to 0.3 and 0.4. Shareholder yield goes from 0.2 points with 30 stocks to 0.7 with 50 and 1.7 with 100. Debt paydown stays below the S&P 500 in all three cases.
Conclusion
So the conclusion is that when a portfolio scores stocks on a set of measures, it can be good to decompose them into separate portfolios and see how each of them performs. The Magic Formula has trailed since about 2015, and similarly, the shareholder yield has mostly trailed since the end of 2017, while dividends have outperformed.
Appendix: the code
# In the settings, I specify the environment in which I test the portfolios. I start by loading the
# libraries, naming the datasets, and setting the working folder. Then I set filters, such as
# including only 30 stocks in each portfolio and considering only the 500 largest stocks by market
# cap across all sectors. The rolling window is 60 months. The reason I specify all of these things
# in the beginning is that if I want to change something, such as excluding a sector, I can do that
# easily at the start, instead of at separate places as I go through the code line by line, where I
# might have to change something five or six times. I only do it once, at the beginning.
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
CODE_DIR = Path.cwd()
DATA_DIR = CODE_DIR.parent / "Betting against beta"
PANEL_CSV = CODE_DIR / "nr44_monthly_panel.csv"
SPX_CSV = CODE_DIR / "nr54_spxtr_daily.csv"
CASHFLOW_CSV = DATA_DIR / "US_shareholder_yield_cashflow_raw.csv"
SECTOR_CSV = DATA_DIR / "US_sectors.csv"
NUMBERS_FILE = CODE_DIR / "nr47c_numbers.json"
CHART_FILE = CODE_DIR / "nr47c_growth.png"
FORMATION_MONTH = 6
HOLD_MONTHS = 12
START_YEAR = 2001
TOP_N = 30
UNIVERSE_SIZE = 500 # None keeps every stock above MIN_MARKET_CAP
MIN_MARKET_CAP = 500 # USD millions
EXCLUDE_SECTORS = [] # for example ["Financials", "Real Estate", "Utilities"]
MAX_REPORT_AGE = 540 # days from the fiscal year end to June
WINSOR = 0.30 # the cap on each yield in the shareholder yield
COMMISSION = 0.0005
ROLLING_MONTHS = 60
assert all(path.exists() for path in [PANEL_CSV, SPX_CSV, CASHFLOW_CSV, SECTOR_CSV]), "a saved file is missing"# I load the monthly total return at the end of each month for all stocks in the sample period, and
# I do the same for the S&P 500 at the same month ends. The stocks' returns form a wide dataset,
# where each column corresponds to one stock, the rows are indexed by date, and the cells contain
# those returns.
panel = pd.read_csv(PANEL_CSV, low_memory=False)
panel["ym"] = pd.PeriodIndex(panel["ym"], freq="M")
ret_wide = panel.pivot_table(index="ym", columns="Instrument", values="ret_m")
print(f"panel: {len(panel):,} stock-months, {panel['Instrument'].nunique():,} stocks, "
f"{panel['ym'].min()} to {panel['ym'].max()}")
spx = pd.read_csv(SPX_CSV, encoding="utf-8-sig", parse_dates=["Date"])
spx["ym"] = spx["Date"].dt.to_period("M")
spx_level = spx.sort_values("Date").groupby("ym")["spxtr"].last()
spx_ret = spx_level.pct_change()
print(f"S&P 500 total return: {spx_level.index.min()} to {spx_level.index.max()}")panel: 1,348,652 stock-months, 10,064 stocks, 2000-01 to 2026-02
S&P 500 total return: 1993-01 to 2026-09
# The cash-flow lines of each fiscal year: the dividends paid, the net stock issued and the net debt
# issued, with the date the year's figures were first announced
f = pd.read_csv(CASHFLOW_CSV, low_memory=False)
for column in ["DividendsPaid", "StockIssuedNet", "DebtIssuedNet"]:
f[column] = pd.to_numeric(f[column], errors="coerce")
f["PeriodEndDate"] = pd.to_datetime(f["PeriodEndDate"], errors="coerce")
f["FilingDate"] = pd.to_datetime(f["FilingDate"], errors="coerce")
f = f.dropna(subset=["PeriodEndDate", "FilingDate"])
f = f.sort_values(["Instrument", "PeriodEndDate"]).drop_duplicates(["Instrument", "PeriodEndDate"], keep="last")
print(f"fiscal years: {len(f):,}, for {f['Instrument'].nunique():,} stocks")fiscal years: 34,225, for 1,764 stocks
# I use lagged fundamentals to avoid any look-ahead bias, which can come from using information
# that was not available to the public at that point in time. Any report that is more than 540 days
# old I drop, because the company may have stopped reporting. I also filter on a market cap larger
# than $500 million. I rebalance each year at the end of June.
form = panel.loc[panel["ym"].dt.month == FORMATION_MONTH, ["Instrument", "ym", "mcap_last", "mcap_last_date"]].copy()
form = form[(form["ym"].dt.year >= START_YEAR) & (form["mcap_last"] > 0)]
form["formation_date"] = pd.to_datetime(form["mcap_last_date"], errors="coerce").fillna(form["ym"].dt.to_timestamp(how="end"))
# merge_asof with direction="backward" gives each stock the latest report filed on or before its June date
sig = pd.merge_asof(form.sort_values("formation_date"), f.sort_values("FilingDate"),
left_on="formation_date", right_on="FilingDate", by="Instrument", direction="backward")
sig = sig[(sig["formation_date"] - sig["PeriodEndDate"]).dt.days <= MAX_REPORT_AGE]
sig = sig[sig["mcap_last"] >= MIN_MARKET_CAP]
sectors = pd.read_csv(SECTOR_CSV).drop_duplicates("Instrument")
sig = sig.merge(sectors, on="Instrument", how="left")
sig = sig[~sig["Sector"].isin(EXCLUDE_SECTORS)]
# The yields need both cash-flow lines, so all four portfolios choose from the same stocks
sig = sig.dropna(subset=["StockIssuedNet", "DebtIssuedNet"])
if UNIVERSE_SIZE:
sig = sig.sort_values("mcap_last", ascending=False).groupby("ym").head(UNIVERSE_SIZE)
print(f"stocks per June: median {sig.groupby('ym').size().median():.0f}, "
f"{sig['ym'].dt.year.min()} to {sig['ym'].dt.year.max()}")stocks per June: median 500, 2001 to 2025
# I consider the three yields because some companies may only do buybacks, and if I were to look
# only at debt paydown or dividends, they would not show up as companies that returned cash to
# their investors, when in fact they do.
mcap = sig["mcap_last"]
sig["dividends"] = sig["DividendsPaid"].abs().fillna(0) / mcap
# Issuing shares or debt brings cash in, so a buyback or a repayment is a negative net issue
sig["buybacks"] = -sig["StockIssuedNet"] / mcap
sig["debt_paydown"] = -sig["DebtIssuedNet"] / mcap
# In the sum, each yield is capped at +/-30%, so that one extreme figure, such as a large debt
# repayment after the sale of a business, counts at most 30 percentage points
sig["shareholder_yield"] = sum(sig[leg].clip(-WINSOR, WINSOR) for leg in ["dividends", "buybacks", "debt_paydown"])
SIGNALS = {"Shareholder yield": "shareholder_yield", "Dividends": "dividends",
"Buybacks": "buybacks", "Debt paydown": "debt_paydown"}
print(sig[list(SIGNALS.values())].median().mul(100).round(2).rename("median, %").to_string())shareholder_yield 2.23
dividends 1.31
buybacks 0.68
debt_paydown -0.01
# I choose the 30 stocks with the highest score on each measure, and I use trading costs to make
# the portfolio return more realistic.
def hold(weights, form_ym):
"""Holds the stocks for 12 months from the given weights, which then move with each stock's
return. Returns the portfolio's monthly returns and each stock's weight at the end. A stock
that leaves the market keeps its last value."""
months = [form_ym + k for k in range(1, HOLD_MONTHS + 1)]
returns = ret_wide.reindex(index=months, columns=weights.index).dropna(how="all")
value = (1 + returns.fillna(0)).cumprod().mul(weights, axis=1)
total = value.sum(axis=1)
monthly = total.pct_change().fillna(total.iloc[0] - 1)
return monthly, value.iloc[-1] / total.iloc[-1]
# The half-spread is half the gap between the bid and the ask price, as a share of the price
spreads = panel[panel["ym"].dt.month == FORMATION_MONTH].pivot_table(index="ym", columns="Instrument", values="hs_first")
portfolios, yearly_cost = {}, {}
for name, column in SIGNALS.items():
monthly, costs = [], []
held = pd.Series(dtype=float)
for form_ym, june in sig.groupby("ym"):
top = june.nlargest(TOP_N, column).set_index("Instrument")["mcap_last"]
weights = top / top.sum()
# Each stock is traded by the change in its weight: bought, sold, or brought back to its new weight
trades = weights.sub(held, fill_value=0).abs()
spread = spreads.loc[form_ym].reindex(trades.index).fillna(spreads.loc[form_ym].median())
cost = (trades * (spread + COMMISSION)).sum()
returns, held = hold(weights, form_ym)
returns.iloc[0] = (1 + returns.iloc[0]) * (1 - cost) - 1
monthly.append(returns)
costs.append(cost)
portfolios[name] = pd.concat(monthly)
yearly_cost[name] = np.mean(costs) * 100
portfolios = pd.DataFrame(portfolios)
portfolios["S&P 500"] = spx_ret.reindex(portfolios.index)
portfolios = portfolios.dropna()
print(f"monthly returns after costs: {portfolios.index.min()} to {portfolios.index.max()}, {len(portfolios)} months")monthly returns after costs: 2001-07 to 2026-02, 296 months
# I compound the returns, and volatility is the standard deviation multiplied by the square root of
# 12. The drawdown is the largest fall from a prior high. I compute volatility and drawdown because
# looking only at return can give a misleading view of a portfolio's performance. Together, they
# capture both the return profile and the risk profile, and that is the trade-off everyone building
# a portfolio needs to consider.
def summary(returns):
"""The annual return, the volatility and the worst drawdown, all in %."""
growth = (1 + returns).cumprod()
years = len(returns) / 12
# The highest value so far starts at the $1 invested
high = growth.cummax().clip(lower=1)
return pd.Series({"annual return": (growth.iloc[-1] ** (1 / years) - 1) * 100,
"volatility": returns.std() * np.sqrt(12) * 100,
"worst drawdown": (growth / high - 1).min() * 100})
table = portfolios.apply(summary).T
table["minus S&P 500"] = table["annual return"] - table.loc["S&P 500", "annual return"]
table["average cost each June"] = pd.Series(yearly_cost)
print(f"Top {TOP_N} by each measure, weighted by market cap, after costs, {portfolios.index.min()} to "
f"{portfolios.index.max()} (%, and percentage points for the difference):")
print(table.round(2).to_string())Top 30 by each measure, weighted by market cap, after costs, 2001-07 to 2026-02 (%, and percentage points for the difference):
annual return volatility worst drawdown minus S&P 500 average cost each June
Shareholder yield 9.51 19.71 -64.21 0.22 0.12
Dividends 10.94 18.12 -66.06 1.65 0.09
Buybacks 9.76 17.12 -48.80 0.47 0.14
Debt paydown 8.15 20.47 -67.67 -1.14 0.13
S&P 500 9.29 14.89 -50.95 0.00 NaN
# I calculate the portfolio's return a year minus the S&P 500's over the same months, on a rolling
# five-year basis. That gives an updated view of whether its return starts to trail the benchmark.
five_year = (1 + portfolios).rolling(ROLLING_MONTHS).apply(np.prod, raw=True) ** (12 / ROLLING_MONTHS) - 1
premium = five_year.sub(five_year["S&P 500"], axis=0).drop(columns="S&P 500").dropna() * 100
each_june = premium[premium.index.month == FORMATION_MONTH]
print(f"Rolling five-year premium over the S&P 500 (points a year), each June and {premium.index[-1]}:")
print(pd.concat([each_june, premium.iloc[[-1]]]).round(1).to_string())
print("\nShare of the five-year windows in which each portfolio beat the S&P 500:")
print((premium > 0).mean().round(2).to_string())Rolling five-year premium over the S&P 500 (points a year), each June and 2026-02:
Shareholder yield Dividends Buybacks Debt paydown
ym
2006-06 10.1 6.8 -0.1 9.8
2007-06 3.9 3.8 -4.4 4.7
2008-06 -2.8 3.6 -1.6 -2.4
2009-06 -2.5 -0.3 -2.0 -4.5
2010-06 -1.3 -2.0 2.2 -3.7
2011-06 -4.1 -2.1 3.1 -6.7
2012-06 -6.0 -0.6 3.6 -9.5
2013-06 1.8 0.6 4.5 -1.4
2014-06 0.7 4.7 6.1 -0.9
2015-06 -2.7 1.8 4.6 -3.9
2016-06 0.0 2.1 2.5 -2.4
2017-06 3.7 -0.2 3.9 1.1
2018-06 -2.9 -0.9 -1.1 -5.6
2019-06 -4.5 -2.3 -2.2 -8.1
2020-06 -6.9 -5.9 -6.2 -10.1
2021-06 -3.1 -6.2 -3.4 -4.6
2022-06 -2.2 0.0 -5.1 -3.3
2023-06 -2.1 -0.8 0.2 -2.7
2024-06 -0.6 0.4 -0.5 0.6
2025-06 2.4 7.4 3.1 4.8
2026-02 -1.6 8.1 0.4 -1.4
Share of the five-year windows in which each portfolio beat the S&P 500:
Shareholder yield 0.33
Dividends 0.49
Buybacks 0.57
Debt paydown 0.28
# Then I save the numbers and put them on the chart.
numbers = {"period": [str(portfolios.index.min()), str(portfolios.index.max())], "top_n": TOP_N,
"universe_size": UNIVERSE_SIZE, "excluded_sectors": EXCLUDE_SECTORS,
"table": table.round(4).to_dict(orient="index"),
"months": [str(month) for month in portfolios.index],
"growth": {name: (1 + portfolios[name]).cumprod().round(6).tolist() for name in portfolios},
"premium_months": [str(month) for month in premium.index],
"premium": {name: premium[name].round(4).tolist() for name in premium}}
NUMBERS_FILE.write_text(json.dumps(numbers, indent=1), encoding="utf-8")
print(f"The numbers are saved in {NUMBERS_FILE.name}")
from matplotlib.ticker import FuncFormatter, NullFormatter
COLORS = {"Shareholder yield": "#c0392b", "Dividends": "#d2822b", "Buybacks": "#17868a",
"Debt paydown": "#7c5cbf", "S&P 500": "#0a2540"}
figure, (top, bottom) = plt.subplots(2, 1, figsize=(11, 9), sharex=True, gridspec_kw={"height_ratios": [3, 2]})
for name, color in COLORS.items():
growth = (1 + portfolios[name]).cumprod()
top.plot(growth.index.to_timestamp(how="end"), growth, color=color, lw=2.6 if name == "S&P 500" else 1.8,
label=f"{name} ({table.loc[name, 'annual return']:.1f}% a year)")
if name in premium:
bottom.plot(premium.index.to_timestamp(how="end"), premium[name], color=color, lw=1.6)
# On a log scale, equal distances up the axis are equal percentage gains
top.set_yscale("log")
top.set_yticks([0.5, 1, 2, 5, 10, 20])
top.yaxis.set_major_formatter(FuncFormatter(lambda value, _: f"${value:g}"))
top.yaxis.set_minor_formatter(NullFormatter())
top.set_ylabel("Value of $1 (log scale)")
top.legend(frameon=False)
bottom.axhline(0, color="#8a8172", lw=1)
bottom.set_ylabel("Rolling five-year premium\n(points a year)")
for axis in (top, bottom):
axis.grid(True, which="major", color="#e6e0d6")
axis.spines[["top", "right"]].set_visible(False)
plt.savefig(CHART_FILE, dpi=150, bbox_inches="tight")
plt.show()The numbers are saved in nr47c_numbers.json
Read next
- The Magic Formula worked. Just not lately. Greenblatt’s Magic Formula returned 11.1% a year after trading costs, against 8.7% for the S&P 500 from 2001 to 2026, and has trailed it since about 2015.
- Do the highest-yielding stocks beat the S&P 500? The 30 highest-yielding US stocks returned 7.9% a year after trading costs from 2012 to 2026, against 15.0% for the S&P 500.
- Testing a stock characteristic as a factor How to go from an economic idea about a characteristic to the evidence that it is a factor, in five steps, with value on simulated data.
Disclaimer: simplified, hypothetical backtest with approximate trading costs, for discussion purposes only. Not investment advice. Past performance does not guarantee future returns.
Data: monthly total returns, market caps, bid and ask prices and annual cash flows of US stocks, and the S&P 500 total return index, from LSEG Workspace (licensed). I do not reproduce the raw data here.
Source: Meb Faber, Shareholder Yield: A Better Approach to Dividend Investing (2013).