How to calculate holdings overlap between two ETFs (formula, code and an API)
The min-weight overlap formula, name matching, why top-ten lists give a lower bound, and how to get the figure from one call to the overlap endpoint.Published 17 September 2026 · 5 min read · by FundFacts APIThe overlap between two ETFs is the sum, over every holding they share, of the smaller of the two weights. If fund A holds a stock at 4% and fund B holds it at 3%, that name contributes 3 points; add the contributions for all shared names and you get the overlap in percent, from 0 (nothing in common) to 100 (identical portfolios). Computing it needs each fund's holdings with weights, a way to match the same company across two lists, and honesty about what is not disclosed. This post gives the formula, the matching rules, a working implementation and the single-request version using the overlap endpoint.
Why overlap matters
Holding two "different" global ETFs is a common way to end up with one portfolio twice. A world index and an all-world index share most of their large-cap names; a technology sector fund and a growth fund can share their entire top ten. Overlap is the number that turns that intuition into a figure, and it feeds three practical decisions: whether adding a fund adds diversification, how concentrated a multi-fund portfolio really is in a few names, and whether two products a client holds are duplicates.
The formula
Take the two funds' holdings as maps from a holding key to a weight in percent. Then:
- Overlap = Σ over shared keys of min(wA, wB).
- Shared count = number of keys present in both maps.
- Disclosed share = Σ wA over all keys in A (and the same for B). This tells you how much of each portfolio the computation could see.
Min-weight is the standard definition because it measures the common part of the two portfolios: the money that is invested in the same thing on both sides. Two alternatives you will meet are the count of shared names (which ignores weights, so ten shared microcaps count as much as ten shared megacaps) and a cosine similarity of the weight vectors (which is symmetric and bounded but not interpretable as "percent of the portfolio in common"). Min-weight overlap is what investors mean by "these funds overlap by 60%".
A small example:
| Holding | Fund A weight | Fund B weight | Contribution |
|---|---|---|---|
| NVIDIA | 5.0 | 3.8 | 3.8 |
| Apple | 4.6 | 3.5 | 3.5 |
| Microsoft | 4.2 | 3.4 | 3.4 |
| Taiwan Semiconductor | 0.0 | 1.9 | 0.0 (not in A) |
| Overlap | **10.7** |
Three shared names contribute 10.7 points. Across a full holdings list the figure for two broad developed-market trackers would be far higher; across a top-ten list it is exactly the sum of the shared top-ten weights, and nothing more.
Matching holdings by name
Fund documents list holdings by name, not by ISIN, and names differ between issuers: "Apple Inc", "APPLE INC", "Apple", "Apple Inc. (Common Stock)". Two ETFs holding the same share class of the same company will not match on raw strings. The pragmatic approach is a normalised key:
- Upper-case, strip accents and punctuation.
- Remove corporate suffixes: INC, CORP, CORPORATION, LTD, PLC, SA, AG, NV, CO, HOLDINGS, GROUP, and the like.
- Remove share-class and instrument noise: "CLASS A", "ORD", "COMMON STOCK", "ADR", "REG S".
- Collapse whitespace.
This is issuer-level matching. It treats Alphabet class A and class C as the same holding, which for overlap purposes is what you want (same company), and it treats a bond fund's several Apple bonds as one line. It will not disambiguate genuinely different companies with similar names, so keep the shared list visible so a reader can spot a bad match. The holdings API post explains what the topHoldings array contains per asset class.
Implementation
Given two fund payloads from GET /api/v1/funds/{isin}, the computation is short:
typescripttype Holding = { name: string; weight: number };const SUFFIX = /\b(INC|INCORPORATED|CORP|CORPORATION|LTD|LIMITED|PLC|SA|AG|NV|SE|CO|HOLDINGS?|GROUP|CLASS [A-C]|ORD|COMMON STOCK|ADR|REG S)\b/g;function key(name: string): string {return name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").replace(SUFFIX, " ").replace(/\s+/g, " ").trim();}function toMap(holdings: Holding[]): Map<string, { label: string; weight: number }> {const m = new Map<string, { label: string; weight: number }>();for (const h of holdings) {if (!h?.name || typeof h.weight !== "number") continue;const k = key(h.name);if (!k) continue;const cur = m.get(k);if (cur) cur.weight += h.weight; // several lines of one issuer collapse into oneelse m.set(k, { label: h.name, weight: h.weight });}return m;}export function overlap(a: Holding[], b: Holding[]) {const A = toMap(a), B = toMap(b);const shared: { label: string; a: number; b: number }[] = [];let total = 0;for (const [k, ha] of A) {const hb = B.get(k);if (!hb) continue;total += Math.min(ha.weight, hb.weight);shared.push({ label: ha.label, a: ha.weight, b: hb.weight });}shared.sort((x, y) => Math.min(y.a, y.b) - Math.min(x.a, x.b));const sum = (m: typeof A) => [...m.values()].reduce((s, h) => s + h.weight, 0);return { overlap: total, sharedCount: shared.length, shared, disclosed: { a: sum(A), b: sum(B) } };}
Feed it data.topHoldings from the two responses and you have the number. Keep disclosed next to it: an overlap of 12% between two funds that each disclosed 15% of their portfolio means something very different from 12% between two funds that disclosed 100%.
The lower-bound problem
Most factsheets print the top ten. Some issuers publish the full holdings file daily, and for those FundFacts returns the full list in topHoldings; for the rest, the array is what the factsheet shows. The overlap you compute from a top-ten list is a lower bound on the true overlap, because every shared holding outside the top ten is invisible. It is still useful (it is exact for the disclosed part), but it must be labelled. The API's overlap response includes a note saying exactly this, and disclosed.a and disclosed.b quantify how much was visible. For synthetic ETFs the list may be the substitute basket rather than the index constituents; the replication post explains why that makes overlap with a physical tracker look low.
One request instead of two plus code
The overlap endpoint does the above for two or more ISINs and returns every pair:
bashcurl "https://fundfactsapi.com/api/v1/overlap?isins=IE00B4L5Y983,IE00B3RBWM25" \-H "Authorization: Bearer ffk_live_..."
json{"funds": [{ "isin": "IE00B4L5Y983", "name": "iShares Core MSCI World UCITS ETF USD (Acc)", "holdings": 10 },{ "isin": "IE00B3RBWM25", "name": "Vanguard FTSE All-World UCITS ETF (USD) Distributing", "holdings": 10 }],"pairs": [{"a": "IE00B4L5Y983","b": "IE00B3RBWM25","overlap": 18.4,"sharedCount": 9,"shared": [{ "label": "NVIDIA Corp", "a": 5.1, "b": 4.3 }],"disclosed": { "a": 24.9, "b": 21.7 }}],"pending": [],"notFound": [],"note": "Overlap is computed over the holdings each fund discloses and matched by issuer name; funds that publish only their top ten will show a lower bound.","rules": "fundfacts-portfolio/1","generatedAt": "2026-09-17T08:00:00.000Z","plan": "pro","quota": { "limit": 9000, "remaining": 8998, "used": 2, "overageUsed": 0, "resetAt": "2026-10-01T00:00:00.000Z", "burst": { "limit": 120, "remaining": 119 } }}
Figures in the example are placeholders for the shape; the live numbers depend on each fund's current disclosure. The parts to note:
- One request per ISIN with an answer;
pendinglists ISINs still loading for the first time (re-request in a minute),notFoundlists ISINs without published data. rulesversions the matching and formula, so results are reproducible and a change in method is visible.- The plan's batch size caps how many ISINs one call may include: Free 1 (so overlap needs Starter or above), Starter 10, Pro 50, Scale 200. Ten ISINs yield 45 pairs in one request.
- The same computation is available to AI agents as the
fund_overlaptool on the MCP server.
Going beyond pairs
Overlap answers "how much do these two have in common". For a whole portfolio the more useful question is "what do I actually hold", which is a weighted union rather than an intersection: combine each fund's holdings weighted by the position size, then read off the top names, sectors and countries. That is portfolio look-through, available as POST /api/v1/portfolio and described in the exposure aggregation post. A high pairwise overlap and a concentrated look-through usually go together, and showing both is the clearest way to explain to a client why three funds are behaving like one.
To try it on your own pair, use the free overlap checker or get a key and call the endpoint.
FAQ
What is a good or bad ETF overlap percentage?
There is no fixed threshold. Two broad global trackers overlapping by most of their portfolio is expected and harmless; two funds bought for diversification that share their entire top ten are duplicates. Read the overlap together with the disclosed share of each portfolio and with the look-through concentration of the combined holdings.
Why is my computed overlap so low for two similar funds?
Usually because one or both funds disclose only their top ten holdings, so shared names outside the top ten are invisible and the result is a lower bound. A second cause is name matching: the same company printed differently by two issuers will not match unless names are normalised. A third is synthetic replication, where the holdings list is a substitute basket rather than the index.
Can I compute overlap between more than two funds?
Overlap is defined pairwise. For a set of funds, compute every pair (n funds give n(n−1)/2 pairs) or switch to portfolio look-through, which aggregates the weighted holdings of all positions into one list. The API's overlap endpoint accepts up to the plan's batch size and returns every pair in one response.
Does the overlap endpoint use ISINs of the holdings?
No. Fund documents list holdings by name, so matching is by normalised issuer name. This merges share classes and several bonds of one issuer into a single line, which is the right granularity for an overlap figure, and the shared list is returned so mismatches can be checked.