Do the highest-yielding stocks beat the S&P 500?

The 30 US stocks with the highest dividend yield, the annual dividend divided by the share price, underperformed the S&P 500. They returned 7.9% a year over a sample that effectively begins in 2012. The index returned 15.0%.
A dividend is cash leaving the company. The share price marks down by the payout on the ex-date, the first day a new buyer no longer receives the dividend, so a high yield does not create value on its own. I test whether a portfolio of the highest-yielding stocks beats the market anyway.
I build an equal-weighted portfolio of those 30 stocks, reset it to equal weights on the first trading day of each month with estimated trading costs, and compare it to the S&P 500.
The setup
- Universe: US common stocks on NYSE and Nasdaq, active and delisted.
- Signal at month
M= last observed dividend yield strictly before the first trading day ofM, forward-filled per stock. - Top 30 by yield, with yield in
(0%, 30%], last observation within 365 days, market cap (share price times number of shares) of at least $500m. If fewer than 25 qualify, skip the month and record no return for it. - Equal weight, monthly rebalance, no rebalancing within the month.
- Costs: bid-ask half-spread (half the gap between the price to buy and the price to sell) on the rebalance day + 0.05% commission, applied to every trade, entries, exits, and the trim/top-up trades on survivors whose weights changed.
- Benchmark: S&P 500, proxied by the total return of SPY, a fund that tracks the index, dividends included.
- Period: February 2012 to May 2026.
I rank on yield. A $1 dividend on a $20 stock is a 5% yield, and the same $1 on a $200 stock is 0.5%, so the two are very different signals.
Starting point
The backtest uses five tables, which I build in a separate job from data downloaded from LSEG Workspace. Because the data is licensed, I cannot share the raw file, and I show only aggregated results. The pipeline is the one from an earlier post, with the universe set to all US common stocks on NYSE and Nasdaq.
daily_wide,Date × Instrumentpanel of daily total returns (decimals).wide_yield_daily,Date × Instrumentpanel of dividend yields in percent, forward-filled per stock through time.wide_yield_obsdate, parallel panel storing the date of each cell’s most recent yield observation. Used by the freshness filter.wide_mc_daily,Date × Instrumentpanel of company market cap in USD millions, forward-filled.wide_hs_daily,Date × Instrumentpanel of the daily half-spread(Ask − Bid) / (2·Ask), capped at 5% and kept only where the bid and ask are positive and the ask is at least the bid. Missing days stay missing.
Parameters
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
TOP_N = 30 # portfolio size
MIN_HOLDINGS = 25 # require at least this many qualifying names, skip otherwise
MIN_YIELD = 0.0 # exclusive lower bound (must pay a dividend)
MAX_YIELD = 30.0 # upper cap in percent (filters distressed / data errors)
MAX_SIGNAL_AGE_DAYS = 365 # drop stocks whose last yield observation is older than this
MIN_MARKET_CAP_M = 500 # USD millions, size floor at signal_date
COMMISSION = 0.0005 # 0.05 % one-way
DEFAULT_HALF_SPREAD = 0.002 # 20 bps, used when a stock has no quote that day
# The month each trading day belongs to. to_period('M') turns 2011-01-03 into 2011-01,
# so every trading day of January has the same label and one month of the panel can
# be selected in a single step.
daily_months = daily_wide.index.to_period('M')Step 1. Pick the top 30 each month
Each month, pick_top_n applies the filters from the setup and keeps the 30 highest yields.
def pick_top_n(signal_date, rebalance_date):
"""The stocks to hold for the month that begins on rebalance_date.
Applies the yield-bound, freshness, market-cap and tradability filters in that order,
then ranks the remaining stocks on the dividend yield known at signal_date and keeps
the TOP_N highest. Returns None when fewer than MIN_HOLDINGS stocks qualify, which
tells the caller to skip the month.
"""
# .loc on one date returns that row of the wide panel as a Series, one dividend
# yield per stock. dropna removes the stocks with no yield recorded by that date.
yield_at_signal = wide_yield_daily.loc[signal_date].dropna()
# The lower bound is exclusive, so a stock that pays nothing is left out. A yield
# above the cap is almost always a collapsed price or a bad print.
above_floor = yield_at_signal > MIN_YIELD
below_cap = yield_at_signal <= MAX_YIELD
pays_dividend = yield_at_signal[above_floor & below_cap]
# Forward-filling copies the last yield forward with no time limit, so a delisted
# name's two-year-old yield would still rank. reindex lines the observation dates up
# with the stocks that are still in the running.
observation_dates = wide_yield_obsdate.loc[signal_date].reindex(pays_dividend.index)
# errors='coerce' turns an unreadable date into NaT instead of stopping the run
observation_dates = pd.to_datetime(observation_dates, errors='coerce')
signal_age_days = (signal_date - observation_dates).dt.days
# A stock with no observation date has a missing age, and NaN <= 365 is False, so
# this comparison drops it as well.
is_fresh = signal_age_days <= MAX_SIGNAL_AGE_DAYS
fresh_enough = pays_dividend[is_fresh]
# Microcaps dominate a pure yield ranking, because a price collapse increases the yield
# without the company paying a cent more.
market_cap = wide_mc_daily.loc[signal_date].reindex(fresh_enough.index)
market_cap = pd.to_numeric(market_cap, errors='coerce')
is_big_enough = market_cap >= MIN_MARKET_CAP_M
big_enough = fresh_enough[is_big_enough]
# The stock can only be bought if it traded on the rebalance day itself.
month_panel = daily_wide.loc[daily_months == rebalance_date.to_period('M')]
# A stock in the yield panel need not appear in the return panel, and asking for a
# column that does not exist would stop the run.
in_return_panel = [stock for stock in big_enough.index
if stock in month_panel.columns]
traded_on_rebalance_date = month_panel[in_return_panel].iloc[0].notna()
# Masking a boolean Series with itself keeps only its True entries, and .index then
# gives the tickers of the stocks that traded.
tradable = traded_on_rebalance_date[traded_on_rebalance_date].index
qualifying = big_enough.loc[tradable]
if len(qualifying) < MIN_HOLDINGS:
return None
# nlargest keeps the TOP_N highest yields, and .index gives their tickers
return qualifying.nlargest(TOP_N).indexTwo design choices.
No yield from after the signal date. The signal is the yield on the signal date, the last trading day before the rebalance day, read from the forward-filled panel, which at every date stores the last yield known on that date. A yield first recorded later in the month cannot enter the ranking.
A one-day eligibility test. The traded_on_rebalance_date check reads the rebalance day only. Requiring a return on every day of the month would drop the stocks that delist during the month, which is information from after the rebalance. The one-day check still looks one day ahead, because each month’s returns start at the close of the signal date, when no investor knows which stocks will have a return on the rebalance day.
Step 2. The backtest loop
def get_half_spread(stock, rebalance_date):
"""The half-spread to charge on one stock's trade on the rebalance day.
Uses DEFAULT_HALF_SPREAD when no quote was recorded that day, rather
than to a stale spread from an earlier and possibly very different market.
"""
stock_is_quoted = stock in wide_hs_daily.columns
date_is_quoted = rebalance_date in wide_hs_daily.index
if stock_is_quoted and date_is_quoted:
half_spread = wide_hs_daily.at[rebalance_date, stock] # .at reads one cell
if pd.notna(half_spread):
return float(half_spread)
return DEFAULT_HALF_SPREAD
def run_backtest():
"""One row per trading day, with the portfolio's gross and net return."""
rebalance_months = sorted(daily_months.unique())
daily_records = []
prev_holdings = set()
prev_weights = {} # weights at the end of last month, after a month of returns
for month in rebalance_months:
# Every trading day of this month. Its first row is the rebalance day.
month_panel = daily_wide.loc[daily_months == month]
if month_panel.empty:
continue
rebalance_date = month_panel.index[0]
# searchsorted says where rebalance_date would slot into the sorted dates, which
# is the position of the first date on or after it. One row earlier is the last
# date strictly before, so its yield was already known on the rebalance date.
insert_position = wide_yield_daily.index.searchsorted(rebalance_date)
signal_position = insert_position - 1
if signal_position < 0:
continue # no earlier date to take a signal from
signal_date = wide_yield_daily.index[signal_position]
new_holdings = pick_top_n(signal_date, rebalance_date)
if new_holdings is None:
continue # too few qualify: no return for this month
number_held = len(new_holdings)
equal_weight = 1.0 / number_held
# Rebalancing costs money on every name that has to trade, including the names
# that stay in the portfolio. I trim a survivor whose weight is above its target and
# top up one whose weight is below, and both trades pay the spread and the commission.
# The `|` is set union: every stock in either portfolio.
new_holdings_set = set(new_holdings)
stocks_to_trade = prev_holdings | new_holdings_set
rebalance_cost = 0.0
for stock in stocks_to_trade:
# A stock that was not held last month starts from a weight of zero
current_weight = prev_weights.get(stock, 0.0)
if stock in new_holdings_set:
target_weight = equal_weight
else:
target_weight = 0.0 # a stock leaving is sold in full
trade_size = abs(target_weight - current_weight)
if trade_size > 0:
cost_per_unit = get_half_spread(stock, rebalance_date) + COMMISSION
rebalance_cost += trade_size * cost_per_unit
# A missing day must not break the wealth path. fillna(0.0) treats it as a zero
# return, which holds the position at the value it last reached.
held_returns = month_panel[new_holdings].fillna(0.0)
# One wealth path per stock: the same dollar into each on the first day of the
# month, compounded down the rows. Compounding increases a winner's share of
# the portfolio through the month instead of holding it at 1/n.
wealth_per_stock = (1 + held_returns).cumprod(axis=0)
# Averaging the wealth paths gives the portfolio's own wealth path, because every
# stock started from the same dollar. Averaging the raw daily returns instead
# would reset the weights to 1/n every day, which is a daily rebalance.
portfolio_nav = wealth_per_stock.mean(axis=1)
daily_portfolio_return = portfolio_nav.pct_change()
# pct_change leaves the first day missing, having no earlier day to compare it
# with. That day's return is its NAV measured against the dollar that went in.
daily_portfolio_return.iloc[0] = portfolio_nav.iloc[0] - 1.0
daily_portfolio_return = daily_portfolio_return.dropna()
# The whole rebalance is paid on the day it happens, so only the first day of
# the month is charged the cost.
first_day = daily_portfolio_return.index[0]
for date, gross_return in daily_portfolio_return.items():
if date == first_day:
net_return = gross_return - rebalance_cost
else:
net_return = gross_return
daily_records.append({'Date': date, 'gross': gross_return, 'net': net_return})
# The end-of-month weights are what the next rebalance trades away from: each stock's
# share of the portfolio's wealth on the last day of the month.
end_of_month_wealth = wealth_per_stock.iloc[-1]
total_wealth = float(end_of_month_wealth.sum())
if total_wealth > 0:
prev_weights = (end_of_month_wealth / total_wealth).to_dict()
else:
prev_weights = {} # nothing left to pass to the next month
prev_holdings = new_holdings_set
backtest = pd.DataFrame(daily_records)
backtest = backtest.set_index('Date').sort_index()
backtest['navs_gross'] = (1 + backtest['gross']).cumprod()
backtest['navs_net'] = (1 + backtest['net']).cumprod()
return backtest
bt_daily = run_backtest()Three design choices.
A monthly rebalance. Averaging the compounded wealth paths gives the net asset value (NAV) of a portfolio set to 1/N on the first day and not rebalanced within the month. Averaging the daily returns instead would reset the weights to 1/N every day, which is a daily rebalance.
Costs on the survivors. Returns increase the weights of winners above 1/N and decrease those of losers below it during the month, and trimming them back costs money on every survivor as well as on the names entering or leaving. If 5 of the 30 names change in a month, that is 25 survivor trades on top of 5 exits and 5 entries.
Missing days within the month. fillna(0.0) holds a stock at its last observed value through the end of the month. If a stock crashes on its last trading day before delisting, LSEG records the crash in the total return and the backtest includes the loss. If a stock halts without a final trading day, the panel never records the loss after the halt, so the backtest misses it and overstates the return.
A stripped-down version of this loop is in Lesson 34 of Practical Coding, and Lesson 31 covers how turnover is charged.
Step 3. Benchmark and chart
I proxy the S&P 500 with SPY total return, aligned to the portfolio’s trading days. I download SPY from Yahoo Finance, annualise the stats, and use the rolling 5-year geometric premium for the bottom panel.
import yfinance as yf
from matplotlib.lines import Line2D
from matplotlib.ticker import LogLocator
from matplotlib.ticker import FuncFormatter
# SPY benchmark
# A few days of slack on each side, so the download certainly covers every day the
# portfolio was invested. The bounds go in as plain strings such as 2011-01-03.
download_start = bt_daily.index.min() - pd.Timedelta(days=10)
download_end = bt_daily.index.max() + pd.Timedelta(days=5)
spy = yf.download('SPY',
start=str(download_start.date()),
end=str(download_end.date()),
auto_adjust=True, progress=False)
# yfinance can return two rows of column headers, the field above the ticker. Keeping
# the first level of each pair leaves ordinary column names such as Close.
if isinstance(spy.columns, pd.MultiIndex):
spy.columns = [column[0] for column in spy.columns]
# auto_adjust=True has already put dividends back into Close, so this is a total return
spy['spy_ret'] = spy['Close'].pct_change()
spy = spy[['spy_ret']].dropna()
# The portfolio and the index have to cover the same trading days before they can
# be compared, so the backtest is cut down to the days the two share.
shared_days = bt_daily.index.intersection(spy.index)
bt_daily = bt_daily.loc[shared_days].copy()
# to_numpy() copies the values across by position. Assigning the Series itself would
# line the two tables up by row label instead.
bt_daily['spy_ret'] = spy.loc[shared_days, 'spy_ret'].to_numpy()
bt_daily['spy_navs'] = (1 + bt_daily['spy_ret']).cumprod()
# Annualised stats
def annualised_stats(nav, daily_returns):
"""Annual return, annual volatility, and the Sharpe ratio with a risk-free rate of zero."""
n_days = len(nav)
# The sample covers n_days / 252 years, so raising the final NAV to the power of
# one over that number gives the geometric average year.
annual_return = nav.iloc[-1] ** (252 / n_days) - 1
# A daily standard deviation grows with the square root of the number of days
annual_vol = daily_returns.std() * np.sqrt(252)
if annual_vol > 0:
sharpe = annual_return / annual_vol
else:
sharpe = np.nan
return annual_return, annual_vol, sharpe
port_annual, port_vol, port_sharpe = annualised_stats(bt_daily['navs_net'],
bt_daily['net'])
spy_annual, spy_vol, spy_sharpe = annualised_stats(bt_daily['spy_navs'],
bt_daily['spy_ret'])
# Rolling 5-year premium (geometric, compounded separately)
ROLL_DAYS = 252 * 5
def compound(window_returns):
"""One window of growth factors multiplied into one: 1.02 and 0.99 give 1.0098."""
return window_returns.prod()
# rolling(ROLL_DAYS) hands each five-year window to compound in turn, and raw=True
# passes the window as a plain numpy array rather than as a Series.
portfolio_growth = (1 + bt_daily['net']).rolling(ROLL_DAYS).apply(compound, raw=True)
spy_growth = (1 + bt_daily['spy_ret']).rolling(ROLL_DAYS).apply(compound, raw=True)
# Five years of growth turned back into a yearly rate
portfolio_cagr = portfolio_growth ** (252 / ROLL_DAYS) - 1
spy_cagr = spy_growth ** (252 / ROLL_DAYS) - 1
# I compound each line first and subtract afterwards, so the premium is the difference
# between two annual rates rather than the compounding of a daily difference.
rolling_premium = ((portfolio_cagr - spy_cagr) * 100).dropna()
# Two-panel chart
DIV_COLOR = '#1F77B4'
SP_COLOR = '#E67E22'
FILL_DIV = '#7FBFDA'
FILL_SP = '#F0B27A'
def as_multiple(value, position):
"""Label a tick 2x rather than 2. FuncFormatter also passes the position, unused here."""
return f'{value:g}x'
# The top panel shows the two wealth paths, the bottom one the rolling premium
fig, (nav_ax, premium_ax) = plt.subplots(2, 1, figsize=(14, 11),
gridspec_kw={'height_ratios': [3, 2]})
fig.subplots_adjust(hspace=0.30)
nav_ax.plot(bt_daily.index, bt_daily['spy_navs'], color=SP_COLOR, lw=2.2)
nav_ax.plot(bt_daily.index, bt_daily['navs_net'], color=DIV_COLOR, lw=2.2)
# Labelling each line at its right-hand end saves the reader a trip to the legend
last_day = bt_daily.index[-1]
spy_final_nav = bt_daily['spy_navs'].iloc[-1]
port_final_nav = bt_daily['navs_net'].iloc[-1]
nav_ax.text(last_day, spy_final_nav, f' {spy_final_nav:.1f}x',
va='center', fontsize=12, fontweight='bold', color=SP_COLOR)
nav_ax.text(last_day, port_final_nav, f' {port_final_nav:.1f}x',
va='center', fontsize=12, fontweight='bold', color=DIV_COLOR)
# On a log scale equal percentage moves take equal space, so the early years of a
# fifteen-year chart stay readable next to the late ones.
nav_ax.set_yscale('log')
nav_ax.set_ylabel('Cumulative Return (log scale)')
nav_ax.set_title(f'Top-{TOP_N} Dividend Yield Portfolio vs S&P 500',
fontsize=15, fontweight='bold')
nav_ax.yaxis.set_major_locator(LogLocator(base=10.0, subs=(1, 2, 3, 5, 7), numticks=20))
nav_ax.yaxis.set_major_formatter(FuncFormatter(as_multiple))
# The plotted lines have no label of their own, so the legend is built from two
# stand-in lines, which lets each label include its own statistics.
spy_label = f'S&P 500 ({spy_annual:+.1%} ann., SR {spy_sharpe:.2f})'
port_label = f'Top-{TOP_N} dividend ({port_annual:+.1%} ann., SR {port_sharpe:.2f})'
spy_legend = Line2D([0], [0], color=SP_COLOR, lw=2.2, label=spy_label)
port_legend = Line2D([0], [0], color=DIV_COLOR, lw=2.2, label=port_label)
nav_ax.legend(handles=[spy_legend, port_legend], loc='upper left')
nav_ax.grid(True, which='both', alpha=0.25)
# Two fills over the same series, one for the days above zero and one for those below
premium_ax.fill_between(rolling_premium.index, 0, rolling_premium.values,
where=rolling_premium.values >= 0, color=FILL_DIV,
label='Dividend portfolio outperforms', interpolate=True)
premium_ax.fill_between(rolling_premium.index, 0, rolling_premium.values,
where=rolling_premium.values < 0, color=FILL_SP,
label='S&P 500 outperforms', interpolate=True)
premium_ax.plot(rolling_premium.index, rolling_premium.values, color='#1a1a1a', lw=1.0)
premium_ax.axhline(y=0, color='#1a1a1a', lw=0.8)
current_premium = rolling_premium.iloc[-1]
if current_premium < 0:
premium_color = SP_COLOR # the index is ahead, so use its colour
else:
premium_color = DIV_COLOR
premium_ax.text(rolling_premium.index[-1], current_premium, f' {current_premium:+.1f}%',
va='center', fontsize=12, fontweight='bold', color=premium_color)
premium_ax.set_ylabel('Annualized Premium (%)')
premium_ax.set_title('Rolling 5-Year Annualized Dividend Premium',
fontsize=13, fontweight='bold')
premium_ax.legend(loc='upper right')
premium_ax.grid(True, axis='y', alpha=0.3)
plt.tight_layout()
plt.show()Results

