BLOG

Behind the numbers.

Code examples, market analysis, and data quality deep-dives.

Does Post-Earnings Announcement Drift Survive Real Filing Dates? PEAD Event Study in Python
Do Insider Buying Clusters Predict Returns? Signal Testing in Python
Does the S&P 500 Index Effect Still Exist? Event Study in Python
Are Companies Leaving the S&P 500 Faster Than They Used To? Index Survival Analysis in Python
Does Gross Profitability Predict Stock Returns? Quintile Factor Test in Python
What Growth Rate Is the Market Pricing In? Reverse DCF in Python
Does a Strong Balance Sheet Cushion Drawdowns? Leverage and Downside Risk in Python
Does Ticker Recycling Corrupt a Mean-Reversion Backtest? Entity-Resolved Z-Scores in Python
How Much of a Growth Screen's Backtested Edge Is Survivorship Bias? Point-in-Time Index Testing in Python
Why Do Leveraged ETFs Decay? Measuring Volatility Drag in Python
Are Consumer Staples Margins Shrinking Under Inflation? Gross Margin Trend Analysis in Python
Do Weak Jobs Reports Predict Market Drawdowns? NFP Surprise Event Study in Python
Is the Rotation From Tech to Industrials Backed by Earnings? Relative EPS Growth Analysis in Python
Is the Semiconductor Rally Broadening Beyond NVIDIA? Return Dispersion Analysis in Python
Which Stocks Benefit Most When Oil Prices Fall? Oil Beta Screening in Python
Do Bond Returns Predict Stock Returns? Granger Causality Test in Python
Which Stocks Actually Drive Portfolio Returns? Shapley Value Attribution in Python
Does "Sell in May" Still Work? Calendar Anomaly Backtest in Python
How to Build Complete Price History Through Ticker Changes? Entity Resolution in Python
Are KO and PEP Cointegrated? Pairs Trading Signal Construction in Python
Which Commodities Have the Strongest Momentum? Rotation Backtest in Python
Which Commodity ETFs Have the Worst Tail Risk? Expected Shortfall in Python
Are Gold Miners Leveraged Gold Bets? Rolling Beta Analysis in Python
Does the Base-Metals-to-Gold Ratio Lead Cyclical Stocks? Signal Test in Python
Can Risk Parity Tame Commodity Volatility? Portfolio Optimization in Python
Are Power Stocks Becoming an AI Infrastructure Trade? Momentum Screening in Python
Which AI Chip Stocks Have Margin Momentum? Profitability Trend Analysis in Python
Which AI Stocks Are Cheapest Relative to Growth? Growth-Adjusted Valuation in Python
Does AI Stock Leadership Persist? Momentum Backtest in Python
Which AI Stocks Have the Cleanest Balance Sheets? Net Cash Screening in Python
Can Risk Parity Reduce Mega-Cap Drawdowns? Portfolio Optimization in Python
Which Growth Stocks Are Self-Funding? Cash-Flow Quality Screening in Python
Which Sectors Struggle When the Dollar Rallies? Sector Rotation Analysis in Python
Do Cheap Stocks Hold Up When Bonds Sell Off? Valuation Rotation in Python
Does the Nasdaq 100 Have Better Growth Quality Than the Dow? Index Constituent Analysis in Python
Do Healthcare Cash-Flow Margins Predict Returns? Signal Evaluation in Python
Which Dividend Stocks Survive a Cash-Flow Stress Test? Dividend Screening in Python
Does Heavy Insider Selling Predict Weak Returns? Insider Flow Test in Python
Can Quality Screens Reduce Small-Cap Balance-Sheet Risk? Russell 2000 Test in Python
Which Retailers Have Positive Operating Leverage? Margin Screening in Python
Is MSTR a Leveraged Bitcoin Proxy? Rolling Beta Analysis in Python
Is Micron's Memory Cycle Recovering? Inventory and Margin Forecasting in Python
Which Sectors Work When Bonds Rally? Rate-Sensitive Rotation in Python
Do One-Month Price Extremes Reverse? Signal Evaluation in Python
Do Low-Volatility S&P 500 Stocks Reduce Drawdowns? Factor Test in Python
Is AI Capex Paying Back Fast Enough? Revenue Hurdle Forecasting in Python
Could Shorter AI Asset Lives Hit Earnings? Depreciation Stress Test in Python
How Much AI Capex Risk Can a Portfolio Remove? Constrained Optimization in Python
Is the AI Capex Trade Crowded? Rolling Volatility and Sector Rotation in Python
Did the AI Boom Come From Existing S&P 500 Members? Point-in-Time Momentum Test in Python
Is AI Revenue Circular? Customer-Vendor Capex Loop Analysis in Python
Is the AI Trade Connected to Private Credit? Rolling Correlation Network in Python
Is Apollo More Balance-Sheet Sensitive Than Peers? Leverage Screen in Python
Are AI Earnings Supported by Cash Flow? Accrual and Capex Screen in Python
Can Defensive Stocks Hedge AI Drawdowns? Basket Regime Test in Python
How Fast Does the Market Price In Fed Decisions? FOMC Event Study in Python
How Much Are Options Sellers Overpaid? The Variance Risk Premium in Python
Which Companies Have the Worst Earnings Quality? Sloan Accrual Screen with Geographic Revenue Data in Python
Does the Oil-to-Gold Ratio Signal Recessions? XLE/GLD Backtest in Python
Is AI Spending Crowding Out Free Cash Flow? Capex Sustainability Across the Mag 7 in Python
Does a Long Energy / Short Bonds Portfolio Capture Inflation Surprises? Factor Construction in Python
Can a Hidden Markov Model Detect Oil Market Regimes? HMM Analysis in Python
Do Grain Prices Predict Food Inflation? Granger Causality Test in Python
Does the Corporate Credit Spread Predict Stock Market Crashes? BAA-AAA Spread Analysis in Python
Do Oil Stocks Hedge Inflation? Rolling Beta Analysis in Python
Which Stocks Are Most Rate-Sensitive? Equity Duration via Bond Beta in Python
Which Companies Have the Highest Accrual Ratios? Earnings Quality Screening in Python
Is Alpha Persistent or Decaying? Rolling Sharpe Ratio Analysis in Python
Are Markets Trending or Mean-Reverting? Hurst Exponent Analysis in Python
Is Consumer Discretionary vs Staples a Leading Indicator? XLY/XLP Ratio Analysis in Python
Does Heavy Capex Predict Future Stock Returns? Capital Expenditure Analysis in Python
How to Estimate Cost of Equity Using CAPM in Python
Is Volatility Predictable? Testing for Volatility Clustering in Python
Which Industrials Are Overleveraged? Net Debt to EBITDA Screening in Python
GM Before and After Bankruptcy: Why Entity Resolution Matters for Financial Data
What Is Adjusted Beta? Merrill Lynch Beta Shrinkage in Python
How Good Is a Stock Pick? Information Ratio and Tracking Error in Python
Do Stock Returns Follow a Normal Distribution? Testing for Fat Tails in Python
Which Large Caps Have the Highest Free Cash Flow Yield? FCF Screening in Python
Which Sectors Won Over 5 Years? Sector Rotation Analysis in Python
How to Forecast Stock Volatility with GARCH Models in Python
Are Stock Prices Mean-Reverting? Augmented Dickey-Fuller Test in Python
How to Calculate CAPM Alpha and Beta with Regression in Python
How to Compare Sector Sharpe Ratios and Sortino Ratios in Python
DELL: Why Stitching Historical Price Data Together Is Wrong
How to Analyze Drawdown and Recovery for Bank Stocks in Python
How to Screen SaaS Stocks by Revenue Growth and Cash Flow in Python
How to Screen REITs by Dividend Yield and Valuation in Python
How Correlated Are the Magnificent 7? Intra-Group Correlation in Python
AAPL vs XOM: Do Individual Stocks Have Seasonal Patterns?
How to Rank Large-Cap Stocks by Momentum in Python
How to Build a Multi-Endpoint Financial Dashboard in Python
How to Compare Volatility Across Energy Stocks in Python
How to Screen Healthcare Stocks by Valuation in Python
How to Build a Sector Correlation Matrix for Portfolio Diversification in Python
How to Find Oversold and Overbought Stocks Using Z-Scores in Python
How to Measure Earnings Quality: Cash Flow vs Net Income in Python
How to Build a Multi-Factor Stock Screen in Python (Value + Momentum + Quality)
How to Build a Simple DCF Model for Any Stock in Python
How to Screen Tech Stocks by Revenue Growth in Python
How to Screen Stocks by Balance Sheet Health in Python
Is "Sell in May" Real? SPY Monthly Seasonality Over 10 Years
How to Compare Sector Performance YTD Using Python
How to Track S&P 500 Additions and Removals Over Time in Python
How to Screen Dividend Stocks by Yield and Quality in Python
How to Calculate Max Drawdown and Recovery Time for Any Stock in Python
How to Compare Profitability Across Mega-Cap Tech Stocks in Python
Why Ticker Symbols Are Unreliable: The Recycling Problem Every Quant Should Know
How to Calculate and Compare Stock Volatility in Python
How to Screen Blue-Chip Stocks by P/E Ratio in Python
How to Track Companies Through Ticker Changes, Bankruptcies, and Renames in Python
S&P 500 Turnover: How Much the Index Has Changed Since 2010
How to Calculate Stock Beta and Correlation in Python
← All articles

