All notes

Is the accruals anomaly dead? Testing Sloan thirty years on

9 min read

The low-minus-high accrual spread paid 0.38% a month before Sloan was published and 0.08% since Green, Hand and Soliman declared it finished. The decade panel does not fit the explanation either paper gives.

Preregistration — fixed before any data was loaded67 lines
# Frozen before any data is loaded. Every cell below reads from PREREG,
# so no value is typed twice and the plan cannot drift from the code.
#
# Provenance of every parameter.
#
# From Sloan (1996), The Accounting Review 71(3), 289-315, read out of the
# JSTOR scan:
#   - the sort variable: accruals, scaled by average total assets
#   - ten portfolios, low accrual minus high accrual as the hedge portfolio
#   - his sample is Compustat NYSE and AMEX firm-years, 1962 through 1991
#   - returns are measured starting four months after fiscal year end
#
# From Green, Hand & Soliman (2011), Management Science 57(5), 797-816,
# abstract read on the INFORMS page:
#   - the claim under test: the hedge returns to Sloan's accruals anomaly
#     have decayed "to the point that they are, on average, no longer
#     reliably positive"
#
# Mine, not theirs, and named as mine:
#   - the three period boundaries. Both are publication dates, which are
#     facts, rather than either paper's sample end, which would put the
#     boundary at a date the market could not have known.
#   - cost_bps, hac_lags, and the decision rule in "success".
#
# French's AC/B is not Sloan's accrual measure. His is the change in
# operating working capital per split-adjusted share from t-2 to t-1 over
# book equity per share at t-1; Sloan's is balance-sheet accruals over
# average total assets. Decay is therefore measured within this one series
# across time and never against Sloan's printed hedge return.

PREREG = {
    "hypothesis": (
        "The low-minus-high accrual decile spread on Ken French's "
        "accrual-sorted portfolios earned a positive average monthly return "
        "before Sloan was published, and its average in the period after "
        "Green, Hand and Soliman declared the anomaly gone is lower and no "
        "longer reliably different from zero."
    ),
    "universe": (
        "Ken French Portfolios_Formed_on_AC, value-weighted deciles built "
        "from NYSE, AMEX and NASDAQ common stocks on CRSP, formed each June "
        "on AC/B. Equal-weighted deciles are the preregistered robustness "
        "cut, because Sloan's result was equal-weighted. Built from CRSP, so "
        "no survivorship bias enters through universe selection."
    ),
    "start":     "1963-07-31",
    "end":       "2026-06-30",
    "holdout":   "2011-05-31 onwards, touched once in the last cell of Results",
    "rebalance": "annual, French forms the portfolios at the end of June",
    "params": {
        "n_deciles":        10,
        "pre_pub_end":      "1996-06-30",   # month before Sloan, TAR 71(3), July 1996
        "interim_end":      "2011-04-30",   # month before Green/Hand/Soliman, MS 57(5), May 2011
        "cost_bps":         20,
        "hac_lags":         12,
        "min_months":       60,
    },
    "success": (
        "Both must hold for the decay claim to replicate. (a) Over "
        "1963-07 to 1996-06 the value-weighted low-minus-high spread has a "
        "positive mean monthly return with |t| > 2 under Newey-West standard "
        "errors, net of 20bp round-trip costs. (b) In the holdout the net "
        "mean is below the pre-publication mean and its |t| is under 2. "
        "If (a) fails, the note reports that the anomaly was never present "
        "in this series and the decay question does not arise."
    ),
}
Setup and imports22 lines
import sys
import warnings

# Library import notices are not findings, and they would otherwise be
# published as output. Silenced before the imports that raise them.
warnings.filterwarnings("ignore")

import numpy as np
import pandas as pd
import statsmodels.api as sm
import matplotlib.pyplot as plt

sys.path.append("..")
from lib import style, data

style.use()
SEED = 0
np.random.seed(SEED)

AS_OF = "2026-08-28"          # as-of date of every cached pull in this note
P = PREREG["params"]
DECILES = ["Lo 10"] + [f"Dec {i}" for i in range(2, 10)] + ["Hi 10"]

Hypothesis

Firms whose earnings lean on accruals rather than cash go on to disappoint, and a portfolio long low-accrual stocks and short high-accrual stocks used to be paid for knowing that. It is not paid any more.

Sloan (1996) documented the pattern. Green, Hand and Soliman (2011) reported that it had stopped working, and attributed that to hedge fund capital moving into the trade. This note tests both claims on Ken French's accrual-sorted deciles: that the spread was reliably positive before Sloan was published, and that in the fifteen years since Green, Hand and Soliman it is no longer distinguishable from zero.

