FMP

FMP

LLM-Powered ETF Analyzer: Explain ETF Holdings, Risks, & Exposure Using FMP ETF Endpoints

ETFs look simple on the surface. A single ticker promises instant diversification, low cost, and broad exposure. In reality, every ETF hides a complex mix of holdings, sector bets, and geographic risk that most investors never fully examine.

Fact sheets list top holdings and percentages, but they rarely explain what those numbers mean. Two ETFs with similar names can behave very differently depending on concentration, overlapping stocks, or hidden regional exposure. Reading raw holdings tables does not scale, especially when portfolios contain hundreds of constituents.

In this article, we build an LLM-powered ETF analyzer using structured data from Financial Modeling Prep ETF endpoints. We fetch ETF profiles, holdings, and exposure data, then use a large language model to explain portfolio composition, risk concentration, and thematic exposure in plain language.

The goal is not to predict returns. The goal is explainability. This system turns raw ETF data into clear, defensible insights that help investors understand what they actually own when they buy an ETF.

FMP Data Sources Behind the Article

  • ETF Profile API - provides high-level ETF metadata such as name, expense ratio, issuer, and investment strategy
  • ETF Holdings API - exposes the underlying constituents and their weights, forming the core of holdings and concentration analysis
  • ETF Sector Exposure API - breaks down portfolio allocation by sector to assess thematic and concentration risk
  • ETF Country Exposure API - shows geographic exposure to evaluate regional and macro risk

Why ETF Analysis Needs More Than Fact Sheets

ETF fact sheets optimize for compliance, not understanding. They list top holdings, expense ratios, and benchmark names, but they stop short of explaining how those pieces interact or where real risk concentrates.

Holdings tables show percentages, but they do not answer practical questions. An investor still struggles to see whether performance depends on a handful of stocks, a single sector, or one geographic region. Two ETFs can share a theme yet behave very differently because of concentration and exposure differences hidden in the raw data.

Risk also evolves over time. Sector weights drift, country exposure changes, and dominant holdings grow larger during strong market cycles. Static PDFs fail to capture these dynamics in a way that helps decision-making.

An LLM changes how investors consume ETF data. Instead of reading tables, they receive explanations. Instead of scanning percentages, they see concentration risk, thematic exposure, and dependency patterns described clearly. When you combine structured ETF data with an LLM, ETF analysis moves from what the fund holds to what the fund is really exposed to.

Overview of FMP ETF Endpoints

Financial Modeling Prep exposes a set of ETF-specific endpoints that deliver structured, analysis-ready data. These endpoints remove the need for scraping PDFs or manually parsing provider websites.

At the foundation is the ETF Profile endpoint, which describes the fund's objective, issuer, expense ratio, and benchmark. This metadata anchors the analysis and helps the LLM frame explanations in the right strategic context.

The ETF Holdings endpoint provides the full list of constituents with weights. This dataset drives concentration analysis, overlap detection, and dependency checks. Because the holdings arrive in a clean tabular format, you can sort, filter, and compress them before passing them to an LLM.

To understand exposure beyond individual stocks, FMP also offers sector and country exposure endpoints. These datasets quantify how much of the portfolio depends on specific industries or regions.

Together, these endpoints form a complete ETF data layer. They give the LLM factual inputs it can explain, summarize, and reason over—without inventing details or relying on vague descriptions.

It is also important to note that some ETFs may include exposures—such as derivatives, cash positions, or non-equity instruments—that are not fully reflected in standard holdings or sector and country exposure datasets. This analyzer focuses on the dominant, disclosed portfolio components, which capture the primary drivers of risk and behavior for most equity-based ETFs.

Designing the LLM-Powered ETF Analysis Pipeline

A reliable ETF explainer starts with structure, not with a prompt. The pipeline must control what data goes in, how it gets summarized, and what the LLM is allowed to explain. This design keeps outputs factual and consistent.