Does the S&P 500 Index Effect Still Exist? Event Study in Python

What's the question?

When S&P Dow Jones Indices adds a company to the S&P 500, every fund tracking the index must own it at the same weight by the same date. That demand is forced and price-insensitive, and the schedule is public in advance.

That mechanism produced the index effect: the abnormal gain of an added stock between announcement and inclusion. Greenwood and Sammon, in the Journal of Finance in April 2025, put the average addition return at 7.4% during the 1990s and 0.3% over the decade to 2020. Passive assets grew enormously since, so forced demand is larger than ever while the premium is supposed to have gone. The test below covers 2016 to 2024, and asks whether a pre-inclusion gain reverses.

The approach

The sample is every S&P 500 addition effective between 1 January 2016 and 31 December 2024: 193 events. Abnormal return means the stock’s daily return minus SPY’s on the same day, cumulated across a window, with beta fixed at 1.

One caveat governs everything below. The record dates the day membership took effect, not the announcement: Workday’s was announced on 6 December 2024 and took effect eleven trading days later, while ad-hoc replacements come about five days ahead. No pre-date window here is a clean announcement effect.

  1. Pull dated additions from a point-in-time membership record; today’s constituent list omits additions later acquired or demoted.
  2. Align each event to trading days, with day 0 as the first day of membership. Volume verifies that convention: index funds trade the closing auction of the last day before membership starts.
  3. Validate identity before using any price series: the symbol must map to the same company on the effective date, and no window may hold a one-day move beyond 60%. TWTR belonged to Tweeter Home Entertainment before Twitter.
  4. Require a complete return series over [-40, +60] trading days, which drops 31 events: 23 spin-offs or merger-created share classes admitted on their first trading day, and 8 whose traded history does not span the window. Survivors number 90 of 105 events in 2016-2019 and 72 of 88 in 2020-2024, so attrition does not favour either era.