The test was fixed before any data was loaded. It is in the first cell, including the period boundaries, which are publication dates rather than either paper's sample end, and the success criterion.

Prior work

Sloan, R. G. (1996). Do stock prices fully reflect information in accruals and cash flows about future earnings? The Accounting Review 71(3), 289-315. The original. He sorts on accruals scaled by average total assets, forms ten portfolios, and measures returns from four months after fiscal year end so the financial statements are public. His sample is Compustat NYSE and AMEX firm-years from 1962 through 1991.

Green, J., Hand, J. R. M. and Soliman, M. T. (2011). Going, going, gone? The apparent demise of the accruals anomaly. Management Science 57(5), 797-816. The claim under test. They report the hedge returns "appear to have decayed in U.S. stock markets to the point that they are, on average, no longer reliably positive", and attribute it partly to hedge fund capital entering the trade.

McLean, R. D. and Pontiff, J. (2016). Does academic research destroy stock return predictability? Journal of Finance 71(1), 5-32. The general version of the same claim across 97 predictors, with an average post-publication decline of 58%. It is the reason the period boundaries here are publication dates: if publication is the mechanism, the boundary belongs where the information became public, not where a sample happened to end.

Data

Ken French's data library, cached on 2026-08-28 under research/data/famafrench/. Two series: the monthly accrual-sorted portfolios and the monthly research factors. All returns are in percent.

French builds the portfolios from CRSP himself, so no survivorship bias enters through universe selection.

Three things a reader should hold against this test.

French's accrual measure is not Sloan's. French sorts on AC/B: the change in operating working capital per split-adjusted share from t-2 to t-1, divided by book equity per share at t-1. Sloan uses balance-sheet accruals, defined as the change in non-cash current assets less the change in current liabilities excluding short-term debt and taxes payable, minus depreciation, all scaled by average total assets. These are related but not the same variable. Every comparison below is therefore made within this one series across time. Sloan's printed hedge return is never used as a benchmark, because a gap against it would measure the difference in construction rather than any change in the world.

The default here is value-weighted, Sloan's result was equal-weighted. Value-weighted is French's default and is the harder test, since an equal-weighted spread leans on small stocks. The equal-weighted version is preregistered and appears in What broke.

Costs are a rough floor. French forms these portfolios once a year at the end of June, so the round trip is charged once a year, in July. Holdings are not published, so within-year drift and the actual cost of trading the tails are not captured.

Code11 lines
dec = data.to_month_end(data.famafrench("Portfolios_Formed_on_AC", AS_OF, table=0))
ff  = data.to_month_end(data.famafrench("F-F_Research_Data_Factors", AS_OF))

m = dec[DECILES].join(ff[["Mkt-RF", "RF", "SMB", "HML"]], how="inner")
m = m.loc[PREREG["start"]:PREREG["end"]]
ex = m[DECILES].sub(m["RF"], axis=0)          # excess returns, percent per month

print(f"deciles  {len(dec):6,} rows  {dec.index.min().date()} -> {dec.index.max().date()}")
print(f"factors  {len(ff):6,} rows  {ff.index.min().date()} -> {ff.index.max().date()}")
print(f"merged   {len(m):6,} rows  {m.index.min().date()} -> {m.index.max().date()}")
print(f"missing values in the merged panel: {int(m.isna().sum().sum())}")
deciles     756 rows  1963-07-31 -> 2026-06-30
factors   1,200 rows  1926-07-31 -> 2026-06-30
merged      756 rows  1963-07-31 -> 2026-06-30
missing values in the merged panel: 0

Method

The strategy is long the lowest-accrual decile and short the highest, held for a year, rebuilt each June when French rebuilds the portfolios. The round trip is charged once a year, in July, at the preregistered 20bp.

Three periods, split at publication dates:

  • before Sloan, 1963-07 to 1996-06
  • between the two papers, 1996-07 to 2011-04
  • the holdout, 2011-05 onward, touched once

Means are tested against zero with Newey-West standard errors at twelve lags. Twelve rather than six because the portfolios are annually rebalanced, so overlapping information persists for about a year.

The decay claim needs both halves. If the spread was never positive in this series, then there is nothing to have decayed, and the note reports that instead.