The pipeline begins by fetching ETF profile, holdings, sector exposure, and country exposure data from FMP. Each dataset serves a distinct role. Profile data sets context. Holdings reveal concentration. Sector and country data expose thematic and geographic risk.

Next, the system normalizes and compresses the data. Large ETFs may hold hundreds of stocks, which exceed LLM token limits. The pipeline ranks holdings by weight, groups smaller positions, and converts tables into compact summaries the model can reason over without losing signal.

This compression introduces a deliberate tradeoff. Grouping smaller holdings improves usability, keeps prompts within token limits, and highlights the dominant drivers of risk. At the same time, it reduces granularity for the long tail of minor positions. For explainability-focused analysis, this tradeoff is intentional: the system prioritizes material exposure over exhaustive detail.

The LLM then operates strictly as an explainer, not a data source. It receives structured inputs and produces natural-language explanations describing portfolio composition, dominant risks, and exposure patterns. The model does not calculate weights or infer missing values.

Finally, the pipeline outputs three explainable artifacts:

  • A clear summary of what the ETF holds
  • A risk-focused explanation of concentration and dependency
  • An exposure narrative covering sectors and regions

This design keeps the system deterministic where accuracy matters and generative where explanation adds value. In the next section, we'll fetch ETF data from FMP using Python and prepare it for LLM consumption.

Fetching ETF Data Using FMP

We'll pull four datasets from FMP and keep them in tidy DataFrames:

To use these endpoints, you'll need an API key from Financial Modeling Prep. You can generate one by creating an account and selecting a plan that includes ETF data access. Availability of specific ETF endpoints may vary by plan tier, so it's best to confirm coverage before integrating them into production workflows.

You can review plans and ETF data access details on the FMP pricing page:

import os

import requests

import pandas as pd


FMP_API_KEY = os.getenv("FMP_API_KEY") # export FMP_API_KEY="YOUR_KEY"

BASE_URL = "https://financialmodelingprep.com/stable"


def _get_json(url: str, params: dict):

if not FMP_API_KEY:

raise ValueError("Missing FMP_API_KEY env var.")

params = dict(params or {})

params["apikey"] = FMP_API_KEY


r = requests.get(url, params=params, timeout=30)

r.raise_for_status()

return r.json()


def fetch_etf_info(symbol: str) -> pd.DataFrame:

"""

Docs endpoint:

https://financialmodelingprep.com/stable/etf/info?symbol=SPY

"""

url = f"{BASE_URL}/etf/info"

data = _get_json(url, {"symbol": symbol})

return pd.DataFrame(data)


def fetch_etf_holdings(symbol: str) -> pd.DataFrame:

"""

Docs endpoint:

https://financialmodelingprep.com/stable/etf/holdings?symbol=SPY

"""

url = f"{BASE_URL}/etf/holdings"

data = _get_json(url, {"symbol": symbol})

return pd.DataFrame(data)


def fetch_etf_sector_weightings(symbol: str) -> pd.DataFrame:

"""

Docs endpoint:

https://financialmodelingprep.com/stable/etf/sector-weightings?symbol=SPY

"""

url = f"{BASE_URL}/etf/sector-weightings"

data = _get_json(url, {"symbol": symbol})

return pd.DataFrame(data)


def fetch_etf_country_weightings(symbol: str) -> pd.DataFrame:

"""

Docs endpoint:

https://financialmodelingprep.com/stable/etf/country-weightings?symbol=SPY

"""

url = f"{BASE_URL}/etf/country-weightings"

data = _get_json(url, {"symbol": symbol})

return pd.DataFrame(data)


def fetch_etf_bundle(symbol: str) -> dict:

"""

Pull everything needed for the LLM analyzer in one shot.

"""

return {

"info": fetch_etf_info(symbol),

"holdings": fetch_etf_holdings(symbol),

"sector": fetch_etf_sector_weightings(symbol),

"country": fetch_etf_country_weightings(symbol),

}


