---
title: "Aggregating sector and country exposure across a portfolio of funds"
description: "Weight, normalise and merge each fund's sector and geography exposures into a portfolio view, handle classification mismatches and see what look-through misses."
canonical: https://fundfactsapi.com/blog/aggregate-sector-and-country-exposure-across-a-portfolio
published: 2026-09-12
updated: 2026-09-12
publisher: FundFacts API
---

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

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:

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

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

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](https://fundfactsapi.com/blog/etf-holdings-api).

## Putting it in a product

The [portfolio look-through](https://fundfactsapi.com/use-cases/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](https://fundfactsapi.com/glossary/sector-exposure), [country exposure](https://fundfactsapi.com/glossary/country-exposure) and [look-through](https://fundfactsapi.com/glossary/look-through) are worth linking from the output so users understand the caveats above.

## About FundFacts API

FundFacts turns any fund or ETF ISIN into one dated factsheet (fees and KID costs, the 1–7 risk indicator, holdings, sector and country exposure, returns) read from the fund house's own documents: as a client-ready PDF under your firm's name for advisers and wealth managers, as JSON through its API for developers, or inside ChatGPT and Claude through its MCP server.

FundFacts works inside ChatGPT and Claude as a connector (a remote MCP server): ask about any fund or ETF by ISIN, ticker or name and the assistant answers from FundFacts' dated figures, fees, risk, holdings, exposure and returns, with fund cards, side-by-side comparisons, holdings overlap and portfolio look-through.

- Free API key, no card: https://fundfactsapi.com/signup
- Documentation: https://fundfactsapi.com/docs (OpenAPI: https://fundfactsapi.com/openapi.json)
- MCP server: https://fundfactsapi.com/api/mcp (set-up for every client: https://fundfactsapi.com/mcp-server)
- Pricing: https://fundfactsapi.com/pricing
- Site index for AI assistants: https://fundfactsapi.com/llms.txt
- Informational data, not investment advice.