Code22 lines
def hedge(table=0, cols=None, cost_bps=P["cost_bps"]):
    """Low-accrual minus high-accrual, with the annual round trip charged in
    July, the month after French rebuilds the portfolios."""
    cols = cols or DECILES
    d = data.to_month_end(data.famafrench("Portfolios_Formed_on_AC", AS_OF, table=table))
    mm = d[cols].join(ff[["Mkt-RF", "RF", "SMB", "HML"]], how="inner")
    mm = mm.loc[PREREG["start"]:PREREG["end"]]
    gross = mm[cols[0]] - mm[cols[-1]]
    charge = pd.Series(0.0, index=mm.index)
    charge[mm.index.month == 7] = 2 * cost_bps / 100.0   # out of one leg, into the other
    return gross - charge, mm

def mean_t(r, lags=P["hac_lags"]):
    """Mean and its Newey-West t-statistic against zero."""
    fit = sm.OLS(r.values, np.ones(len(r))).fit(cov_type="HAC", cov_kwds={"maxlags": lags})
    return len(r), r.mean(), float(fit.tvalues[0]), np.sqrt(12) * r.mean() / r.std()

spread, m = hedge()
PERIODS = [
    ("before Sloan        1963-07 to 1996-06", slice(None, P["pre_pub_end"])),
    ("between the papers  1996-07 to 2011-04", slice("1996-07-01", P["interim_end"])),
]

Results

Code9 lines
# Criterion (a): was the spread there before Sloan was published?

print("low-accrual minus high-accrual decile, value-weighted, net of costs\n")
print(f"{'period':40} {'n':>4} {'mean%':>8} {'t':>7} {'Sharpe':>8}")
for label, sl in PERIODS:
    n, mu, t, sr = mean_t(spread.loc[sl])
    print(f"{label:40} {n:>4} {mu:+8.3f} {t:+7.2f} {sr:+8.3f}")
n, mu, t, sr = mean_t(spread)
print(f"{'full sample         1963-07 to 2026-06':40} {n:>4} {mu:+8.3f} {t:+7.2f} {sr:+8.3f}")
low-accrual minus high-accrual decile, value-weighted, net of costs

period                                      n    mean%       t   Sharpe
before Sloan        1963-07 to 1996-06    396   +0.379   +3.14   +0.504
between the papers  1996-07 to 2011-04    178   +0.187   +0.88   +0.197
full sample         1963-07 to 2026-06    756   +0.261   +2.50   +0.310

Criterion (a) is met. Before Sloan was published the spread paid +0.38% a month at t = 3.14, net of costs, with an annualised Sharpe ratio of 0.50.

Between the two papers the mean falls to +0.19% at t = 0.88. That average hides a turn rather than describing a steady decline: the strategy was still paying well for the first eight of those years and flat for the rest. The growth chart below shows where the change actually happened.

Code20 lines
fig, ax = plt.subplots(figsize=(8, 4.4))

pre  = ex.loc[:P["pre_pub_end"]].mean()
hold = ex.loc["2011-05-01":].mean()
x = np.arange(1, 11)

# Demeaned within each period. Raw means are not comparable across periods:
# the holdout is a bull market, which lifts all ten deciles together and would
# hide the only thing being compared, which is the slope.
ax.plot(x, pre - pre.mean(), color=style.ACCENT, lw=1.8, marker="o", ms=4,
        label="Before Sloan, 1963-07 to 1996-06")
ax.plot(x, hold - hold.mean(), color=style.SERIES[1], lw=1.2, marker="o", ms=4,
        label="Holdout, 2011-05 to 2026-06")
ax.axhline(0, color=style.MUTED, lw=0.7)
ax.set_xticks(x)
ax.set_xlabel("Accrual decile, 1 = lowest accruals")
ax.set_ylabel("Mean monthly excess return\nrelative to the period average, %")
ax.set_title("The accrual sort used to slope down. It no longer does.")
ax.legend(loc="lower left")
plt.show()

The accent line falls from left to right, which is the anomaly: low-accrual deciles beat high-accrual deciles by about 0.4% a month across the sort. The grey line is close to flat and its highest point is decile 7, which is where a sort carrying no information would put it.

Code16 lines
fig, ax = plt.subplots(figsize=(8, 4.2))

growth = (1 + spread / 100).cumprod()
ax.plot(growth.index, growth.values, color=style.ACCENT, lw=1.6)

# Linear, not log. The series spans about 6x, and a log axis over that range
# only buys a set of minor tick labels nobody reads.
for date, label, height in [("1996-07-31", "Sloan", 0.94),
                            ("2011-05-31", "Green, Hand and Soliman", 0.10)]:
    ax.axvline(pd.Timestamp(date), color=style.SERIES[1], lw=1.0, ls="--")
    ax.annotate(label, (pd.Timestamp(date), growth.max() * height),
                textcoords="offset points", xytext=(6, 0),
                fontsize=8.5, color=style.MUTED)