Code

import numpy as np
import pandas as pd
from scipy import stats
import xfinlink as xfl

xfl.set_api_key("YOUR_API_KEY")  # free at https://xfinlink.com/signup

PRE, POST, JUMP = 40, 60, 0.60
WINDOWS = [("[-40,-11]", -40, -11), ("[-10,-1]", -10, -1), ("[-1] only", -1, -1),
           ("[0]", 0, 0), ("[+1,+5]", 1, 5), ("[+1,+20]", 1, 20), ("[+1,+60]", 1, 60)]

ev = xfl.index_events("sp500", event_type="added",
                      start="2016-01-01", end="2024-12-31")
ev["eff"] = pd.to_datetime(ev["effective_date"])

spy = xfl.prices("SPY", start="2015-08-01", end="2025-06-30",
                 fields=["return_daily"])
cal = pd.DatetimeIndex(sorted(spy["date"].unique()))
spy_ret = spy.set_index("date")["return_daily"].astype(float)

# identity windows per symbol: resolve() takes 10 tickers per call
spans, tk_all = {}, sorted(ev["ticker"].unique().tolist())
for i in range(0, len(tk_all), 10):
    for t, payload in xfl.resolve(tk_all[i:i + 10])["data"].items():
        spans[t] = [(e["entity_id"], e.get("ticker_valid_from"))
                    for e in payload.get("entities", [])]