# Example usage (SPY as a default demo ETF)

bundle = fetch_etf_bundle("SPY")


for k, df in bundle.items():

print(f"{k}: shape={df.shape}")

display(df.head(3))

This gives you clean, structured ETF data directly from FMP. Next, we'll compress and structure holdings so the LLM can explain them without hitting token limits.

Structuring ETF Holdings for LLM Consumption

ETF holdings can easily exceed token limits. You don't want to dump 500 rows into a prompt. You want to compress the portfolio while preserving signal: top weights, concentration, and the long tail.

Below is a practical approach that works well:

  • Keep the top N holdings
  • Group the rest into “Other Holdings”
  • Compute concentration metrics (Top-10 weight, HHI)
  • Build a compact JSON payload the LLM can explain

import pandas as pd

import numpy as np


def _to_float(x):

try:

return float(x)

except Exception:

return np.nan


def normalize_holdings_df(holdings: pd.DataFrame) -> pd.DataFrame:

"""

Expected columns vary slightly across ETFs.

We try to standardize to: symbol, name, weight

"""

df = holdings.copy()


# Common column names seen in ETF holdings datasets

col_map = {

"asset": "symbol",

"ticker": "symbol",

"symbol": "symbol",

"name": "name",

"assetName": "name",

"holdingName": "name",

"weight": "weight",

"weightPercentage": "weight",

"holdingPercent": "weight",

}


# Rename only what exists

rename_dict = {}

for c in df.columns:

if c in col_map:

rename_dict[c] = col_map[c]

df = df.rename(columns=rename_dict)


# Ensure required columns exist

if "symbol" not in df.columns:

df["symbol"] = None

if "name" not in df.columns:

df["name"] = None

if "weight" not in df.columns:

# If weight column doesn't exist, you can still pass top holdings by market value

# but for this article we assume weight exists.

df["weight"] = np.nan


# Clean weight: accept 0-1 or 0-100, normalize to 0-1

df["weight"] = df["weight"].apply(_to_float)

# If it looks like percentages (e.g., max > 1.5), convert to fraction

if df["weight"].max(skipna=True) and df["weight"].max(skipna=True) > 1.5:

df["weight"] = df["weight"] / 100.0


df = df.dropna(subset=["weight"]).copy()

df = df[df["weight"] > 0].copy()


# Sort by weight descending

df = df.sort_values("weight", ascending=False).reset_index(drop=True)


return df[["symbol", "name", "weight"]]



def compute_concentration_metrics(df: pd.DataFrame, top_k: int = 10) -> dict:

"""

Metrics that help the LLM explain risk and dependency.

- top_k_weight: total weight of top K holdings

- hhi: Herfindahl-Hirschman Index (higher = more concentrated)

"""

w = df["weight"].to_numpy()

w = w / w.sum() # re-normalize for safety


top_k_weight = float(w[:top_k].sum())

hhi = float(np.sum(w ** 2)) # if weights sum to 1, HHI in (0,1]


return {

"top_k": top_k,

"top_k_weight": top_k_weight,

"hhi": hhi,

}



def compress_holdings_for_llm(holdings: pd.DataFrame, top_n: int = 25) -> dict:

"""

Returns a compact structure:

- top_holdings: list of top N holdings with weights

- other_weight: remaining weight grouped

- metrics: concentration stats

"""

df = normalize_holdings_df(holdings)


# Renormalize to 1.0 (handles rounding issues)

df["weight"] = df["weight"] / df["weight"].sum()


top = df.head(top_n).copy()

other_weight = float(max(0.0, 1.0 - top["weight"].sum()))


metrics = compute_concentration_metrics(df, top_k=min(10, len(df)))