ax.set_ylabel("Growth of $1")
ax.set_title("Long low-accrual, short high-accrual, net of costs")
plt.show()

A dollar grows to 3.92 by the month Sloan was published and carries on to 6.27 by January 2004. Over the twenty-two years since, it has ranged between 3.96 and 7.31 and ends at 5.22, below where it stood in 2004.

Neither dashed line marks the turn. The strategy paid well for about eight years after Sloan appeared, and it had already stopped seven years before Green, Hand and Soliman published. Whatever ended it, the timing does not line up with either paper.

Code14 lines
# Criterion (b): the holdout. Evaluated once.

hold_r = spread.loc["2011-05-01":]
n_h, mu_h, t_h, sr_h = mean_t(hold_r)
n_p, mu_p, t_p, sr_p = mean_t(spread.loc[:P["pre_pub_end"]])

print(f"holdout 2011-05 to 2026-06, net of {P['cost_bps']}bp   n {n_h} months")
print(f"  mean    {mu_h:+.3f}% per month")
print(f"  t       {t_h:+.2f}")
print(f"  Sharpe  {sr_h:+.3f}")
print()
print(f"  before Sloan:  mean {mu_p:+.3f}%  t {t_p:+.2f}  Sharpe {sr_p:+.3f}")
print(f"  holdout:       mean {mu_h:+.3f}%  t {t_h:+.2f}  Sharpe {sr_h:+.3f}")
print(f"  the spread retains {100 * mu_h / mu_p:.0f}% of its pre-publication mean")
holdout 2011-05 to 2026-06, net of 20bp   n 182 months
  mean    +0.077% per month
  t       +0.29
  Sharpe  +0.084

  before Sloan:  mean +0.379%  t +3.14  Sharpe +0.504
  holdout:       mean +0.077%  t +0.29  Sharpe +0.084
  the spread retains 20% of its pre-publication mean

Criterion (b) is met. The holdout mean is +0.08% a month at t = 0.29, against +0.38% at t = 3.14 before publication. About a fifth of the original premium remains and it is indistinguishable from zero.

Both halves of the preregistered claim therefore hold on this series. The anomaly was real, and it is gone.

What broke

Every variant tried, reported against both the pre-publication period and the holdout.

Code17 lines
QUINTILES = ["Lo 20", "Qnt 2", "Qnt 3", "Qnt 4", "Hi 20"]
variants = [
    ("preregistered: VW deciles, 20bp", dict()),
    ("equal-weighted deciles",          dict(table=1)),
    ("quintiles instead of deciles",    dict(cols=QUINTILES)),
    ("cost 0bp",                        dict(cost_bps=0)),
    ("cost 100bp",                      dict(cost_bps=100)),
]

print(f"{'variant':34} {'pre n':>6} {'pre%':>7} {'pre t':>6}   "
      f"{'hold n':>6} {'hold%':>7} {'hold t':>7}")
for label, kw in variants:
    r, _ = hedge(**kw)
    a = mean_t(r.loc[:P["pre_pub_end"]])
    b = mean_t(r.loc["2011-05-01":])
    print(f"{label:34} {a[0]:>6} {a[1]:+7.3f} {a[2]:+6.2f}   "
          f"{b[0]:>6} {b[1]:+7.3f} {b[2]:+7.2f}")
variant                             pre n    pre%  pre t   hold n   hold%  hold t
preregistered: VW deciles, 20bp       396  +0.379  +3.14      182  +0.077   +0.29
equal-weighted deciles                396  +0.387  +4.23      182  +0.194   +0.90
quintiles instead of deciles          396  +0.349  +3.49      182  +0.133   +0.72
cost 0bp                              396  +0.413  +3.42      182  +0.110   +0.41
cost 100bp                            396  +0.246  +2.04      182  -0.055   -0.21

The conclusion does not move. Equal-weighting raises the pre-publication t-statistic to 4.23, which is expected since Sloan's own result was equal-weighted and the effect is stronger in small stocks, and its holdout t-statistic is still 0.90. Quintiles weaken it as a coarser sort should. Costs are close to irrelevant on an annually rebalanced portfolio: at 100bp the pre-publication spread still clears t = 2.

Code8 lines
# Subperiod behaviour, preregistered specification.

print("decade      n    mean%       t")
for start in range(1960, 2030, 10):
    s = spread.loc[f"{start}":f"{start + 9}"]
    if len(s) >= 24:
        _, mu, t, _ = mean_t(s)
        print(f"{start}s   {len(s):>3}  {mu:+7.3f}  {t:+6.2f}")