# batch by quarter, key the panel on entity_id rather than ticker
ev["q"] = ev["eff"].dt.to_period("Q")
panel = {}
for q, g in ev.groupby("q"):
    d = xfl.prices(sorted(g["ticker"].unique().tolist()),
                   start=(g["eff"].min() - pd.Timedelta(days=95)).strftime("%Y-%m-%d"),
                   end=(g["eff"].max() + pd.Timedelta(days=100)).strftime("%Y-%m-%d"),
                   fields=["return_daily", "volume"])
    for eid, de in d.groupby("entity_id"):
        panel[(q, eid)] = de.sort_values("date").set_index("date")

paths = []
for _, r in ev.iterrows():
    hit = [s for s in spans.get(r["ticker"], []) if s[0] == r["entity_id"]]
    if not hit or (hit[0][1] and pd.Timestamp(hit[0][1]) > r["eff"]):
        continue                 # symbol did not belong to this company yet
    d = panel.get((r["q"], r["entity_id"]))
    pos = cal.searchsorted(r["eff"])
    if d is None or pos - PRE < 0 or pos + POST >= len(cal) or cal[pos] != r["eff"]:
        continue
    win = cal[pos - PRE: pos + POST + 1]
    ret = d["return_daily"].astype(float).reindex(win)
    if ret.isna().any() or ret.abs().max() > JUMP:
        continue                 # no pre-history, a trading gap, or a data break
    vol = d["volume"].astype(float).reindex(win)
    paths.append({
        "era": "2016-2019" if r["eff"] < pd.Timestamp("2020-01-01") else "2020-2024",
        "ar": (ret - spy_ret.reindex(win)).values,
        "relvol_m1": vol.iloc[PRE - 1] / vol.iloc[:PRE - 1].median()})

res = pd.DataFrame([p["ar"] for p in paths], columns=np.arange(-PRE, POST + 1))
meta = pd.DataFrame([{k: v for k, v in p.items() if k != "ar"} for p in paths])

def car(df, a, b):
    return df.loc[:, [c for c in df.columns if a <= c <= b]].sum(axis=1) * 100

print(f"median day -1 volume vs days -40..-2: {meta['relvol_m1'].median():.1f}x")
for name, a, b in WINDOWS:
    v = car(res, a, b)
    t = stats.ttest_1samp(v, 0)
    print(f"{name:<11}{v.mean():8.2f}%{v.median():8.2f}%{t.statistic:9.2f}{t.pvalue:9.4f}")
    for era in ["2016-2019", "2020-2024"]:
        print("   ", era, f"{car(res[meta['era'] == era], a, b).median():6.2f}%")

Full script with formatting and visualisation: sp500-index-effect-event-study-python.py

Output

Median cumulative abnormal return against SPY from 40 trading days before to 60 trading days after S&P 500 inclusion, for additions in 2016-2019 and 2020-2024.
S&P 500 additions, effective 2016-01-01 to 2024-12-31: 193 events
Complete return history over [-40,+60] trading days: 162 events (31 excluded)
  2016-2019: 90 of 105 events kept (86%)
  2020-2024: 72 of 88 events kept (82%)

