Which part of shareholder yield pays?

Python
Backtesting
Analysis
Code
From 2001 to 2026, the 30 large US stocks with the highest dividend yield beat the S&P 500 by 1.7 points a year after trading costs, those with the highest buyback yield beat it by 0.5 points, and those with the highest debt paydown yield trailed it by 1.1 points.
Published

October 27, 2026

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}\]

The value of $1 and the rolling five-year premium of the four portfolios and the S&P 500, July 2001 to February 2026 A chart with two panels from June 2001 to February 2026. The top panel shows the value of $1 invested in June 2001 in each of the four portfolios and in the S&P 500, on a log scale. The bottom panel shows each portfolio's rolling five-year premium over the S&P 500 from June 2006. From July 2001 to October 2007, $1 in the S&P 500 grew to $1.41. In the four portfolios, it grew to between $1.36 and $2.13. During the financial crisis, from their 2007 highs to February 2009, the S&P 500 lost 51%. The buyback portfolio lost the least, 49%, and the other three portfolios lost between 64% and 68%. From March 2009 to June 2014, all four portfolios grew faster than the S&P 500, which returned 22.8% a year. The dividend portfolio grew the fastest, at 29.7% a year. From July 2014 to June 2020, the S&P 500 returned 10.2% a year, more than all four portfolios. The buyback portfolio came closest, with 6.3% a year. From July 2020 to February 2026, the dividend portfolio returned 25.1% a year, the most. Over the whole period, after costs, the dividend portfolio returned 10.9% a year, the buyback portfolio 9.8%, and the S&P 500 9.3%.

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