decade      n    mean%       t
1960s    78   +0.668   +2.09
1970s   120   +0.500   +2.43
1980s   120   +0.040   +0.20
1990s   120   +0.420   +2.56
2000s   120   +0.005   +0.02
2010s   120   +0.426   +1.57
2020s    78   -0.276   -0.65
Code25 lines
fig, ax = plt.subplots(figsize=(8, 3.9))

labels, means, counts = [], [], []
for start in range(1960, 2030, 10):
    s = spread.loc[f"{start}":f"{start + 9}"]
    if len(s) >= 24:
        labels.append(f"{start}s")
        means.append(s.mean())
        counts.append(len(s))

# The decades that clear t = 2 on their own are the loud ones. Everything
# else is context, including the positive-but-insignificant bars.
sig = [abs(mean_t(spread.loc[f"{lab[:4]}":f"{int(lab[:4]) + 9}"])[2]) > 2 for lab in labels]
colors = [style.ACCENT if s else style.SERIES[2] for s in sig]

ax.bar(labels, means, color=colors, width=0.62)
ax.axhline(0, color=style.MUTED, lw=0.7)
for i, (v, n) in enumerate(zip(means, counts)):
    ax.annotate(f"n={n}", (i, 0), textcoords="offset points",
                xytext=(0, -16 if v > 0 else 8), ha="center",
                fontsize=7.5, color=style.MUTED)
ax.set_ylabel("Mean monthly return, %")
ax.set_title("Decades reaching t > 2 on their own, in accent")
ax.tick_params(axis="x", pad=14)
plt.show()

This is the result that complicates the paper's explanation. If hedge fund capital arbitraged the anomaly away after Sloan published in 1996, the decade pattern should be a decline. It is not. The 1980s returned +0.04% a month and the 2000s +0.01%, both long before and after publication respectively, while the 1990s returned +0.42% and the 2010s +0.43%. The premium was already absent for a decade in the middle of the pre-publication period.

The three-period split and the decade panel are both true, and they support different stories. The split says the anomaly decayed. The decade panel says it was intermittent throughout and the last fifteen years look like the 1980s looked. Nothing in this note separates a premium that was arbitraged away from one that was never stable.

Verdict

Do not trade it. Both preregistered criteria are met, so Green, Hand and Soliman's claim replicates on French's accrual-sorted deciles.

The spread paid +0.38% a month at t = 3.14 before Sloan was published, and +0.08% at t = 0.29 in the fifteen years after Green, Hand and Soliman declared it finished. About a fifth of the premium is left and it is not distinguishable from zero. The decile sort, which used to slope down cleanly from low accruals to high, is now flat with its peak in decile 7.

What the note does not establish is why. The decade panel shows two near-zero decades inside the period when the anomaly was supposedly working, which is hard to square with publication or with hedge fund capital as the cause. A reader looking for the mechanism will not find it here. A reader asking whether to allocate to it will: on this series there has been nothing to allocate to since the mid-1990s.

References

French, K. R. (2026). Portfolios formed on AC; Fama/French 3 factors. Data library, accessed 2026-08-28. https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html

Green, J., Hand, J. R. M. and Soliman, M. T. (2011). Going, going, gone? The apparent demise of the accruals anomaly. Management Science 57(5), 797-816. https://pubsonline.informs.org/doi/abs/10.1287/mnsc.1110.1320

McLean, R. D. and Pontiff, J. (2016). Does academic research destroy stock return predictability? Journal of Finance 71(1), 5-32. https://onlinelibrary.wiley.com/doi/10.1111/jofi.12365

Sloan, R. G. (1996). Do stock prices fully reflect information in accruals and cash flows about future earnings? The Accounting Review 71(3), 289-315. The publisher's page is not reachable without a subscription. The copy read for this note, and the one every parameter above was taken from, is the scan at https://www.cuhk.edu.hk/acy2/workshop/June2009Wasley/1996TAR).pdf

Code6 lines
print(f"python       {sys.version.split()[0]}")
print(f"pandas       {pd.__version__}")
print(f"numpy        {np.__version__}")
print(f"statsmodels  {sm.__version__}")
print(f"seed         {SEED}")
print(f"data as-of   {AS_OF}  (research/data/famafrench/{AS_OF}/)")
python       3.12.13
pandas       3.0.5
numpy        2.5.2
statsmodels  0.15.0
seed         0
data as-of   2026-08-28  (research/data/famafrench/2026-08-28/)

Research, not investment advice. Every result here is a test on historical data, and nothing in it is a recommendation to trade.