payload = {

"top_holdings": [

{

"symbol": None if pd.isna(r["symbol"]) else str(r["symbol"]),

"name": None if pd.isna(r["name"]) else str(r["name"]),

"weight": float(r["weight"]),

}

for _, r in top.iterrows()

],

"other_holdings_weight": other_weight,

"metrics": metrics,

"total_holdings_count": int(df.shape[0]),

}


return payload



# Example usage: using the bundle from previous section

holdings_payload = compress_holdings_for_llm(bundle["holdings"], top_n=25)


holdings_payload["metrics"], holdings_payload["top_holdings"][:3], holdings_payload["other_holdings_weight"]

What you achieved here: you turned a raw holdings table into a small, explainable, LLM-ready object with:

  • The top holdings list
  • The long-tail grouped
  • Concentration stats the model can talk about confidently

Important limitation: The renormalization step assumes that the holdings dataset represents the full investable portfolio. In practice, some ETFs hold cash, derivatives, or other instruments that may not be fully reflected in equity holdings data. If those components are missing, renormalized weights can slightly overstate concentration metrics such as Top-10 weight or HHI. For this analyzer, the metrics should be interpreted as concentration within disclosed holdings, not as a complete balance-sheet-level exposure.

Using an LLM to Explain ETF Holdings and Strategy (with prompt + Python code)

Now you feed the LLM only structured facts (ETF info + compressed holdings + exposures). The model's job stays simple: explain what the ETF owns, how concentrated it is, and what the strategy really implies—without inventing data.

1) Build an explanation-ready payload

import json


def build_llm_input(bundle: dict, top_n: int = 25) -> dict:

info_df = bundle["info"]

info = info_df.iloc[0].to_dict() if len(info_df) else {}


holdings_payload = compress_holdings_for_llm(bundle["holdings"], top_n=top_n)


sector_df = bundle["sector"].copy()

country_df = bundle["country"].copy()


# Keep the payload small: only top sectors/countries by weight

def _standardize_weights(df: pd.DataFrame):

d = df.copy()

# Try to find likely column names

weight_col = None

for c in d.columns:

if c.lower() in ["weight", "percentage", "weightpercentage", "weight_percent", "exposure"]:

weight_col = c

break

if weight_col is None:

# fallback: pick a numeric column

num_cols = d.select_dtypes(include="number").columns.tolist()

weight_col = num_cols[0] if num_cols else None


name_col = None

for c in d.columns:

if c.lower() in ["sector", "country", "name"]:

name_col = c

break

if name_col is None:

name_col = d.columns[0] if len(d.columns) else None


if weight_col is None or name_col is None:

return []


d[weight_col] = pd.to_numeric(d[weight_col], errors="coerce")

d = d.dropna(subset=[weight_col]).sort_values(weight_col, ascending=False)


# normalize 0-100 → 0-1 if needed

if d[weight_col].max() > 1.5:

d[weight_col] = d[weight_col] / 100.0


return [{"name": str(r[name_col]), "weight": float(r[weight_col])} for _, r in d.iterrows()]


sectors = _standardize_weights(sector_df)[:10]

countries = _standardize_weights(country_df)[:10]


return {

"etf_symbol": info.get("symbol"),

"etf_name": info.get("name") or info.get("companyName"),

"expense_ratio": info.get("expenseRatio"),

"aum": info.get("aum"),

"description": info.get("description"),

"holdings": holdings_payload,

"top_sectors": sectors,

"top_countries": countries,

}


llm_input = build_llm_input(bundle, top_n=25)

print(json.dumps(llm_input, indent=2)[:1200])

2) Use a strict prompt that prevents hallucination

SYSTEM_PROMPT = """

You are a financial ETF analyst.

You MUST use only the provided JSON.

If a field is missing, say "Not provided in the dataset."

Do not guess tickers, sectors, or weights.

Explain clearly and concisely in active voice.

"""


