All Posts
TutorialETF OverlapN-PORTPython

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.

Published October 25, 202618 min readStockFit Engineering
ETF Overlap API: Compare Two ETFs in Python

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)ValueQuestion it answers
Shared holdings80How many names do they have in common?
SCHD weight in shared names99.09%I own VYM. Does adding SCHD buy me anything new?
VYM weight in shared names21.42%I own SCHD. Does adding VYM buy me anything new?
Sum-of-min overlap21.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.

SCHD vs VYM: two directions, two answers
Source: StockFit /api/fund/overlap?symbol1=SCHD&symbol2=VYM, fields overlapWeight1 and overlapWeight2, as of 2026-10-25.

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:

bash
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:

json
{
  "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, onlyIn1Count and onlyIn2Count split 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.
  • overlapWeight1 and overlapWeight2 are the two directional overlaps, in percent of each fund's net assets.
  • reportDate1 and reportDate2 are 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.

SCHD vs VYM: the same names at different weights
Source: StockFit /api/fund/overlap?symbol1=SCHD&symbol2=VYM, first five rows of the response, N-PORT pctVal, as of 2026-10-25. SCHD report date 2026-05-31, VYM report date 2026-07-31.

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).

python
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}%")
text
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 reportDate1 and reportDate2 with 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 reportDate and 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:

json
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.

python
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.

python
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 pair

Read 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 CUSIP 747525103 and you find Qualcomm with a valUSD of 6397222108.20 and a pctVal of 6.737733132342, the same as value1 and weight1 in the API row. The two futures rows are there too, with CUSIP 000000000 and assetCat DE.
  • 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?
ETF overlap measures how much two funds hold the same stocks. By count, it is shared holdings divided by a total. By weight, it is directional: add up fund A's weights in the names fund B also holds, and separately B's weights in the names A holds. The common single score, sum-of-min, adds up the smaller of the two weights for every shared name. For SCHD and VYM the directional numbers are 99.09% and 21.42%, and sum-of-min is 21.42%.
Q.Is there an API for ETF overlap?
Yes. StockFit's GET /api/fund/overlap takes two fund tickers and returns the shared, only-in-first and only-in-second counts, both directional overlap weights, the report date of each fund's filing and one row per holding with its weight in each fund. The data comes from each fund's latest Form N-PORT filing, matched by CUSIP.
Q.How do I calculate ETF overlap in Python?
Call /api/fund/overlap with requests, keep the holdings rows whose overlapStatus is “both”, and drop rows with an empty or all-zero CUSIP. Sum weight1 and weight2 over those rows for the two directional overlaps, and sum min(weight1, weight2) for the symmetric sum-of-min score. For more than two funds, loop over itertools.combinations and fill a matrix.
Q.Why is ETF overlap different depending on which fund you start from?
Because each fund's side uses its own weights. SCHD holds about a hundred stocks at large weights; VYM holds about six hundred, including all 80 shared names, at smaller weights. So 99.09% of SCHD's net assets sit in names VYM holds, but only 21.42% of VYM's sit in names SCHD holds.
Q.Where does the ETF overlap data come from?
From SEC Form N-PORT, the portfolio report registered funds file with the SEC. The overlap endpoint compares each fund's most recent public N-PORT holdings. Every weight is the filing's pctVal, so you can check a row against the filing on EDGAR: SCHD's Qualcomm weight of 6.737733132342% appears in accession 0001410368-26-075040.
Q.How current is ETF overlap data?
As current as each fund's latest public N-PORT report, and the two funds can differ. Public holdings come once per fiscal quarter for each fund, and quarters differ by fund: the SCHD vs VYM comparison sets SCHD as of 2026-05-31 against VYM as of 2026-07-31. Read reportDate1 and reportDate2 on every response, or pin a date with the reportDate parameter of /api/fund/holdings.
Q.Why do futures and cash rows distort ETF overlap?
N-PORT lists futures with the placeholder CUSIP 000000000, so two funds that both hold futures look like they share a holding when you join on CUSIP. Cash sweep funds can have no CUSIP, or a real one that many funds share. Drop empty and all-zero CUSIPs before summing, or filter the holdings endpoint to assetCat EC (common equity).
Q.Which plan includes the ETF overlap API?
The ETF plan, $59/month or $468/year ($39/month billed annually), and the Professional plan, $99/month or $828/year ($69/month billed annually). The free tier includes fund profiles, so you can check a fund's report dates and holding count before subscribing.

Ready to build?

Free API key, no credit card.