Excluded events (31):
  UA     2016-04-08  did not trade on 0 of 40 days before, 1 of 61 from day 0
  FTV    2016-07-05  did not trade on 40 of 40 days before, 1 of 61 from day 0
  FTI    2017-01-17  did not trade on 0 of 40 days before, 1 of 61 from day 0
  IIN    2017-04-05  listing history does not span the event window
  INFO   2017-06-02  listing history does not span the event window
  BHF    2017-08-07  did not trade on 40 of 40 days before, 1 of 61 from day 0
  IQV    2017-08-29  did not trade on 40 of 40 days before, 55 of 61 from day 0
  DD     2017-09-01  did not trade on 0 of 40 days before, 1 of 61 from day 0
  LIN    2018-10-31  did not trade on 40 of 40 days before, 1 of 61 from day 0
  DOW    2019-04-02  did not trade on 40 of 40 days before, 1 of 61 from day 0
  CTVA   2019-06-03  did not trade on 40 of 40 days before, 1 of 61 from day 0
  BMS    2019-06-07  did not trade on 0 of 40 days before, 59 of 61 from day 0
  AMCR   2019-06-11  did not trade on 40 of 40 days before, 1 of 61 from day 0
  PSKY   2019-12-23  listing history does not span the event window
  TI     2019-12-23  listing history does not span the event window
  CARR   2020-04-03  did not trade on 40 of 40 days before, 1 of 61 from day 0
  OTIS   2020-04-03  did not trade on 40 of 40 days before, 1 of 61 from day 0
  VNT    2020-10-09  did not trade on 40 of 40 days before, 1 of 61 from day 0
  VTRS   2020-11-17  did not trade on 40 of 40 days before, 1 of 61 from day 0
  AIRC   2020-12-15  did not trade on 40 of 40 days before, 1 of 61 from day 0
  OGN    2021-06-03  did not trade on 40 of 40 days before, 1 of 61 from day 0
  CEG    2022-02-02  did not trade on 40 of 40 days before, 1 of 61 from day 0
  WBD    2022-04-11  did not trade on 40 of 40 days before, 1 of 61 from day 0
  MBC    2022-12-15  did not trade on 40 of 40 days before, 1 of 61 from day 0
  GEHC   2023-01-04  did not trade on 40 of 40 days before, 1 of 61 from day 0
  FTRE   2023-07-03  did not trade on 40 of 40 days before, 1 of 61 from day 0
  VLTO   2023-10-02  did not trade on 40 of 40 days before, 1 of 61 from day 0
  SOLV   2024-04-01  did not trade on 40 of 40 days before, 1 of 61 from day 0
  GEV    2024-04-02  did not trade on 40 of 40 days before, 1 of 61 from day 0
  SW     2024-07-08  did not trade on 40 of 40 days before, 1 of 61 from day 0
  AMTM   2024-09-30  did not trade on 40 of 40 days before, 1 of 61 from day 0

Identity re-checked against continuous price history for 4 names: COO, IIN, AOS, TI

Largest single-day moves left in the sample (checked by name)
ticker        eff max_move  max_move_day
  EPAM 2021-12-14    45.7%            51
  NKTR 2018-03-19    41.8%            53
  SMCI 2024-03-18    35.9%           -40
   HWM 2020-04-03    26.4%            -8
   AMD 2017-03-20    24.2%            30

Day-0 alignment check (effective date = first day of membership)
  median volume on day -1 vs days -40..-2: 25.3x
  share of events whose heaviest volume day in [-5,+5] is day -1: 98%
  peak-volume day distribution: {-5: 1, -1: 158, 0: 1, 3: 1, 4: 1}

Cumulative abnormal return vs SPY, market-adjusted (beta = 1)
window          mean   median   t-stat        p   % > 0
[-40,-11]      6.54%    4.88%     5.84   0.0000     67%
[-10,-1]       1.73%    0.22%     3.09   0.0023     51%
[-1] only     -0.05%   -0.25%    -0.24   0.8123     44%
[0]           -0.00%   -0.08%    -0.02   0.9829     47%
[+1,+5]       -0.22%    0.16%    -0.69   0.4900     52%
[+1,+20]      -0.20%    0.39%    -0.30   0.7622     51%
[+1,+60]      -0.71%    1.14%    -0.56   0.5785     52%

