Look-throughPortfolioExposure

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 API

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

text
E(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 - total to an Unclassified bucket. 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:

SchemeMaintained byTop levelWhere you meet it
GICSMSCI and S&P Dow Jones Indices11 sectors (Information Technology, Communication Services, Real Estate, ...)Funds tracking MSCI or S&P indices, most US-oriented factsheets
ICBFTSE Russell11 industries, aligned more closely with GICS since its 2021 revision but with different names and boundaries in placesFunds tracking FTSE indices
House categoriesEach fund houseAnywhere 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:

  1. Choose a canonical taxonomy for your product. A GICS-like set of 11 sectors is the usual pick for equities.
  2. Build a mapping table from normalised source labels (lower case, trimmed, punctuation removed) to canonical buckets.
  3. Send anything unmapped to Unclassified and log it, so the table grows over time.
  4. 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

typescript
type 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:

typescript
async 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 sector array 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 in topHoldings when 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.

Try it on your own ISINs

One request returns key facts, holdings, risk and performance as JSON. Free plan, no card.