Who wins in size and momentum?

Python
Factor Investing
Analysis
Code
If I sort stocks by size and momentum, who wins when I compare returns in excess of the market?
Published

October 29, 2026

If I sort stocks by size and momentum, who wins when I compare returns in excess of the market? The reason I am using size and momentum is that momentum has this famous reputation as an anomaly that performs quite well, but it comes with high turnover, and some of these stocks may be tiny stocks that can come with high trading costs. That is why I am using size to get a broader picture of performance across sizes, because that is also a limitation: all these returns are before trading costs. Looking at size can show whether, even among big companies, you still earn a premium or excess return by focusing on the winners (stocks with the highest past return), since big companies are less likely to incur high trading costs than small companies.

The data

I use Kenneth French’s data: 25 groups sorted by size and momentum, with returns from 12 months to 2 months before, using cutoffs from New York Stock Exchange (NYSE) stocks for both. The groups are re-sorted every month, and for each group I take the value-weighted return in the month after sorting, then compute an annual return for each group. The following animation shows how stocks are assigned to each group.

\[ \begin{aligned} \text{MOM}_{i,t} &= (1 + r_{i,t-12})(1 + r_{i,t-11}) \cdots (1 + r_{i,t-2}) - 1\\[4pt] \text{winners: } & \text{MOM}_{i,t} \text{ above the 80th percentile of NYSE stocks}\\ \text{losers: } & \text{MOM}_{i,t} \text{ below the 20th percentile of NYSE stocks}\\ \text{big: } & \text{size}_{i,t-1} \text{ above the 80th percentile of NYSE stocks}\\ \text{small: } & \text{size}_{i,t-1} \text{ below the 20th percentile of NYSE stocks}\\ & \text{the winners among the big stocks}\\ & \quad \text{are big and winners}\\[4pt] \text{with: } & \text{MOM}_{i,t} = \text{the momentum of stock } i \text{ for month } t\\ & r_{i,t} = \text{the return of stock } i \text{ in month } t\\ & \text{size}_{i,t-1} = \text{the market cap of stock } i\\ & \quad \text{at the end of month } t-1 \end{aligned} \]

How Kenneth French forms the 25 size and momentum groups, on made-up stocks Made-up stocks plotted by their return from 12 months to 2 months before, left to right, and by market cap, bottom to top. Lines at the 20th, 40th, 60th and 80th percentiles of NYSE stocks split them into 5 size groups and 5 momentum groups, which gives 25 groups. The top right group holds the winners among the big stocks. A month later the stocks move and the cutoffs are computed again.
  • An NYSE stock
  • An AMEX or NASDAQ stock
  • A larger dot is a larger stock
  • Winners among the big stocks

Results

For each year from 1927 to 2025, the animation shows how each group performs against the market; at the end, it shows each group’s average yearly excess return and the proportion of years it beat the market.

Kenneth French's 25 size and momentum groups against the market, year by year A 5 by 5 grid of the 25 groups, momentum from losers on the left to winners on the right and size from small at the bottom to big at the top. Each group shows its return in one calendar year minus the market's, from 1927 to 2025, green above 2 points, orange within 2 points and red below minus 2 points. The last step shows each group's average over the 99 years and the share of years it beat the market.
  • Each group: its return in the year minus the market's, in percentage points
  • Beat the market by more than 2 points
  • Within 2 points of the market
  • Trailed the market by more than 2 points
  • No data that year

Winners are indeed winners. Small-cap winners have the highest excess return, but you should not take that as evidence to trade only small caps, because they can also come with high trading costs. But there is still a winner effect for the big ones, too, as the winners among the big stocks beat the market in 60 of the 99 years, by 3.8 points a year on average.

And the losers are indeed losers, as they trail the market on average in every size group except the smallest.

Conclusion

So the conclusion here is that yes, momentum does beat the market. It is not surprising, since it is a factor that the market factor cannot explain, so there is an anomaly in that sense, or an alpha. And it is interesting to see that regardless of the size of the groups, the winners beat the market on average, also among the big companies.

Appendix: the code

import io
import urllib.request
import zipfile
from itertools import takewhile

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

