Equal-weight vs cap-weight returns: SPY vs RSP in Python
How to measure the SPY vs RSP gap with Python: RSP led by 1.4 pp in the week to Oct 30 and 3.1 pp YTD. Code, missing-data traps and fund facts.

In the last week of October, the S&P 500 sent two different signals depending on how you held it. SPY, the fund that weights the index's companies by market value, fell 0.6% from the Oct 23 close to the Oct 30 close. RSP, which holds the same companies in roughly equal amounts, rose 0.8%. That's a gap of 1.4 percentage points in five sessions. For the year to date, RSP's price was up 14.0% and SPY's 10.8% (StockFit daily closes, as of the Oct 30, 2026 close).
Gaps like that are easy to quote and easy to get slightly wrong. This guide shows how to measure the gap yourself in a few dozen lines of Python, using daily closes from the StockFit API. It also covers the three choices that change the answer: which close you use, what happens when the last bar or a day in the middle is missing, and whether the running gap comes from price levels or from adding up daily differences. It reports what happened and says nothing about which weighting is preferable.
What is the difference between cap-weight and equal-weight?
Both funds start from the same list of about 500 large US companies. They differ in how much of each they hold.
| SPY | RSP | |
|---|---|---|
| Index | S&P 500 | S&P 500 Equal Weight Index |
| Weighting | Float-adjusted market capitalization | Same weight for each member at every quarterly rebalance |
| Structure | Unit investment trust, listed Jan 22, 1993 | Exchange-traded fund, full replication of its index |
| Expense ratio | 0.0945% (gross), as of Oct 30, 2026 | 0.20%, per the fund's prospectus |
Sources: State Street SPY page (fund information as of Oct 30, 2026); RSP summary prospectus (Aug 28, 2025). StockFit's fund fee data, taken from Invesco's Aug 27, 2026 filing, also gives RSP a total expense ratio of 0.20%.
Cap-weight means a company's share of the fund follows its market value. At the Oct 29 close, SPY's ten largest lines made up about 37.4% of the fund: Nvidia 7.77%, Apple 7.65%, Microsoft 5.92%, Amazon 3.60%, Alphabet's two share classes 2.50% and 2.02%, Broadcom 2.47%, Meta 2.07%, Tesla 1.71% and Eli Lilly 1.65% (State Street, holdings as of Oct 29, 2026, subject to change). That's ten lines but nine companies.
Equal-weight means each member starts each quarter at the same weight. RSP's prospectus says the index "assigns each component security the same weight at each quarterly rebalance". With about 500 members, that is roughly 0.2% each. Between rebalances the weights drift with prices. In RSP's July 31, 2026 portfolio report, its largest stock positions were about 0.26% to 0.27% of the fund (StockFit, from the fund's N-PORT filing). At each rebalance, the index trims members that rose more than average and tops up those that fell, back to equal weight.
The practical effect is that a big move in one very large company moves SPY far more than RSP. On Thursday, Oct 29, Alphabet fell 12.6% after its third-quarter results. Its two share classes together were about 4.5% of SPY at that day's close, against the roughly equal small weights of RSP's members. That illustrates the mechanism. It doesn't prove what caused the week's gap, which would take a stock-by-stock breakdown we haven't done.
Index rules decide who's in a fund and when it trades. Our guide to the Russell reconstitution covers how another index family rebuilds itself on a published schedule.
What did the SPY vs RSP gap look like in the week to Oct 30?
These are the closes that every weekly number in this guide comes from:
| Date (2026) | SPY close | RSP close |
|---|---|---|
| Fri, Oct 23 (base) | $759.97 | $216.54 |
| Mon, Oct 26 | $765.37 | $217.29 |
| Tue, Oct 27 | $769.66 | $217.52 |
| Wed, Oct 28 | $772.31 | $217.43 |
| Thu, Oct 29 | $769.36 | $220.04 |
| Fri, Oct 30 | $755.72 | $218.31 |
Unadjusted daily closes in US dollars. Source: StockFit, as of the Oct 30, 2026 close.
SPY rose more than RSP on Monday, Tuesday and Wednesday. The week turned on Thursday, when SPY fell 0.4% and RSP rose 1.2%, a gap of 1.6 points. On Friday both fell, SPY by 1.8% and RSP by 0.8%. Over October as a whole, from the Sep 30 close, SPY fell 0.9% while RSP rose 4.9%. What drove the week, from the Fed to Alphabet and Intel, is in our week in review for Oct 26–30. The rest of this guide is about how to get these numbers.
How do you pull daily closes from StockFit in Python?
StockFit's price history endpoint returns one bar per session for a symbol, as a list of [timestamp, close] pairs. The timestamp is midnight UTC of the session date, in milliseconds, and a close that doesn't exist comes back as null (None in Python). By default the closes are adjusted for dividends and splits. Pass adjusted=false for the price as it traded. The parameters and plans are documented at developer.stockfit.io.
The function below makes one request per symbol, reads the API key from an environment variable so it never sits in your code, and keeps a missing close as NaN:
import os
import pandas as pd
import requests
URL = "https://api.stockfit.io/v1/api/price/history"
KEY = os.environ["STOCKFIT_API_KEY"] # set it in your shell; never paste a key into code
def daily_closes(symbol: str, start: str, end: str, adjusted: bool = False) -> pd.Series:
"""Daily closes from start to end, both inclusive. A missing close stays NaN."""
resp = requests.get(
URL,
params={"symbol": symbol, "resolution": "1d", "from": start, "to": end,
"adjusted": "true" if adjusted else "false"},
headers={"Authorization": f"Bearer {KEY}"},
timeout=30,
)
resp.raise_for_status()
bars = resp.json()["data"] # [[timestamp_ms, close_or_None], ...]
dates = pd.to_datetime([ts for ts, _ in bars], unit="ms", utc=True).normalize()
return pd.Series([close for _, close in bars], index=dates, name=symbol, dtype="float64")
start, end = "2026-10-23", "2026-10-30" # Friday Oct 23's close is the base for the week
closes = pd.concat([daily_closes("SPY", start, end), daily_closes("RSP", start, end)], axis=1)
Two details matter. pd.concat(..., axis=1) is an outer join: a date that exists for one fund but not the other becomes a row with NaN in the gap, so nothing is silently dropped. And the window starts one session early. A return "for the week of Oct 26–30" runs from the Oct 23 close to the Oct 30 close. If you start on Oct 26, you lose Monday's move.
Check the last bar before you trust the window
The most common way to get a wrong weekly number is to run the script before the latest session is complete in the data. A daily series requested soon after the close may still end on the previous day. A bar for the current session pulled during trading hours is the price so far, not the close. An inner join would then quietly give you a four-day "week". So print what you got and stop if the window is short:
expected_last = pd.Timestamp(end, tz="UTC")
for symbol in closes.columns:
first, last = closes[symbol].first_valid_index(), closes[symbol].last_valid_index()
print(f"{symbol}: {first:%Y-%m-%d} to {last:%Y-%m-%d}, "
f"{closes[symbol].count()} closes, {closes[symbol].isna().sum()} missing")
if last != expected_last:
raise ValueError(f"{symbol} ends on {last:%Y-%m-%d}, not {end}: the latest close isn't in yet")
Set end to a session that has already closed, not a weekend or a holiday. The date check can't tell an intraday price from a final close on the same date. For the latest day, run the script after the session has closed and its data has settled, or compare the last bar with a quote taken after the close. The Oct 30 closes used here are $755.72 for SPY and $218.31 for RSP.
How do you compute the return, the daily spread and the cumulative gap?
Three numbers describe the gap, and they answer different questions:
- The return over the window for each fund: last close divided by the base close, minus 1.
- The daily spread: RSP's return on a day minus SPY's return that day, in percentage points.
- The cumulative gap: RSP's return since the base minus SPY's return since the base, also in percentage points. This is the running line you'd chart.
base = pd.Timestamp(start, tz="UTC")
cumulative = closes / closes.loc[base] - 1 # return since the base close, from price levels
gap_pp = (cumulative["RSP"] - cumulative["SPY"]) * 100
daily = closes / closes.shift(1) - 1 # deliberately not pct_change(); see below
spread_pp = (daily["RSP"] - daily["SPY"]) * 100
print((cumulative.iloc[-1] * 100).round(2)) # each fund's return over the window, in %
print(round(gap_pp.iloc[-1], 2)) # RSP minus SPY, in percentage points
print(spread_pp.round(2)) # one row per day; the base day is NaN
Applied to the closes in the table above, this logic gives:
| Period | SPY | RSP | Gap |
|---|---|---|---|
| Week to Oct 30 | −0.56% | +0.82% | +1.38 pp |
| Mon, Oct 26 | +0.71% | +0.35% | −0.36 pp |
| Tue, Oct 27 | +0.56% | +0.11% | −0.45 pp |
| Wed, Oct 28 | +0.34% | −0.04% | −0.39 pp |
| Thu, Oct 29 | −0.38% | +1.20% | +1.58 pp |
| Fri, Oct 30 | −1.77% | −0.79% | +0.99 pp |
Price returns from StockFit unadjusted daily closes, week measured from the Oct 23 close, as of the Oct 30, 2026 close. Gap = RSP minus SPY, in percentage points (pp).
Take the cumulative gap from levels, not from summed spreads. Adding up the five daily spreads gives 1.36 points, while the gap from levels gives 1.38. The difference is compounding: each day's return applies to a different starting amount. Over a week the two differ only in the second decimal place, but over months they drift further apart. The levels version is the one that matches what a holder of each fund saw. It has a second advantage: each point depends only on that day's close and the base close, so a missing day in the middle never corrupts the days after it.
Why not pct_change()? In pandas 2.x, DataFrame.pct_change() fills missing values forward by default before computing (fill_method='pad', deprecated since version 2.1, according to the pandas 2.2 documentation). With that default, a missing close turns into a 0.00% day, and the next day carries two days of movement. The current documentation says fill_method must now be None, so the same line of code behaves differently depending on your pandas version. Writing closes / closes.shift(1) - 1 gives the same result everywhere: a missing close stays missing. For the same reason, there's no fillna(0), ffill() or dropna() anywhere in the code above.
Test it with a day you know is missing
A check only counts if it can fail. Delete one close on purpose and confirm that the result is unknown, not zero:
wed, thu = pd.Timestamp("2026-10-28", tz="UTC"), pd.Timestamp("2026-10-29", tz="UTC")
test = closes.copy()
test.loc[wed, "RSP"] = float("nan") # pretend Wednesday's RSP close never arrived
test_daily = test / test.shift(1) - 1
assert test_daily.loc[[wed, thu], "RSP"].isna().all() # unknown on the gap day and the day after
test_cum = test / test.loc[base] - 1
assert test_cum.loc[thu].notna().all() # the levels-based gap still exists on Thursday
With Wednesday's RSP close removed, the daily spread is unknown on Wednesday and also on Thursday, because Thursday's return needs Wednesday's close. The cumulative gap on Thursday is still there, at +0.38 points from the Oct 23 base, because it only needs Thursday's close and the base. If you fill the hole with zero or forward-fill it, the first assertion fails: Wednesday's RSP return becomes −100% or 0.00% instead of unknown. The second assertion guards the levels method. It would fail if the running gap were built by chaining daily returns, because Thursday's daily return is unknown. That's the point of the test.
Adjusted or unadjusted closes: which should you use?
It depends on whether you want the price change or something closer to what a holder earned including dividends. Unadjusted closes give the price return. StockFit's default adjusted closes are back-adjusted for dividends and splits, which approximates total return with distributions reinvested on the ex-date. Call daily_closes() with adjusted=True to get them.
Over the week to Oct 30 the choice makes no difference. Both funds' last distributions went ex in mid-September (Sep 18 for SPY and Sep 21 for RSP, going by when the two StockFit series stop differing), so the adjusted and unadjusted closes are identical for the whole window. Over the year to date the difference shows:
| Year to date | SPY | RSP | Gap |
|---|---|---|---|
| Price return (unadjusted closes) | +10.8% | +14.0% | +3.1 pp |
| Dividend-adjusted closes | +11.7% | +15.3% | +3.6 pp |
Dec 31, 2025 close to Oct 30, 2026 close; gap = RSP minus SPY, in percentage points (pp). Source: StockFit daily closes, as of the Oct 30, 2026 close. Base closes on Dec 31, 2025: SPY $681.92 and RSP $191.56 unadjusted; $676.635 and $189.296 adjusted, as of Oct 30, 2026.
With dividends counted, RSP's lead is about half a point larger, because RSP paid out more relative to its price than SPY did this year.
Two cautions come with adjusted data:
- Adjusted history changes. Every new distribution restates the whole adjusted series back in time. A dividend-adjusted figure computed today won't match the same calculation after the next ex-date. So always print the as-of date next to an adjusted number, as the table above does.
- It isn't the sponsor's official total return. To sanity-check an adjusted series, compare it with the return the fund sponsor publishes. State Street's year-to-date return for SPY at net asset value was 13.71% as of Oct 29, 2026. StockFit's adjusted SPY closes from Dec 31 to Oct 29 give +13.70%. Small differences are normal: the sponsor uses NAV, while exchange closes are market prices.
Fund prices are already net of each fund's expenses, which are accrued daily. So the gap above already reflects the difference between the two expense ratios, and you shouldn't subtract the fees again.
What does the year-to-date gap look like?
Run the same code with start = "2025-12-31" and the cumulative gap becomes a year-to-date line. Here it is at each month-end:
| Month-end | SPY YTD | RSP YTD | Gap |
|---|---|---|---|
| Jan 30 | +1.47% | +3.40% | +1.93 |
| Feb 27 | +0.60% | +7.00% | +6.40 |
| Mar 31 | −4.63% | +0.19% | +4.82 |
| Apr 30 | +5.39% | +6.20% | +0.81 |
| May 29 | +10.93% | +9.02% | −1.92 |
| Jun 30 | +9.51% | +11.07% | +1.56 |
| Jul 31 | +9.55% | +12.24% | +2.69 |
| Aug 31 | +12.48% | +14.53% | +2.04 |
| Sep 30 | +11.84% | +8.59% | −3.24 |
| Oct 30 | +10.82% | +13.96% | +3.14 |
2026 month-end closes. Price returns from the Dec 31, 2025 close, unadjusted daily closes; gap = RSP minus SPY, in percentage points (pp). Source: StockFit, as of the Oct 30, 2026 close.
Across the 209 sessions after the base date, the line moved a long way in both directions:
- Its high was +6.40 points on Feb 27. Its low was −4.04 points on Oct 7.
- It crossed zero several times: in early May, three times in early June, in mid-September and in late October.
- The last month was the biggest swing. At the Sep 30 close RSP trailed SPY by 3.2 points for the year. By Oct 30 it led by 3.1, a swing of about 6.4 points in a month.
- Day by day it was close to even. RSP's daily return was above SPY's on roughly half of the 209 trading days after the base date (StockFit closes).
What can this comparison not tell you?
- Why the gap moved. The cumulative gap is the net result of about 500 weight differences. Splitting it by stock or sector takes the holdings of both funds and a contribution calculation, which this guide doesn't do. One large company's bad day is a plausible part of a story, not proof of it.
- Anything about the future. These are closes from one 10-month period. The same code over another window can show the opposite sign, as the month-end table shows within this year alone.
- Exact total return. Price return leaves out dividends. The adjusted series approximates total return but is restated with each distribution and uses market closes, not NAV.
- The index itself. SPY and RSP are funds. Their closes include fees, trading costs and the small differences between a fund's price and its NAV.
What to watch next
- The next quarterly rebalance. RSP's index resets to equal weights again at its next quarterly rebalance. After that, its largest positions start drifting from roughly 0.2% again.
- The next distributions. When either fund's next dividend goes ex, every adjusted close before it is restated. Re-run any dividend-adjusted figure and update its as-of date.
- The weekly picture. The week in review reports SPY and RSP side by side. With the code above, you can check any window yourself. More data how-tos and explainers are in Guides.
Sources
- Daily closes for SPY and RSP, unadjusted and dividend-adjusted, Dec 31, 2025 to Oct 30, 2026
- RSP fund fees (from the fund's Aug 27, 2026 registration filing) and holdings (N-PORT, Jul 31, 2026)
- State Street SPDR S&P 500 ETF Trust (SPY), fund page (expense ratio, NAV, YTD return, top holdings)
- Invesco S&P 500 Equal Weight ETF (RSP), summary prospectus
- Invesco Exchange-Traded Fund Trust, post-effective amendment (Form 485BPOS) filed Aug 27, 2026
- pandas.DataFrame.pct_change (current documentation)
- pandas.DataFrame.pct_change (pandas 2.2 documentation)
More in Guides
GuidesRussell reconstitution 2026: December dates and rules
The Russell indexes now rebuild twice a year. Rank day is Oct 30, lists come Nov 13 and changes take effect Dec 11: the dates, the rules and what differs from June.
GuidesCPI vs PCE: how they differ and why the Fed targets PCE
CPI and PCE price the same goods but weigh them differently. The four differences, why the Fed's 2% goal is in PCE, the latest readings and release dates.
GuidesStock market holidays 2026–27: when stocks and bonds close
Every NYSE holiday and 1 pm early close for 2026–28, with SIFMA bond closes side by side, times in ET and UTC, and why Veterans Day differs.