FMP
Jan 06, 2026
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.
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.
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.
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:
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.
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.
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:
|
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:
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.
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.
|
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]) |
|
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)) |
|
# 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.
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:
|
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.
|
llm_input_with_risk = dict(llm_input) llm_input_with_risk["risk_signals"] = risk_signals |
|
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)) |
|
# 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:
An LLM-powered ETF analyzer improves explainability, but it still requires clear boundaries and guardrails. Understanding these limits keeps the system reliable and defensible.
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.
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. """ |
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.
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.
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.
Introduction In corporate finance, assessing how effectively a company utilizes its capital is crucial. Two key metri...
Bank of America analysts reiterated a bullish outlook on data center and artificial intelligence capital expenditures fo...
Pinduoduo Inc., listed on the NASDAQ as PDD, is a prominent e-commerce platform in China, also operating internationally...