Does following insider buyers beat the S&P 500 after trading costs?
Over the last five years, a portfolio of the 50 stocks whose insiders bought the most, in dollars net of their sales, reset to equal weights every month, returned 10.4 percentage points a year less than the S&P 500. Over the full 2001 to 2026 sample the same portfolio returned 15.1 % a year against 9.1 %. Corporate insiders are officers, directors, and 10 %-plus owners. They can trade legally on everything except material non-public information, and they know better than outside investors when their company’s stock is temporarily mispriced.
Form 4, a filing with the US Securities and Exchange Commission (SEC), requires insiders to report every open-market trade, a purchase or sale on the exchange, within two business days. We can read the feed in near real time on OpenInsider, Finviz, and EDGAR. The question is whether we can build a portfolio that follows it and beats the index after estimated trading costs.
Lakonishok and Lee (2001) show that insider purchases predict returns, especially in small firms, and report that a newsletter recommending stocks on the basis of insider trades returned 16.0 % a year against 18.4 % for the S&P 500 over 1985 to 1997. Klein, Maug, and Schneider (2017) find most insider trades are routine portfolio adjustments with no information content. Cohen, Malloy, and Pomorski (2012), covered in an earlier post, identify the subset of insiders whose trades are informative.
I run the backtest from 2001 to 2026 and charge estimated trading costs on every rebalance trade.
The strategy
- All US open-market insider buys and sells on NYSE and Nasdaq. I exclude option exercises, grants, gifts, and tax-withholding trades, which are mechanical.
- Signal at month
M= net dollar value of insider trades over[M-14, M-3]. The 2-month skip helps avoid using a trade before its filing was public, when filings are late or amended. - Top 50 stocks by signal, require signal > 0 (net dollar buyer).
- Equal weight, monthly rebalance, no rebalancing within the month.
- Costs: an estimate from quotes of the half-spread (half the gap between the price to buy and the price to sell) + 0.05 % commission on every rebalance trade, entries, exits, and the trim/top-up trades on survivors whose weights changed during the month.
- Benchmark: S&P 500, proxied by the total return of SPY, a fund that tracks the index, dividends included.
The signal ranks on dollar value. A 1,000-share trade at $1 is not the same signal as 1,000 shares at $500. Ranking on shares gives penny-stock trades the same weight as mega-cap insider purchases.
Starting point
The backtest runs off three in-memory objects, built in a separate job from LSEG Workspace. Because the data is licensed, the raw file and the cleaning logic stay off this page. You can rebuild the insider-trade feed from LSEG with this download script, or from OpenInsider, Finviz, or EDGAR. The daily return panel and the quote-based spread proxy need a market-data source such as WRDS or LSEG.
trades, one row per open-market insider trade:Instrument,TransactionDate,Direction('Buy'/'Sell'),SharesTraded,TransactionPrice.daily_wide,Date × Instrumentpanel of daily total returns (decimals).wide_hs,Instrument × monthpanel of the first observed daily half-spread in each month, wherehalf_spread = (Ask - Bid) / (2·Ask). The first-of-month value is a simple proxy for the spread paid at rebalance.
Parameters
The constants used throughout. DEFAULT_HS is the half-spread, 20 basis points (bps), charged on a stock with no quote in the month.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
TOP_N = 50 # portfolio size
SIGNAL_LOOKBACK = 12 # months in the signal
SIGNAL_SKIP = 2 # skip M-1 and M-2 (look-ahead from late filings)
COMMISSION = 0.0005 # 0.05 % one-way
DEFAULT_HS = 0.002 # 20 bps fallback half-spread
# A year holds about 252 trading days. Scaling a daily return or a daily standard
# deviation up to a year needs that count.
TRADING_DAYS_PER_YEAR = 252Step 1. The signal
Sign each trade (+ buy, − sell), multiply by price, aggregate per (stock, month), take a rolling 12-month sum shifted three months back.
# errors='coerce' turns an unreadable price into a missing value instead of stopping the run
trades['TransactionPrice'] = pd.to_numeric(trades['TransactionPrice'], errors='coerce')
# A trade with no price has no dollar value, so it cannot enter the signal at all.
# copy(): the columns added below then write into a table of their own.
trades = trades.dropna(subset=['TransactionPrice']).copy()
trades['trade_value'] = trades['SharesTraded'] * trades['TransactionPrice']
# Anything not marked 'Buy' counts as a sale and enters the sum with a minus sign
is_buy = trades['Direction'] == 'Buy'
trades['signed_value'] = np.where(is_buy, trades['trade_value'], -trades['trade_value'])
# A Period names the month, so every day of March 2007 groups together as 2007-03
trades['ym'] = trades['TransactionDate'].dt.to_period('M')
# One row per stock and month, holding everything that firm's insiders traded, netted
monthly_net = trades.groupby(['Instrument', 'ym'])['signed_value'].sum().reset_index()
monthly_net = monthly_net.rename(columns={'signed_value': 'net_value'})
# pivot lays the same numbers out as a grid: one row per stock, one column per month
wide_net = monthly_net.pivot(index='Instrument', columns='ym', values='net_value')
# A stock with no insider trade in a month has no row in monthly_net and appears in the
# pivot as a blank cell. Zero is the right value there, because no trading is a net flow
# of nothing, and a rolling sum that requires twelve values would otherwise return a missing value for the whole window.
wide_net = wide_net.fillna(0)
# The rolling window has to run forwards through the calendar, so this line puts the month
# columns in calendar order explicitly.
wide_net = wide_net.reindex(sorted(wide_net.columns), axis=1)
# rolling() always works down the rows, so the months are moved onto the rows for the sum
# and the result is turned back on its side afterwards.
net_by_month = wide_net.T
# min_periods: all twelve months have to be inside the panel, so the first eleven months
# of the sample get no signal at all.
rolling_sum = net_by_month.rolling(window=SIGNAL_LOOKBACK,
min_periods=SIGNAL_LOOKBACK).sum()
rolled = rolling_sum.T
# Move the signal along the months. With SIGNAL_SKIP = 2 the shift is three columns, so
# signal[M] is the sum over [M-14, M-3]. axis=1 shifts across the columns, which are the months.
signal_wide = rolled.shift(SIGNAL_SKIP + 1, axis=1)The .shift(SIGNAL_SKIP + 1) does the lagging. rolled[M] is the trailing 12-month sum through M, which contains trades dated in M itself, and an insider can still file or amend those trades after the month ends. Shifting three months back, excluding M, M-1, M-2, gives a sum over [M-14, M-3], which is fully lagged relative to the M rebalance.
Step 2. The backtest loop
Equal-weight monthly rebalance with three kinds of turnover every month: exits, entries, and the trim/top-up on survivors whose weights changed. The loop charges for all three.
# One month label per row of the daily panel, so the loop can pick out a single month
daily_months = daily_wide.index.to_period('M')
def half_spread_at_rebalance(stock, month):
"""Half-spread at rebalance. `wide_hs` stores the first observed daily
half-spread per (stock, calendar month), so this returns the spread
quoted on the stock's first trading day of that month."""
if stock in wide_hs.index and month in wide_hs.columns:
# .at reads one cell, given its row label and its column label
spread = wide_hs.at[stock, month]
if pd.notna(spread):
return float(spread)
# The quote panel has nothing for this stock this month, so the function returns the fallback half-spread
return DEFAULT_HS
def run_backtest(signal_wide):
# A month can be traded only once some stock has a signal, which rules out the start
# of the sample, where no twelve-month window is complete yet.
rebalance_months = [month for month in signal_wide.columns
if signal_wide[month].notna().any()]
daily_records = []
previous_holdings = set()
previous_weights = {} # what last month's weights drifted to
for month in rebalance_months:
month_signal = signal_wide[month].dropna()
net_buyers = month_signal[month_signal > 0] # net dollar buyers only
if net_buyers.empty:
continue
# Comparing the month labels to this month gives a True or False for every row of
# the daily panel, which selects the days belonging to the month.
month_panel = daily_wide.loc[daily_months == month]
if month_panel.empty:
continue
# Eligibility: day-1 return must exist. Checking .notna().all()
# across the whole month is look-ahead, a delisting with NaN
# returns after day N would be retroactively dropped, even
# though live we would have held it on day 1.
# A stock can have a signal and still have no column in the return panel, and
# asking for a column that is not there raises.
in_panel = [stock for stock in net_buyers.index
if stock in month_panel.columns]
has_day_one_return = month_panel[in_panel].iloc[0].notna()
# Indexing a True or False Series with itself keeps the True entries, and their
# labels are the stocks that can be bought on day 1.
eligible = has_day_one_return[has_day_one_return].index
eligible_signal = net_buyers.loc[eligible]
if eligible_signal.empty:
continue
held_stocks = eligible_signal.nlargest(TOP_N).index
n_held = len(held_stocks)
equal_weight = 1.0 / n_held
# Rebalance cost, survivor-aware.
# Each stock in the previous or the new portfolio trades from
# the weight it drifted to during the month to its new target.
# Exits trade down to 0. Entries trade from 0 up to 1/n.
# Survivors trade from their drifted weight back to 1/n.
rebalance_cost = 0.0
for stock in previous_holdings | set(held_stocks): # in either portfolio, or both
current_weight = previous_weights.get(stock, 0.0) # 0.0 if it was not held
if stock in held_stocks:
target_weight = equal_weight
else:
target_weight = 0.0
trade_size = abs(target_weight - current_weight)
if trade_size > 0:
cost_rate = half_spread_at_rebalance(stock, month) + COMMISSION
rebalance_cost += trade_size * cost_rate
# Monthly rebalance with intra-month drift.
# Weights set to 1/n on day 1, drift with each stock's
# cumulative return. The quick version held_returns.mean(axis=1)
# is equivalent to resetting to 1/n every day, a daily
# rebalance.
# NaN handling: mid-month missing returns are filled with 0,
# which holds the stock at its last observed NAV. Whether that
# captures a delisting loss depends on whether the return field
# records the final-day write-down.
held_returns = month_panel[held_stocks].fillna(0.0)
# cumprod down the days: what one dollar in each stock is worth on each day
wealth_per_stock = (1 + held_returns).cumprod(axis=0)
# The mean across the stocks is the NAV of a portfolio that put the same dollar
# into each of them on day 1 and left the weights alone afterwards.
portfolio_nav = wealth_per_stock.mean(axis=1)
daily_portfolio_return = portfolio_nav.pct_change()
# pct_change has no earlier day to compare day 1 against. The portfolio started
# the month at 1.0, so its first return is the day-1 NAV minus one.
daily_portfolio_return.iloc[0] = portfolio_nav.iloc[0] - 1.0
daily_portfolio_return = daily_portfolio_return.dropna()
# The whole month's trading cost is charged on the day the trades happen
rebalance_day = daily_portfolio_return.index[0]
for date, gross_return in daily_portfolio_return.items():
if date == rebalance_day:
net_return = gross_return - rebalance_cost
else:
net_return = gross_return
daily_records.append({'Date': date, 'gross': gross_return, 'net': net_return})
# Pass the end-of-month weights to the next month.
final_wealth = wealth_per_stock.iloc[-1]
total_wealth = float(final_wealth.sum())
if total_wealth > 0:
previous_weights = (final_wealth / total_wealth).to_dict()
else:
previous_weights = {} # nothing left to trim or top up
previous_holdings = set(held_stocks)
# daily_records is a list holding one small dictionary per day, which DataFrame
# turns into a table whose columns are the dictionary keys.
backtest = pd.DataFrame(daily_records)
backtest = backtest.set_index('Date')
# cumprod below multiplies down the rows, so the days have to be in order first
backtest = backtest.sort_index()
backtest['navs_gross'] = (1 + backtest['gross']).cumprod()
backtest['navs_net'] = (1 + backtest['net']).cumprod()
return backtest
backtest_daily = run_backtest(signal_wide)Four design choices.
Causal eligibility. iloc[0].notna() uses day 1 only, the information set at rebalance. .notna().all() across the month is look-ahead: it drops stocks that delist mid-month.
True monthly rebalance. wealth_per_stock.mean(axis=1) on the compounded wealth path gives the NAV of a portfolio set to 1/n on day 1 and not rebalanced. Taking .mean(axis=1) on the raw daily returns is mathematically identical to resetting to 1/n every day, which is a daily rebalance. This backtest rebalances monthly.
Survivor-aware costs. During the month the weight on a stock that gained is above 1/N and the weight on a stock that lost is below 1/N. Trimming them back costs money. Ignoring that understates trading costs.
Missing values within a month. fillna(0) holds a stock at its last observed NAV through the end of the month. If the stock posts a crash on its last trading day before delisting, the backtest captures the loss when the total return field records that final-day write-down. If the stock halts without a final trading day, the residual is not reflected and fillna(0) holds the position at pre-halt NAV until the next rebalance.
Two of the steps used above have plain versions in Practical Coding: lesson 22 on aggregating per ticker, and lesson 34 on wrapping a backtest into a single function.
Step 3. Benchmark
I proxy the S&P 500 with SPY’s total return, aligned to the portfolio’s trading days and downloaded with yfinance, so this step runs without a licensed feed. SPY is the oldest S&P 500 ETF, and its auto-adjusted close includes reinvested dividends. The download starts ten days early because pct_change needs a close from before the first portfolio day. Without that start, the first portfolio day would have no SPY return and would drop out of the sample.
import yfinance as yf
# The download starts ten days early, so that SPY has an earlier close to compare against
# on the portfolio's first day, and ends five days late so the last day is included.
download_start = (backtest_daily.index.min() - pd.Timedelta(days=10)).date()
download_end = (backtest_daily.index.max() + pd.Timedelta(days=5)).date()
spy = yf.download('SPY', start=str(download_start), end=str(download_end),
auto_adjust=True, progress=False)
# yfinance can label each column with two levels, the field and the ticker, so that the
# closing price arrives as ('Close', 'SPY'). Only the field name is wanted here.
FIELD_LEVEL = 0
if isinstance(spy.columns, pd.MultiIndex):
spy.columns = [column[FIELD_LEVEL] for column in spy.columns]
spy['spy_ret'] = spy['Close'].pct_change()
spy = spy[['spy_ret']].dropna() # the first row has no previous close
# The two series need not cover the same days, so both are cut to the days they share
common = backtest_daily.index.intersection(spy.index)
backtest_daily = backtest_daily.loc[common].copy()
# to_numpy() copies the values across by position. Assigning the Series itself would align
# the two tables by row label instead.
backtest_daily['spy_ret'] = spy.loc[common, 'spy_ret'].to_numpy()
backtest_daily['spy_navs'] = (1 + backtest_daily['spy_ret']).cumprod()
def annual_stats(nav_path, daily_returns):
"""Annual return, annual volatility, and the return-to-volatility ratio.
No risk-free rate is subtracted, so it is not a Sharpe ratio."""
n_days = len(nav_path)
# The last NAV is the growth over the whole sample. TRADING_DAYS_PER_YEAR / n_days is
# one divided by the number of years, so this power turns it into a rate per year.
annual_return = nav_path.iloc[-1] ** (TRADING_DAYS_PER_YEAR / n_days) - 1
# A daily standard deviation grows with the square root of the number of days
annual_vol = daily_returns.std() * np.sqrt(TRADING_DAYS_PER_YEAR)
if annual_vol > 0:
sharpe = annual_return / annual_vol
else:
sharpe = np.nan # no volatility, nothing to divide by
return annual_return, annual_vol, sharpe
port_ann_return, port_ann_vol, port_sharpe = annual_stats(
backtest_daily['navs_net'], backtest_daily['net'])
spy_ann_return, spy_ann_vol, spy_sharpe = annual_stats(
backtest_daily['spy_navs'], backtest_daily['spy_ret'])Step 5. Chart
Two panels. Top: log-scale cumulative NAV. Bottom: rolling 5-year premium.
from matplotlib.lines import Line2D
from matplotlib.ticker import LogLocator
from matplotlib.ticker import FuncFormatter
INSIDER_COLOR = '#1F77B4'
SP_COLOR = '#E67E22'
INSIDER_FILL = '#7FBFDA'
SP_FILL = '#F0B27A'
OUTLINE = '#1a1a1a'
def as_multiple(value, position):
"""An axis tick of 3.0 becomes '3x'. FuncFormatter always passes the
position of the tick as a second argument, which this one ignores."""
return f'{value:g}x'
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 11),
gridspec_kw={'height_ratios': [3, 2]})
fig.subplots_adjust(hspace=0.30)
# Top panel: cumulative NAV, log scale
ax1.plot(backtest_daily.index, backtest_daily['spy_navs'], color=SP_COLOR, lw=2.2)
ax1.plot(backtest_daily.index, backtest_daily['navs_net'], color=INSIDER_COLOR, lw=2.2)
# Each line is labelled at its right-hand end with what one dollar grew to
last_day = backtest_daily.index[-1]
spy_final = backtest_daily['spy_navs'].iloc[-1]
port_final = backtest_daily['navs_net'].iloc[-1]
ax1.text(last_day, spy_final, f' {spy_final:.1f}x',
va='center', fontsize=12, fontweight='bold', color=SP_COLOR)
ax1.text(last_day, port_final, f' {port_final:.1f}x',
va='center', fontsize=12, fontweight='bold', color=INSIDER_COLOR)
ax1.set_yscale('log')
ax1.set_ylabel('Cumulative Return (log scale)')
ax1.set_title('Insider portfolio vs S&P 500', fontsize=15, fontweight='bold')
ax1.yaxis.set_major_locator(LogLocator(base=10.0, subs=(1, 2, 3, 5, 7), numticks=20))
ax1.yaxis.set_major_formatter(FuncFormatter(as_multiple))
# These two lines are never added to the axes, so they draw nothing and exist only to
# hold a colour and a label, with the numbers from Step 3, for the legend.
spy_label = f'S&P 500 ({spy_ann_return:+.1%} ann., return/vol {spy_sharpe:.2f})'
port_label = f'Top-{TOP_N} insider ({port_ann_return:+.1%} ann., return/vol {port_sharpe:.2f})'
ax1.legend(handles=[
Line2D([0], [0], color=SP_COLOR, lw=2.2, label=spy_label),
Line2D([0], [0], color=INSIDER_COLOR, lw=2.2, label=port_label),
], loc='upper left')
ax1.grid(True, which='both', alpha=0.25)
# Bottom panel: rolling 5-year premium
premium_dates = rolling_premium.index
premium_values = rolling_premium.to_numpy()
# where= shades only the stretches that meet the condition, and interpolate=True cuts the
# shading at the crossing point instead of at the next day.
ax2.fill_between(premium_dates, 0, premium_values,
where=premium_values >= 0, color=INSIDER_FILL,
label='Insider outperforms', interpolate=True)
ax2.fill_between(premium_dates, 0, premium_values,
where=premium_values < 0, color=SP_FILL,
label='S&P 500 outperforms', interpolate=True)
ax2.plot(premium_dates, premium_values, color=OUTLINE, lw=1.0)
ax2.axhline(y=0, color=OUTLINE, lw=0.8)
latest_premium = rolling_premium.iloc[-1]
if latest_premium < 0:
label_color = SP_COLOR
else:
label_color = INSIDER_COLOR
ax2.text(premium_dates[-1], latest_premium, f' {latest_premium:+.1f}%',
va='center', fontsize=12, fontweight='bold', color=label_color)
ax2.set_ylabel('Annualised premium (%)')
ax2.set_title('Rolling 5-year annualised insider premium',
fontsize=13, fontweight='bold')
ax2.legend(loc='upper right')
ax2.grid(True, axis='y', alpha=0.3)
plt.tight_layout()
plt.show()Results