USER_PROMPT_TEMPLATE = """

Given the following ETF dataset in JSON, produce an explanation with these sections:


1) ETF Summary (1 short paragraph)

2) What it holds (reference top holdings and their weights)

3) Concentration & dependency (explicitly cite Top-10 weight and HHI from holdings.metrics)

4) Sector exposure (top 3-5 sectors with weights)

5) Country exposure (top 3-5 countries with weights)

6) Plain-English risks (3-6 bullets, each grounded in numeric values from the dataset)


Rules:

- You MUST reference numeric values (weights, percentages, or metrics) in every section where applicable.

- Cite values exactly as provided in the JSON (do not estimate or round unless already rounded).

- If a numeric value is missing, explicitly say "Not provided in the dataset."

- Do not introduce external data or assumptions.


JSON:

{json_payload}

"""



def build_prompt(payload: dict) -> str:

return USER_PROMPT_TEMPLATE.format(json_payload=json.dumps(payload, ensure_ascii=False))

3) Call an LLM (OpenAI-compatible chat API)

# pip install openai

import os

from openai import OpenAI


def explain_etf_with_openai(payload: dict, model: str = "gpt-4.1-mini"):

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

prompt = build_prompt(payload)


resp = client.chat.completions.create(

model=model,

messages=[

{"role": "system", "content": SYSTEM_PROMPT},

{"role": "user", "content": prompt},

],

temperature=0.2,

)

return resp.choices[0].message.content


# explanation = explain_etf_with_openai(llm_input)

# print(explanation)

At this point, you get a clean ETF narrative grounded in FMP data.

Analyzing ETF Risk and Exposure with an LLM

Instead of asking the LLM to “figure out risk,” we compute hard risk signals from FMP data, then ask the LLM to explain them. This keeps the output grounded and repeatable.

We'll compute:

  • Top-10 concentration (already in holdings.metrics.top_k_weight)
  • HHI (already in holdings.metrics.hhi)
  • Single-holding dependency (largest holding weight)
  • Sector concentration flags (top sector weight)
  • Country concentration flags (top country weight)

1) Compute risk signals deterministically

import numpy as np


def compute_risk_signals(llm_input: dict) -> dict:

h = llm_input["holdings"]

metrics = h.get("metrics", {})

top_holdings = h.get("top_holdings", [])


# Largest holding weight (single-stock dependency)

max_holding_weight = float(top_holdings[0]["weight"]) if top_holdings else 0.0


# Top sector and country weights (concentration)

top_sector_weight = float(llm_input["top_sectors"][0]["weight"]) if llm_input.get("top_sectors") else 0.0

top_country_weight = float(llm_input["top_countries"][0]["weight"]) if llm_input.get("top_countries") else 0.0


top10 = float(metrics.get("top_k_weight", 0.0))

hhi = float(metrics.get("hhi", 0.0))


# Simple, explainable thresholds (you can tune for your article)

flags = {

"high_top10_concentration": top10 >= 0.50, # top10 >= 50%

"very_high_top10_concentration": top10 >= 0.65,

"high_single_stock_dependency": max_holding_weight >= 0.10, # >= 10%

"very_high_single_stock_dependency": max_holding_weight >= 0.20,

"high_sector_concentration": top_sector_weight >= 0.35, # top sector >= 35%

"high_country_concentration": top_country_weight >= 0.60, # top country >= 60%

"high_hhi": hhi >= 0.08, # rough heuristic

"very_high_hhi": hhi >= 0.12,

}


return {

"top10_weight": top10,

"hhi": hhi,

"max_holding_weight": max_holding_weight,

"top_sector_weight": top_sector_weight,

"top_country_weight": top_country_weight,

"flags": flags,

}


risk_signals = compute_risk_signals(llm_input)

risk_signals

It's important to note that sector and country weight columns are standardized using heuristic-based detection (for example, selecting likely weight and name columns from the dataset). While this approach works well across most ETFs, column naming and structure can vary by fund and provider. For production or high-stakes analysis, readers should validate the standardized sector and country outputs before passing them into downstream LLM explanations.

