ETF Overlap API: Compare Two ETFs in Python
Call the ETF overlap API for two funds' shared holdings. Read both directional overlap weights, then compute sum-of-min overlap in Python from N-PORT.

Two dividend ETFs, SCHD and VYM, share 80 holdings. Ask how much they overlap and you can get two honest answers that differ by a factor of almost five: 99.09% of SCHD's portfolio sits in stocks VYM also owns, but only 21.42% of VYM's portfolio sits in stocks SCHD owns. Both numbers are correct. Which one matters depends on which fund you already hold.
This tutorial uses the StockFit ETF overlap API to get both numbers in one request, reads the response field by field, and then computes the single symmetric sum-of-min score in a short Python script. It also covers the three things that quietly break overlap numbers built from SEC filings: report dates that do not line up, futures and cash rows, and identifiers that differ from filer to filer. Every figure below is real API output pulled on October 25, 2026, from each fund's latest Form N-PORT filing, and the last section shows how to check them against the filing itself.
What ETF overlap means: two directions and one symmetric score
ETF overlap measures how much two funds hold the same stocks. There are three common ways to put a number on it, and they answer different questions:
- Overlap by count. Shared holdings divided by some total. SCHD and VYM share 80 names: that is 78.4% of SCHD's 102 holdings, 13.2% of VYM's 606, and 12.7% of the 628 distinct names across both. Count ignores position size, so a 6% position and a 0.01% position count the same.
- Directional overlap by weight. For fund A, add up A's weights in the names B also holds. This is the share of A's money that is already covered by B. It is different from B's view of A, because each side uses its own weights.
- Sum-of-min overlap. For each shared name, take the smaller of the two weights, and add those up. This gives one symmetric number per pair. It answers “how much of a portfolio is the same in both funds”: if you held both 50/50, it is the part where the funds agree.
| Measure (SCHD vs VYM) | Value | Question it answers |
|---|---|---|
| Shared holdings | 80 | How many names do they have in common? |
| SCHD weight in shared names | 99.09% | I own VYM. Does adding SCHD buy me anything new? |
| VYM weight in shared names | 21.42% | I own SCHD. Does adding VYM buy me anything new? |
| Sum-of-min overlap | 21.42% | How similar are the two portfolios, as one number? |
The sum-of-min score can never be larger than either directional number, because each term is at most that fund's own weight. Here it is exactly 21.42%, the same as VYM's direction, because VYM holds every one of the 80 shared names at a lower weight than SCHD does. That is the trap of quoting one number: a reader who sees “21% overlap” would never guess that almost all of SCHD is already inside VYM.
The ETF overlap API request
The endpoint is /api/fund/overlap. It takes two fund tickers, loads each fund's most recent N-PORT holdings, matches positions by CUSIP and returns the comparison. With cURL:
curl "https://api.stockfit.io/v1/api/fund/overlap?symbol1=SCHD&symbol2=VYM" \
-H "Authorization: Bearer YOUR_API_KEY"The response, trimmed to the first three of its 628 holding rows:
{
"symbol1": "SCHD",
"symbol2": "VYM",
"reportDate1": "2026-05-31",
"reportDate2": "2026-07-31",
"overlapCount": 80,
"onlyIn1Count": 22,
"onlyIn2Count": 526,
"overlapWeight1": 99.08651197884093,
"overlapWeight2": 21.420235528329005,
"holdings": [
{
"cusip": "747525103",
"name": "QUALCOMM Inc",
"mappedSymbol": "QCOM",
"weight1": 6.737733132342,
"weight2": 0.632122745052,
"value1": 6397222108.2,
"value2": 627028828.75,
"overlapStatus": "both"
},
{
"cusip": "882508104",
"name": "Texas Instruments Inc",
"mappedSymbol": "TXN",
"weight1": 5.904722048933,
"weight2": 1.017828520776,
"value1": 5606309673.04,
"value2": 1009626421.84,
"overlapStatus": "both"
},
{
"cusip": "91324P102",
"name": "UnitedHealth Group Inc",
"mappedSymbol": "UNH",
"weight1": 5.092561094641,
"weight2": 1.518846662089,
"value1": 4835193644.82,
"value2": 1506607144,
"overlapStatus": "both"
}
]
}The header gives you the whole comparison at a glance:
overlapCount,onlyIn1CountandonlyIn2Countsplit the union of both portfolios: 80 shared, 22 only in SCHD, 526 only in VYM. The first two add up to SCHD's 102 holdings and the first and third to VYM's 606, the same counts /api/fund/profile reports for each fund.overlapWeight1andoverlapWeight2are the two directional overlaps, in percent of each fund's net assets.reportDate1andreportDate2are the period-end dates of the two filings being compared. They are not the same here, which gets its own section below.
Each row in holdings carries the matching key (cusip), a name, the resolved ticker (mappedSymbol), the weight and market value in each fund (weight1, value1, weight2, value2) and overlapStatus, which is both, only_1 or only_2. A side that does not hold the name has null weight and value.
Reading overlapWeight1 and overlapWeight2
The first rows already explain the asymmetry. Qualcomm is 6.74% of SCHD and 0.63% of VYM. Texas Instruments is 5.90% against 1.02%, UnitedHealth 5.09% against 1.52%. SCHD is a concentrated portfolio of about a hundred dividend payers. VYM is a broad high-yield index of about six hundred that holds the same large names, each at a smaller weight, and then adds hundreds more.
So the two directions read like this:
- SCHD inside VYM: 99.09%. SCHD's 102 rows add up to 99.82% of its net assets, and the 22 names VYM does not hold carry only 0.73% of that. The largest are Erie Indemnity at 0.14% and Federated Hermes at 0.10%, followed mostly by small banks and other financial companies, a State Street money market fund and two futures contracts. For a VYM holder, SCHD adds almost no new companies. It changes the weights, tilting the position toward SCHD's top names.
- VYM inside SCHD: 21.42%. For an SCHD holder, the remaining 78.58% of VYM's net assets is outside SCHD's names: the 526 holdings SCHD does not own (VYM's two cash rows among them), plus other net assets. Adding VYM brings in hundreds of companies SCHD does not hold, so the two funds are not substitutes.
A useful habit is to always report the pair with the direction spelled out: “99% of SCHD is in VYM, 21% of VYM is in SCHD”. Any single overlap number should say which fund it is measured against. To go the other way and ask which funds hold one of the shared names, the ETF reverse lookup guide covers /api/fund/reverse-lookup.
Calculate ETF overlap in Python
The script below calls the endpoint with requests, keeps the shared rows, and computes all three weight measures from the per-holding weights. It recomputes the two directional numbers instead of trusting the header, which gives you a check, and it drops rows whose CUSIP is empty or the all-zero placeholder before summing (the reason is in the futures section below).
import os
import requests
BASE = "https://api.stockfit.io/v1/api"
HEADERS = {"Authorization": f"Bearer {os.environ['STOCKFIT_API_KEY']}"}
# N-PORT uses an all-zero CUSIP for positions that have none (futures, some cash lines).
PLACEHOLDER_CUSIPS = {None, "", "000000000"}
def get_overlap(symbol1, symbol2):
r = requests.get(
f"{BASE}/fund/overlap",
params={"symbol1": symbol1, "symbol2": symbol2},
headers=HEADERS,
timeout=30,
)
r.raise_for_status()
return r.json()
def overlap_summary(symbol1, symbol2):
data = get_overlap(symbol1, symbol2)
shared = [
row for row in data["holdings"]
if row["overlapStatus"] == "both" and row["cusip"] not in PLACEHOLDER_CUSIPS
]
return {
"symbol1": symbol1,
"symbol2": symbol2,
"report_date1": data["reportDate1"],
"report_date2": data["reportDate2"],
"shared": len(shared),
"only1": data["onlyIn1Count"],
"only2": data["onlyIn2Count"],
# Directional: how much of each fund sits in names the other one holds.
"weight1_in_shared": sum(row["weight1"] for row in shared),
"weight2_in_shared": sum(row["weight2"] for row in shared),
# Symmetric: the smaller of the two weights, summed over shared names.
"sum_of_min": sum(min(row["weight1"], row["weight2"]) for row in shared),
"api": data,
}
s = overlap_summary("SCHD", "VYM")
api = s["api"]
print(f"{s['symbol1']} as of {s['report_date1']} vs {s['symbol2']} as of {s['report_date2']}")
print(f"shared holdings: {s['shared']:>4} (API overlapCount {api['overlapCount']})")
print(f"only in {s['symbol1']}: {s['only1']:>4}")
print(f"only in {s['symbol2']}: {s['only2']:>4}")
print(f"{s['symbol1']} weight in shared: {s['weight1_in_shared']:6.2f}% (API {api['overlapWeight1']:.2f})")
print(f"{s['symbol2']} weight in shared: {s['weight2_in_shared']:6.2f}% (API {api['overlapWeight2']:.2f})")
print(f"sum-of-min overlap: {s['sum_of_min']:6.2f}%")SCHD as of 2026-05-31 vs VYM as of 2026-07-31
shared holdings: 80 (API overlapCount 80)
only in SCHD: 22
only in VYM: 526
SCHD weight in shared: 99.09% (API 99.09)
VYM weight in shared: 21.42% (API 21.42)
sum-of-min overlap: 21.42%For this pair the recomputed values match the header exactly, and the filter removes nothing from the 80 shared rows: SCHD's two futures rows have no counterpart in VYM and come back as only_1, and VYM's two cash rows come back as only_2. That will not hold for every pair, which is why the filter is there. When the recomputed shared count is lower than overlapCount, print the rows the filter removed and look at them before you publish a number.
Which overlap number to show
For a portfolio tool, show both directions next to each other, because the user's question is almost always directional (“I own X, should I add Y?”). Use sum-of-min where you need one sortable number per pair, for example to rank a list of candidate funds or to color a heatmap, and label it as the symmetric score. The count is good for context, never for the headline: SCHD and VYM share 12.7% of their combined names, but nearly all of SCHD's money.
Mismatched N-PORT report dates
The SCHD vs VYM comparison above sets SCHD's portfolio as of 2026-05-31 against VYM's as of 2026-07-31, two months apart. That is not a bug. The public N-PORT holdings come once per fiscal quarter for each fund, and fiscal quarters differ by fund. /api/fund/profile lists the available periods: SCHD's recent public reports end November 30, February 28 and May 31, and VYM's end January 31, April 30 and July 31. The two calendars never meet, so any overlap between these two funds compares portfolios from different dates.
Two ways to handle it:
- Show the dates. Print
reportDate1andreportDate2with every overlap figure. Over two months prices move and index funds rebalance, so weights drift and membership can change. - Pin the dates yourself. /api/fund/holdings accepts
reportDateand resolves it point in time, returning the latest filing on or before that date. Asking for VYM at 2026-05-31 returns its April 30 portfolio, one month from SCHD's instead of two. The holdings-based join in the identifier section below takes this parameter.
Why the lag exists and how it compares with issuer files is covered in how often ETF holdings update. When you need both portfolios as of yesterday, the daily ETF holdings API serves issuer-published holdings for the funds in its coverage list.
Futures, cash and placeholder CUSIP rows
An N-PORT filing lists everything the fund holds, not only stocks. SCHD's May 31 report includes two equity index futures contracts, and they are reported with an all-zero CUSIP:
GET /api/fund/holdings?symbol=SCHD&pageSize=5&page=21
{
"reportDate": "2026-05-31",
"data": [
{ "name": "Chicago Board of Trade", "cusip": "000000000",
"title": "DJIA MINI E-CBOT JUN26", "pctVal": 0.00517532174,
"units": "NC", "assetCat": "DE", "issuerCat": "OTHER" },
{ "name": "Chicago Mercantile Exchange", "cusip": "000000000",
"title": "S+P MID 400 EMINI JUN26", "pctVal": 0.000831925084,
"units": "NC", "assetCat": "DE", "issuerCat": "OTHER" }
]
}The assetCat value DE marks a derivative, and units NC means the balance is a number of contracts. These rows are tiny (0.0052% and 0.0008% of SCHD), but they matter for overlap because 000000000 is not an identifier. Two funds that both hold futures both have that value, and a join on CUSIP will treat it as a shared holding. Drop placeholder and empty CUSIPs before you count or sum shared names.
Cash sweeps are the other half. VYM's filing carries two rows for the Vanguard Market Liquidity Fund with no CUSIP at all (0.1494% and 0.0064%). SCHD holds its cash in the State Street Institutional US Government Money Market Fund (GVMXX), which has a real CUSIP and ticker, so a CUSIP filter keeps it. Two funds that sweep cash into the same money market fund would then show a shared holding that has nothing to do with their stock picks. The robust filter uses the holdings endpoint, where every row carries assetCat: keep EC (equity, common) and the GVMXX row, which is STIV (short-term investment vehicle), drops out with the futures.
Matching holdings across funds: CUSIP and ISIN
Overlap is a join, and a join is only as good as its key. The overlap endpoint matches on CUSIP, the nine-character identifier most N-PORT rows carry. Each row in /api/fund/holdings also carries an isin where the filer reported one (QCOM is US7475251036, the CUSIP plus a country prefix and a check digit), along with mappedSymbol and mappedCik for rows resolved to a company.
The identifiers come from each filer, and filers do not always agree on them. Companies incorporated outside the US are a common case: one fund may report a security under one identifier and another fund under a different one, and a CUSIP-only join then shows one shared company as two unrelated rows. The defensive version builds the join itself from holdings: keep common equity, key on ISIN and fall back to CUSIP, and sum lots of the same security.
from collections import defaultdict
def equity_weights(symbol, report_date=None):
"""Equity weights by identifier from one N-PORT filing, ISIN first, CUSIP as fallback."""
weights, page, total_pages, as_of = defaultdict(float), 1, 1, None
while page <= total_pages:
params = {"symbol": symbol, "page": page, "pageSize": 1000}
if report_date:
params["reportDate"] = report_date # resolves to the latest filing on or before this date
r = requests.get(f"{BASE}/fund/holdings", params=params, headers=HEADERS, timeout=30)
r.raise_for_status()
body = r.json()
as_of, total_pages = body["reportDate"], body["totalPages"]
for row in body["data"]:
if row.get("assetCat") != "EC": # keep common equity; drops futures, money market, cash
continue
key = row.get("isin") or row.get("cusip")
if key and key != "000000000":
weights[key] += row["pctVal"] # sum lots of the same security
page += 1
return as_of, weights
def directional_overlap(symbol1, symbol2, report_date=None):
d1, w1 = equity_weights(symbol1, report_date)
d2, w2 = equity_weights(symbol2, report_date)
shared = w1.keys() & w2.keys()
return {
"as_of": (d1, d2),
"shared": len(shared),
"weight1_in_shared": sum(w1[k] for k in shared),
"weight2_in_shared": sum(w2[k] for k in shared),
"sum_of_min": sum(min(w1[k], w2[k]) for k in shared),
}This costs more requests than the overlap endpoint (one or more per fund instead of one per pair), but it lets you pin the report date, apply your own asset filter and choose the join key. A good pattern is to use /api/fund/overlap for the interactive answer and the holdings join as a nightly cross-check. Where the two disagree, check mappedCik on the unmatched rows: two rows with the same company CIK and different identifiers are one company.
An N-fund ETF overlap matrix in Python
Comparing more than two funds is a loop over pairs. For n funds there are n(n-1)/2 pairs, so four funds take six requests and ten funds take 45. Directional overlap needs a full matrix, because the cell for (A, B) differs from (B, A); sum-of-min is symmetric.
from itertools import combinations
import pandas as pd
def overlap_matrices(symbols):
"""Directional and sum-of-min overlap for every pair, one API call per pair."""
directional = pd.DataFrame(index=symbols, columns=symbols, dtype=float)
symmetric = pd.DataFrame(index=symbols, columns=symbols, dtype=float)
as_of = {}
for a, b in combinations(symbols, 2):
s = overlap_summary(a, b)
# Row = the fund being measured, column = the fund it is compared against.
# Cell = % of the row fund's net assets in names the column fund also holds.
directional.loc[a, b] = s["weight1_in_shared"]
directional.loc[b, a] = s["weight2_in_shared"]
symmetric.loc[a, b] = symmetric.loc[b, a] = s["sum_of_min"]
as_of[a], as_of[b] = s["report_date1"], s["report_date2"]
return directional.round(2), symmetric.round(2), as_of
funds = ["SCHD", "VYM", "DGRO", "HDV"]
directional, symmetric, as_of = overlap_matrices(funds)
print(as_of) # always print the report date behind every row
print(directional) # asymmetric: read across a row
print(symmetric) # symmetric: one number per pairRead the directional matrix across a row: each cell is the share of the row fund's net assets in names the column fund also holds. A high cell means the row fund is largely contained in the column fund, so for someone who already owns the column fund, adding the row fund brings few new companies. A row of high numbers marks a fund whose holdings are largely contained in the others. The diagonal stays empty on purpose: a fund's own weights add up to slightly less or more than 100% of net assets (SCHD's to 99.82%), so writing 100 there would be wrong. Before you publish a matrix, run the same checks as for a single pair on every cell: print the report dates, and compare your filtered shared count with overlapCount. For dividend funds in particular, look at the fees as well as the overlap; two funds that hold the same names at different expense ratios are where the ETF fee analysis API earns its place.
Verify the overlap against the SEC N-PORT filing
Every weight in the response is a pctVal from a public filing, so you can check any row by hand. The SEC describes the form on its Form N-PORT data sets page: “Form N-PORT is the reporting form used for monthly reports of registered management funds other than money market funds, and exchange-traded funds organized as unit investment trusts.” The public filings appear on EDGAR as form type NPORT-P.
The two filings behind this comparison:
- SCHD, period 2026-05-31, filed 2026-07-24, accession
0001410368-26-075040: primary_doc.xml on EDGAR. Search it for CUSIP747525103and you find Qualcomm with avalUSDof 6397222108.20 and apctValof 6.737733132342, the same asvalue1andweight1in the API row. The two futures rows are there too, with CUSIP 000000000 andassetCatDE. - VYM, period 2026-07-31, filed 2026-09-28, accession
0001004655-26-000453: primary_doc.xml on EDGAR. VYM is the ETF share class of Vanguard High Dividend Yield Index Fund (series S000014011), and N-PORT reports at the series level, so the weights are a percent of the whole fund's net assets. That is the same for every share class, so the overlap does not depend on it.
To find the filing for any other fund, search the fund or its trust on EDGAR search and pick the NPORT-P whose period matches reportDate1 or reportDate2. The guide to mapping fund CIKs to tickers explains how trusts, series and share classes fit together when the ticker is not the filer.
The overlap endpoint is on the ETF plan ($59/month, or $39/month billed annually) and the Professional plan ($99/month, or $69/month billed annually). to explore fund profiles first; the free tier needs no credit card.
FAQ
Q.What is ETF overlap and how is it calculated?
Q.Is there an API for ETF overlap?
Q.How do I calculate ETF overlap in Python?
Q.Why is ETF overlap different depending on which fund you start from?
Q.Where does the ETF overlap data come from?
Q.How current is ETF overlap data?
Q.Why do futures and cash rows distort ETF overlap?
Q.Which plan includes the ETF overlap API?
Ready to build?
Free API key, no credit card.