Over the full sample, the insider portfolio returned 15.1 % a year. The S&P 500 returned 9.1 %. The return-to-volatility ratios, the annual return divided by the typical size of the yearly swings, were 0.57 for the portfolio and 0.48 for the S&P 500. A dollar compounded to $33.6 in the portfolio, and to $8.8 in the index.
The rolling 5-year premium had a local peak above 11 % in 2021 and equals −10.4 % on the last day of the sample, after about four years below zero.
Three possible explanations for the recent underperformance.
Signal decay. The information in a Form 4 may be reflected in prices sooner than it was earlier in the sample. This backtest does not measure how quickly prices respond to a filing.
Regime. Insider-heavy portfolios hold smaller and cheaper stocks than the index, and small and value stocks have had lower returns than mega-cap indices since 2022. I do not measure the size or value exposure of this portfolio.
Noise. The rolling 5-year premium is negative for several earlier stretches of the chart and is positive again afterwards.
Signal decay, regime and noise all remain possible after this backtest. The backtest applies no filter on who the insider is or on whether the trade is routine, so it treats a founder’s opportunistic buy after bad earnings the same as a director’s routine annual top-up. Cohen, Malloy, and Pomorski (2012) argue that only the opportunistic trades are informed, and the classifier is in the previous post.
Limitations
Market impact beyond the quoted spread. The cost model charges the half-spread and a commission. Real execution shows slippage past top-of-book, especially in the small-caps where this strategy concentrates.
Simultaneous execution at a single price. The backtest assumes all rebalance trades, exits, entries, and survivor trims, fill at the same reference price at the moment of rebalance. In practice, we have to sequence the trades, prices move between fills, and spreads in the opening and closing auctions are wider than intraday spreads. This is additional slippage on top of the half-spread.
Half-spread measurement itself is noisy. Hagströmer and Hübbert (2026) show conventional trade-quote matching algorithms (Lee–Ready and variants) overstate effective spreads by roughly 8–18 %. A first-day quote as a monthly proxy avoids that problem, because it relies on no matching rule, but it is one observation per stock per month, which is its own kind of noise.
Missing values within a month. As Step 2 explains, a stock that halts and delists without a final trading day is held at its pre-halt NAV until the next rebalance, so its final write-down is missed. CRSP’s DLRET books the final write-down explicitly; my LSEG pull relies on whatever the feed recorded.
Universe scope. The universe is whatever the LSEG insider pull returns, active and delisted US names over the window. Lakonishok and Lee cover NYSE, AMEX and Nasdaq. This universe covers NYSE and Nasdaq, so it excludes AMEX names.
No role filter. I keep every Form 4 filer: officers, directors, and 10 %-plus beneficial owners. Lakonishok and Lee find beneficial-owner trades predict returns less strongly than officer and director trades. I do not test a version that excludes beneficial owners.
Conclusion
The takeaway is that following insider buyers beat the S&P 500 after costs over 2001 to 2026, 15.1% a year against 9.1%, but trailed it by 10.4 percentage points a year over the last five years, so a long record does not show that the edge is still there.
References
Cohen, L., Malloy, C., and Pomorski, L. (2012). Decoding Inside Information. Journal of Finance 67(3): 1009–1043.
Hagströmer, B., and Hübbert, A. (2026). Bias in Execution Cost Measures. Working paper, Stockholm Business School.
Klein, O., Maug, E., and Schneider, C. (2017). Trading strategies of corporate insiders. Journal of Financial Markets 34: 48–68.
Lakonishok, J., and Lee, I. (2001). Are Insider Trades Informative? Review of Financial Studies 14(1): 79–111.
Disclaimer: simplified, hypothetical backtest with approximate trading costs for discussion purposes only. Not investment advice. Past performance does not guarantee future returns.