By era (median CAR, mean in brackets)
window                  2016-2019             2020-2024
[-40,-11]         4.11% (  4.91%)       6.49% (  8.59%)
[-10,-1]         -1.08% ( -0.33%)       3.46% (  4.32%)
[-1] only        -0.42% ( -0.49%)       0.15% (  0.51%)
[0]               0.11% (  0.31%)      -0.32% ( -0.40%)
[+1,+5]           0.19% ( -0.29%)      -0.02% ( -0.14%)
[+1,+20]          0.79% (  0.46%)      -0.84% ( -1.02%)
[+1,+60]          0.55% ( -0.49%)       1.38% ( -1.00%)

Does the run-up reverse? (medians)
  2016-2019: [-10,-1] = -1.08%, [+1,+20] = +0.79%  ->  no run-up to reverse
  2020-2024: [-10,-1] = +3.46%, [+1,+20] = -0.84%  ->  gives back 24% of it

Largest pre-inclusion run-ups
ticker        eff  run_up   hold60
  MRNA 2021-07-21 29.4256   9.4904
  DXCM 2020-05-12 22.3826  -7.2565
  SMCI 2024-03-18 19.2331 -21.6056
  TWTR 2018-06-07 18.5090 -12.6304
Widest 60-day outcomes after inclusion
ticker        eff  run_up   hold60
  PLTR 2024-09-23 16.0064  66.8539
  ETSY 2020-09-21  0.1363  36.2518
  EPAM 2021-12-14  9.0427 -88.2098
  NKTR 2018-03-19 -1.6486 -53.1170

Median SPY return over the same [+1,+20] window: 1.71% (a beta error of 0.3 moves the estimate by 0.51pp)

What this tells us

Alignment first. For 158 of 162 events the heaviest volume day in [-5, +5] is day -1, where the median stock trades 25.3 times normal volume.

On that day the median abnormal return is -0.25%, the mean -0.05%, t-statistic -0.24. Buying large enough to lift volume twenty-five-fold clears at no measurable premium, and day 0 is flat at -0.08%.

The gain that survives testing sits far from the event: over [-40, -11] the median addition beats SPY by 4.88%, t-statistic 5.84, with 67% of events positive. That window closes before the announcement for quarterly additions, so it does not pay for index demand. It reflects how the index gets built: S&P screens on size, liquidity, float and recent profits, so a stock qualifies only after it has appreciated.

The era split holds a surprise: the close-in window did not decay but grew, from a median of -1.08% in 2016-2019 to +3.46% in 2020-2024. Who was added explains much of it, with Moderna up 29.4% before its July 2021 inclusion and Super Micro 19.2% before March 2024. The same cohort carries the wider [-40, -11] figure, pointing to selection rather than heavier late pressure.

Afterwards nothing reliable happens: every post-date window returns a p-value above 0.48. The recent cohort hands back 24% of its close-in run-up over the next 20 trading days, a median of -0.84%, while the earlier cohort has none to hand back. Means and medians disagree in sign over [+1, +60] because company news swamps index flow: EPAM lost 88.2% against SPY within 60 trading days of its December 2021 inclusion, once Russia invaded Ukraine and stranded a workforce concentrated there.

So what?

Treat the rebalance close as a liquidity event, not a price event. Volume at twenty-five times normal with a flat abnormal return means the auction absorbs size without moving price, which matters to anyone working a large order.

Buying an announced addition to sell into the rebalance has no support here beyond what momentum already provides. Fading them afterwards is weaker still: 0.84% over 20 days does not cover costs, and the tails are wide enough that sizing matters more.

The measurement lesson generalises: rebuild the universe as it stood, confirm a symbol still means the same company on the event date, and read the exclusions before trusting an average.

Built with xfinlink — free financial data API for Python. pip install xfinlink

Built with xfinlink — free financial data API for Python. pip install xfinlink
← All articles