Aggregating sector and country exposure across a portfolio of funds
Weight, normalise and merge each fund's sector and geography exposures into a portfolio view, handle classification mismatches and see what look-through misses.Published 12 September 2026 · 5 min read · by FundFacts APIA portfolio of six funds is really a portfolio of several thousand securities. To know how much of it is in technology, or in Japan, you cannot read six factsheets and eyeball it; you need to weight each fund's exposure by its position size and add them up. This is look-through aggregation. The arithmetic is simple. The hard parts are the classification schemes that do not agree with each other, the "Other" buckets, and knowing what the result does not tell you.
The arithmetic
Let w_i be the weight of fund i in the portfolio (position value divided by total value, so the weights sum to 1) and e_i(b) the fund's exposure to bucket b, a sector or a country, in percent. The portfolio exposure to b is the weighted sum:
textE(b) = sum over i of w_i * e_i(b)w_i = value_i / (sum over j of value_j)
If every fund's exposures sum to 100% and the portfolio weights sum to 1, the portfolio exposures sum to 100% too. In practice neither holds exactly, which is where the work starts.
The input
For each ISIN, FundFacts API returns data.sector and data.geography, each an array of { label, weight } with weights in percent, read from the documents the fund house publishes. Example values:
json{"sector": [{ "label": "Information Technology", "weight": 24.8 },{ "label": "Financials", "weight": 15.1 },{ "label": "Health Care", "weight": 11.2 },{ "label": "Other", "weight": 3.4 }],"geography": [{ "label": "United States", "weight": 68.9 },{ "label": "Japan", "weight": 6.0 },{ "label": "Europe ex UK", "weight": 12.3 }]}
Three things to notice: the weights may not sum to exactly 100 (rounding, cash, unclassified positions); the labels are the fund house's own words; and the geography array can mix countries with regions. Each of these needs a rule before you add anything together.
Normalising fund weights
There are two defensible choices when a fund's exposures sum to, say, 96.6%:
- Rescale to 100. Divide each weight by the total. Simple, but it silently attributes the missing 3.4% to the classified buckets in proportion, which overstates them.
- Keep the residual explicit. Add
100 - totalto anUnclassifiedbucket. Slightly uglier, more honest.
Prefer the second for anything a client will see. Either way, apply the same rule to every fund and state it in the output. Never mix the two. A fund house's own "Other" bucket should be treated the same way: it is an unclassified remainder, not a sector.
Classification mismatch
This is the real problem. Fund houses do not all use the same sector scheme:
| Scheme | Maintained by | Top level | Where you meet it |
|---|---|---|---|
| GICS | MSCI and S&P Dow Jones Indices | 11 sectors (Information Technology, Communication Services, Real Estate, ...) | Funds tracking MSCI or S&P indices, most US-oriented factsheets |
| ICB | FTSE Russell | 11 industries, aligned more closely with GICS since its 2021 revision but with different names and boundaries in places | Funds tracking FTSE indices |
| House categories | Each fund house | Anywhere from 8 to 20 buckets, with labels like "Technology", "Banks", "Consumer", "Cash & Derivatives" | Active funds, older factsheets, bond funds |
Even between GICS and ICB the same company can sit in different top-level buckets, and labels differ ("Information Technology" versus "Technology", "Consumer Staples" versus "Consumer Non-Cyclicals" in some house schemes). Between an equity scheme and a bond scheme (government, corporate, securitised, by rating) there is no mapping at all.
The workable approach:
- Choose a canonical taxonomy for your product. A GICS-like set of 11 sectors is the usual pick for equities.
- Build a mapping table from normalised source labels (lower case, trimmed, punctuation removed) to canonical buckets.
- Send anything unmapped to
Unclassifiedand log it, so the table grows over time. - Group by asset class first (
keyFacts.assetClass) and only merge sector exposures within equities. Bond and multi-asset funds get their own tables.
Geography has the same issue at a different level. "Europe ex UK" is not a country; "Emerging Markets" is not a region in the same sense as "Asia Pacific". Decide whether your canonical level is country or region, map to it, and never add a country and the region that contains it. profile.regionFocus and data.region help when a fund only publishes regions.
A TypeScript merge function
typescripttype Exposure = { label: string; weight: number };type Position = { isin: string; value: number };const SECTOR_MAP: Record<string, string> = {"information technology": "Information Technology","technology": "Information Technology","financials": "Financials","banks": "Financials","health care": "Health Care","healthcare": "Health Care","communication services": "Communication Services","telecommunications": "Communication Services","cash": "Cash","cash & derivatives": "Cash",};const normalise = (s: string) => s.toLowerCase().replace(/[^a-z& ]/g, "").trim();export function mergeExposures(positions: Position[],exposuresByIsin: Record<string, Exposure[]>,map: Record<string, string> = SECTOR_MAP,): Exposure[] {const total = positions.reduce((s, p) => s + p.value, 0);const out = new Map<string, number>();const add = (label: string, w: number) => out.set(label, (out.get(label) ?? 0) + w);for (const p of positions) {const w = p.value / total;const rows = exposuresByIsin[p.isin] ?? [];let classified = 0;for (const { label, weight } of rows) {const bucket = map[normalise(label)] ?? "Unclassified";add(bucket, w * weight);classified += weight;}add("Unclassified", w * Math.max(0, 100 - classified));}return [...out.entries()].map(([label, weight]) => ({ label, weight: Math.round(weight * 100) / 100 })).filter((e) => e.weight > 0).sort((a, b) => b.weight - a.weight);}
The function keeps the residual explicit, sends a fund house's "Other" to Unclassified because it is not in the map, and treats a fund with no exposure data at all as 100% unclassified rather than dropping it. Loading the inputs is one request per ISIN:
typescriptasync function loadExposures(isins: string[]) {const out: Record<string, Exposure[]> = {};for (const isin of isins) {const res = await fetch(`https://fundfactsapi.com/api/v1/funds/${isin}`, {headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },});if (!res.ok) throw new Error(`${isin}: ${res.status}`);const { data } = await res.json();out[isin] = data.sector ?? [];}return out;}
Responses are cached for 24 hours, so a portfolio of twenty funds costs twenty requests the first time and returns in milliseconds after that. On the free plan that is two days of quota; paid plans raise the daily limit.
What look-through does and does not capture
Aggregated exposure is a useful approximation. Know its limits:
- Derivatives. A fund holding index futures to equitise cash, or a synthetic ETF whose return comes from a swap, reports the exposure of the index it targets, not of the securities it holds. That is the economically right view for market risk, but the counterparty and collateral picture is invisible. Check the replication method before trusting the sector table of a synthetic fund.
- Fund-of-funds. If a position is itself a fund that holds other funds, its
sectorarray is the fund house's own look-through, computed on their date with their scheme. To go one level deeper you need the underlying funds' ISINs, which appear intopHoldingswhen the fund house publishes them. - Currency hedging. A currency-hedged share class has the same sector and country exposure as its unhedged sibling but a very different currency exposure. Country exposure is not currency exposure.
- Reporting dates. Each fund's exposures are as of its own
dataAsOf. In a fast-moving market, one fund's month-end and another's quarter-end can be weeks apart. - Multinationals. Country of listing or domicile is not where revenue comes from. A "United States" bucket of 65% is a statement about where the companies are listed.
- Overlap. Two funds each holding a large position in the same company show up as sector exposure but not as issuer concentration. For that you need top-holdings aggregation, covered in the holdings API guide.
Putting it in a product
The portfolio look-through use case shows the end-to-end flow: take a list of ISINs and weights, load each factsheet, merge sectors and countries, render two charts. The glossary entries for sector exposure, country exposure and look-through are worth linking from the output so users understand the caveats above.