2) Attach risk signals back into the LLM payload

llm_input_with_risk = dict(llm_input)

llm_input_with_risk["risk_signals"] = risk_signals

3) Use a risk-focused prompt (LLM explains, does not compute)

RISK_PROMPT_TEMPLATE = """

You will explain ETF risk and exposure using ONLY the JSON provided.

You MUST cite the numeric values from risk_signals and holdings.metrics.

Do not add external facts.


Output these sections:


1) Concentration risk (Top-10 weight, max holding, HHI)

2) Sector concentration risk (top sectors and top sector weight)

3) Country concentration risk (top countries and top country weight)

4) What could go wrong (3-6 bullets grounded in the flags + values)

5) What this ETF is actually exposed to (1 short paragraph)


JSON:

{json_payload}

"""


def build_risk_prompt(payload: dict) -> str:

return RISK_PROMPT_TEMPLATE.format(json_payload=json.dumps(payload, ensure_ascii=False))

4) Call the LLM

# pip install openai

import os

import json

from openai import OpenAI


def explain_etf_risk_with_openai(

payload: dict,

model: str = "gpt-4.1-mini"

):

"""

Uses OpenAI-compatible chat API to explain ETF risk and exposure.

The model only explains values already present in the payload.

"""

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


prompt = build_risk_prompt(payload)


response = client.chat.completions.create(

model=model,

messages=[

{

"role": "system",

"content": SYSTEM_PROMPT

},

{

"role": "user",

"content": prompt

}

],

temperature=0.2, # low temperature for factual explanations

)


return response.choices[0].message.content



# Example usage

risk_explanation = explain_etf_risk_with_openai(

llm_input_with_risk,

model="gpt-4.1-mini"

)


print(risk_explanation)



This pattern gives you the best of both worlds:

  • Deterministic risk computation you can test
  • LLM explanation that stays grounded in numbers

Limitations and Improvement Areas

An LLM-powered ETF analyzer improves explainability, but it still requires clear boundaries and guardrails. Understanding these limits keeps the system reliable and defensible.

1) Token limits with large ETFs

Broad-market ETFs can hold hundreds or thousands of constituents. Even after compression, prompts can grow large.

Improvement: keep only top holdings, group the long tail, and pass precomputed concentration metrics instead of raw tables. You already applied this pattern in the pipeline.

2) Hallucination risk without strict prompts

LLMs may invent exposure details if prompts allow open-ended reasoning.

Improvement: enforce strict instructions that limit the model to provided JSON only, and require it to explicitly say when data is missing.

SYSTEM_PROMPT = """

You are a financial ETF analyst.

Use ONLY the provided JSON.

If a value is missing, say 'Not provided in the dataset.'

Do not infer, estimate, or guess.

"""

3) Static thresholds may not fit all ETFs

A 50% Top-10 concentration might be normal for thematic ETFs but risky for broad-market funds.

Improvement: tune thresholds by ETF category (broad market, sector, thematic, single-country) and store them as configuration rather than hardcoded values.

4) Explainability, not prediction

This system explains exposure and risk. It does not forecast performance or returns.

Improvement: keep messaging clear. Position the analyzer as a decision-support and transparency tool, not a predictive engine.

With these guardrails in place, the ETF analyzer stays factual, interpretable, and suitable for real investor workflows.

Conclusion

In this article, we built an LLM-powered ETF analyzer using structured ETF data from Financial Modeling Prep. Instead of relying on static fact sheets, we designed a system that explains ETF holdings, concentration risk, and exposure using verifiable data and controlled LLM reasoning.

By separating deterministic calculations from generative explanations, the pipeline stays accurate and auditable. FMP ETF endpoints provide the factual backbone, while the LLM turns those facts into clear, investor-friendly insights.

This approach does not predict returns. It improves transparency. With the right guardrails, LLMs can help investors understand what they own—and where real risk sits—before capital is at stake.