In the top panel, on a log scale, the blue line is the dividend portfolio after costs and the orange line the S&P 500. A dollar grew to $2.9 in the portfolio and to $7.3 in the index. The ratios labelled SR in the legend, 0.42 and 0.90, are the annual return divided by the annual volatility, with no risk-free rate, the return on cash, subtracted, so they are not Sharpe ratios in the usual sense.
The bottom panel is the portfolio’s annual return minus the S&P 500’s over each rolling five-year window. It is negative in every window, and the most recent value is −8.1%.
This is a simple version of a dividend portfolio: a yield ranking with a size floor, a freshness cap and a yield cap, and no screen on the quality of the payout.
Limitations
Distressed yields above the market-cap floor. A 25% yield on a $1 billion stock is still usually a price-collapse signal. The $500m floor drops the worst, but the portfolio still picks up some of these. A payout-ratio screen would help; this version does not have one.
Sector concentration. A ranking on yield alone can fill the top 30 with one sector in some months, and equal weights across the 30 names do not stop that. I do not measure the sector mix.
Start in February 2012. The LSEG pull has returns from January 2000 but almost no TR.DividendYield values before 2012: every month before February 2012 has fewer than 25 qualifying stocks, and every month from then on has at least 25. The code gives a skipped month no return, in the portfolio and in the aligned S&P 500 series, instead of holding the previous portfolio through it, but no month after the first investment is skipped.
Market impact beyond the quoted spread. The cost model charges the quoted half-spread and a commission. A trade larger than the size at the best quote moves the price further, which this model does not charge. The quoted spread can also differ from the effective spread a trade pays, and Hagströmer and Hübbert (2026) show that conventional trade-quote matching algorithms (Lee–Ready and variants) overstate effective spreads by roughly 8–18%. I do not measure either effect here.
The first day of each month. Each month’s returns include the rebalance day’s own return, from the previous close, so the portfolio is in effect bought at the close of the signal date, the day whose yield ranks it. If the yield on that date uses that day’s closing price, the ranking and the purchase share one close. The eligibility test in Step 1 has the same one-day timing. I do not measure the size of either effect.
Conclusion
The takeaway is that a high dividend yield alone did not pick stocks that beat the market: from 2012 to 2026, the 30 highest-yielding US stocks returned 7.9% a year after costs, against 15.0% for the S&P 500.
Data source
US equity total returns, dividend yields, market caps and bid-ask quotes come from LSEG Workspace (licensed). The SPY series for the S&P 500 benchmark comes from Yahoo Finance.
Disclaimer: simplified, hypothetical backtest with approximate trading costs for discussion purposes only. Not investment advice. Past performance does not guarantee future returns.