LIBRARY = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/"
YEARS = list(range(1927, 2026))


def download(name):
    with urllib.request.urlopen(LIBRARY + name) as response:
        archive = zipfile.ZipFile(io.BytesIO(response.read()))
    return archive.read(archive.namelist()[0]).decode("latin-1")


def monthly_table(text):
    lines = text.splitlines()
    start = next(number for number, line in enumerate(lines) if line.startswith(","))
    rows = list(takewhile(str.strip, lines[start:]))
    table = pd.read_csv(io.StringIO("\n".join(rows)), index_col=0)
    table.index = pd.PeriodIndex(table.index.astype(str).str.strip(), freq="M")
    table.columns = table.columns.str.strip()
    return table.replace([-99.99, -999], np.nan) / 100


def one_year(monthly):
    complete = monthly.groupby(monthly.index.year).count() == 12
    compounded = (1 + monthly).groupby(monthly.index.year).prod() - 1
    return compounded.where(complete).loc[YEARS]


groups = monthly_table(download("25_Portfolios_ME_Prior_12_2_CSV.zip"))
factors = monthly_table(download("F-F_Research_Data_Factors_CSV.zip"))
market = factors["Mkt-RF"] + factors["RF"]

excess = one_year(groups).sub(one_year(market), axis=0) * 100
average = excess.mean().to_numpy().reshape(5, 5)
share = ((excess > 0).sum() / excess.notna().sum()).to_numpy().reshape(5, 5)
big_winners = excess["BIG HiPRIOR"]
print(f"Winners among the big stocks beat the market in {(big_winners > 0).sum()} "
      f"of {big_winners.notna().sum()} years, "
      f"by {big_winners.mean():.1f} points a year on average")
print("Average a year minus the market, points (rows small to big, columns losers to winners):")
print(pd.DataFrame(average, index=["Small", "2", "3", "4", "Big"],
                   columns=["Losers", "2", "3", "4", "Winners"]).round(1).to_string())

BG, INK = "#FCEFE3", "#1f1f1f"
GREEN, ORANGE, RED = "#2f8f4e", "#d2822b", "#c0392b"
fig, axis = plt.subplots(figsize=(6.4, 6.0))
for size in range(5):
    for momentum in range(5):
        value = average[size, momentum]
        color = GREEN if value > 2 else RED if value < -2 else ORANGE
        axis.add_patch(plt.Rectangle((momentum, size), 0.96, 0.96, color=color))
        axis.text(momentum + 0.48, size + 0.56, f"{value:+.1f}".replace("-", "−"),
                  ha="center", va="center", color="white", fontsize=13, fontweight="bold")
        axis.text(momentum + 0.48, size + 0.3, f"{share[size, momentum]:.0%} of years",
                  ha="center", va="center", color="white", fontsize=8.5)
axis.set_xlim(-0.04, 5)
axis.set_ylim(-0.04, 5)
axis.set_xticks(np.arange(5) + 0.48, ["Losers", "2", "3", "4", "Winners"])
axis.set_yticks(np.arange(5) + 0.48, ["Small", "2", "3", "4", "Big"])
axis.set_xlabel("Momentum: return from 12 to 2 months before", color=INK)
axis.set_ylabel("Size: market cap", color=INK)
axis.set_title("Return a year minus the market, in percentage points, 1927 to 2025",
               color=INK, fontsize=11)
axis.tick_params(length=0, colors=INK)
for side in axis.spines.values():
    side.set_visible(False)
fig.patch.set_facecolor(BG)
axis.set_facecolor(BG)
fig.savefig("size_momentum.png", dpi=200, bbox_inches="tight")
Winners among the big stocks beat the market in 60 of 99 years, by 3.8 points a year on average
Average a year minus the market, points (rows small to big, columns losers to winners):
       Losers    2    3     4  Winners
Small     0.2  6.0  8.9  10.8     11.2
2        -3.4  2.3  4.1   6.3      9.3
3        -4.6  0.9  2.0   3.5      7.6
4        -4.5 -0.6  1.3   3.0      7.5
Big      -3.5 -2.2 -0.8   1.0      3.8