Automating fund factsheets: from PDF scraping to structured JSON
Why parsing factsheet PDFs breaks, what a normalised fund data schema looks like, and how wealth managers and fintechs generate client-ready factsheets automatically.Published 22 July 2026 · 3 min read · by FundFacts APIEvery wealth manager has a version of the same script: download this month's factsheets, copy the numbers into a spreadsheet, paste them into the client report. Every fintech has tried to automate it with a PDF parser and regretted it. This article explains why factsheet scraping fails, what "structured fund data" should actually mean, and how teams replace the whole pipeline with one API call per ISIN.
Why PDF parsing does not scale
Factsheets are designed for humans. The same fact appears as "Fund size", "Net assets", "AUM" or "Total net assets"; the same table is sometimes two columns and sometimes four; the layout changes with every rebrand. Concretely, a scraper has to cope with:
- Layout drift. Fund houses redesign factsheets every one to two years, and each redesign silently breaks column detection.
- Unit ambiguity. "151.7" — millions or billions? USD or the share-class currency? The unit lives in a header three lines away.
- Locale.
1.234,5versus1,234.5; dates as25/09/2009orSep 25, 2009. - Missing panels. A bond fund has a credit-quality table where an equity fund has a sector table, in the same position on the page.
- Timing. Factsheets lag month-end by two to four weeks; the PDF you parse today may describe a portfolio from six weeks ago.
Each of these is solvable in isolation. Together, across a universe of a few hundred funds from thirty fund houses, they turn into a maintenance job for a full-time engineer.
What a normalised schema looks like
The alternative is a single schema, filled for every fund regardless of who issues it or what asset class it holds. That is the contract FundFacts API exposes:
json{"keyFacts": { "assetClass": "Equities", "currency": "USD", "aum": "USD 151.7bn", "inception": "Sep 25, 2009", "distribution": "Accumulating", "holdings": 1344 },"riskRating": 4,"profile": { "category": "Global Blend Equity", "valuation": "blend", "concentration": "diversified", "regionFocus": "Global", "regionTilt": "North America" },"topHoldings": [{ "name": "NVIDIA Corp", "weight": 5.43 }],"sector": [{ "label": "Technology", "weight": 30.5 }],"geography": [{ "label": "North America", "weight": 75.54 }],"creditQuality": [],"maturity": [],"calendarReturns": { "years": ["2024", "2025"], "fund": [18.7, 21.2] },"annualisedReturns": [{ "label": "5 Years p.a.", "fund": 11.45, "index": 10.98 }],"headlineMetrics": { "ter": "0.20%", "volatility3y": "11.9%", "sharpe3y": "1.21" },"metrics": { "peRatio": "18.7x", "maxDrawdown": "-9.29%" }}
The design choices that make it usable:
- Same keys for every fund. Panels that do not apply are empty, not absent. One template renders equity, bond, multi-asset and money-market funds.
- Breakdowns as `{ label, weight }`. One donut component, one bar component.
- Formatted values keep their unit.
"USD 151.7bn"is unambiguous;151.7is not. - Numbers you chart are numbers. Returns and weights are numeric so you can compute without parsing.
- Provenance dates.
dataAsOf(what the fund published) andgeneratedAt(when it was refreshed) travel with the payload.
The new pipeline
textlist of client ISINs ──► nightly job: GET /api/v1/funds/{isin} ──► store JSON ──► render template ──► PDF / web factsheet
- The nightly job keeps every ISIN inside the API's 24-hour freshness window and makes daytime rendering instant.
- Rendering is a template over the schema — HTML + print CSS is enough, and inline SVG charts print crisply.
- Because the schema is stable, a redesign of the *fund house's* factsheet changes nothing in *your* factsheet.
A minimal factsheet template
tsxexport function Factsheet({ fund }: { fund: FundResponse }) {const d = fund.data;return (<article className="factsheet"><header><h1>{fund.name}</h1><p>{fund.isin} · {d.keyFacts.assetClass} · {d.keyFacts.currency}</p></header><section className="facts"><dl><dt>Fund size</dt><dd>{d.keyFacts.aum}</dd><dt>Ongoing charges</dt><dd>{d.headlineMetrics.ter}</dd><dt>Inception</dt><dd>{d.keyFacts.inception}</dd><dt>Risk indicator</dt><dd>{d.riskRating} / 7</dd></dl></section><section className="exposure"><Donut title="Sectors" slices={d.sector} /><Bars title="Top holdings" items={d.topHoldings.map((h) => ({ label: h.name, weight: h.weight }))} /></section><section className="performance"><GrowthChart points={d.indexedPerformance.points} /><ReturnsTable rows={d.annualisedReturns} /></section><footer>Data as of {d.dataAsOf || fund.generatedAt.slice(0, 10)}. Past performance is not a guide to future returns.</footer></article>);}
Compliance considerations
- Keep the as-of date and the currency on the page; both are required in most jurisdictions.
- Show calendar-year returns for at least five years where available, not just cumulative figures.
- Add the standard past-performance disclaimer and the risk indicator explanation.
- Store the raw JSON you rendered from. When a client questions a number a year later, you can reproduce the page.
Where to start
Take five ISINs from a real client portfolio, create a free key, and request them from the dashboard. If the data object covers what your current factsheet shows — for most equity, bond and multi-asset funds it does — the rest is a template. The field reference lists every key; the comparison-tool tutorial covers caching and charting.