---
title: "Build a fund comparison tool in an afternoon with a fund data API"
description: "A practical architecture for a fund screener or comparison page: ISIN input, caching, side-by-side table, charts and a PDF export, with code in Next.js and Python."
canonical: https://fundfactsapi.com/blog/build-fund-comparison-tool
published: 2026-07-29
updated: 2026-07-29
publisher: FundFacts API
---

# Build a fund comparison tool in an afternoon with a fund data API

> A practical architecture for a fund screener or comparison page: ISIN input, caching, side-by-side table, charts and a PDF export, with code in Next.js and Python.

A side-by-side fund comparison is the feature clients ask for most and the one that used to need a data licence. With a fund data API that returns one schema for every ISIN, it fits in an afternoon. This post lays out the architecture, the pitfalls and the code.

## What we are building

A page where a user pastes two to five ISINs and gets:

1. A **comparison table**: fund size, TER, inception, risk indicator, 1/3/5-year returns, volatility, Sharpe.
2. A **growth-of-100 chart** with all funds on one axis.
3. **Exposure panels**: sector and geography donuts per fund, plus a holdings-overlap score.
4. A **PDF/print view** for advisors.

Everything comes from `GET /api/v1/funds/{isin}` on [FundFacts API](https://fundfactsapi.com/), so the backend is a thin proxy.

## Architecture

```text
Browser ──► /api/compare?isins=A,B,C ──► your server ──► FundFacts API (per ISIN, parallel)
                                          │
                                          └─ your cache (Redis / DB) keyed by ISIN, TTL 24h
```

Two rules:

- **Never call the fund API from the browser.** Your key would leak and every visitor would burn your quota. Proxy it.
- **Cache on your side too.** The API caches for 24 hours, but each call still counts against your plan. Storing the response next to `expiresAt` means a popular comparison costs you nothing after the first view.

## The server route (Next.js)

```typescript
// app/api/compare/route.ts
import { NextResponse } from "next/server";
import { cache } from "@/lib/cache"; // any KV with get/set(ttl)

const BASE = "https://fundfactsapi.com/api/v1/funds";

async function getFund(isin: string) {
  const hit = await cache.get(isin);
  if (hit) return hit;
  const res = await fetch(`${BASE}/${isin}`, {
    headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },
    signal: AbortSignal.timeout(300_000),
  });
  if (!res.ok) throw new Error(`${isin}: ${res.status}`);
  const json = await res.json();
  const ttl = Math.max(60, (new Date(json.expiresAt).getTime() - Date.now()) / 1000);
  await cache.set(isin, json, ttl);
  return json;
}

export async function GET(req: Request) {
  const isins = new URL(req.url).searchParams.get("isins")?.split(",").slice(0, 5) ?? [];
  const results = await Promise.allSettled(isins.map(getFund));
  return NextResponse.json(
    results.map((r, i) => (r.status === "fulfilled" ? r.value : { isin: isins[i], error: String(r.reason) })),
  );
}
```

`Promise.allSettled` matters: one unknown ISIN should not break the whole comparison.

## Shaping the comparison table

Pick the fields once, then render generically:

```typescript
const pct = (s?: string | null) => (s ? Number(/-?\d+(\.\d+)?/.exec(s)?.[0]) : null);

export const COLUMNS = [
  { label: "Fund size", get: (f) => f.data.keyFacts.aum },
  { label: "TER", get: (f) => f.data.headlineMetrics.ter, lowerIsBetter: true },
  { label: "Inception", get: (f) => f.data.keyFacts.inception },
  { label: "Risk (1–7)", get: (f) => f.data.riskRating },
  { label: "1Y", get: (f) => f.data.annualisedReturns.find((r) => r.label === "1 Year")?.fund },
  { label: "5Y p.a.", get: (f) => f.data.annualisedReturns.find((r) => r.label.startsWith("5 Years"))?.fund },
  { label: "Volatility 3y", get: (f) => f.data.headlineMetrics.volatility3y, lowerIsBetter: true },
  { label: "Sharpe 3y", get: (f) => f.data.headlineMetrics.sharpe3y },
  { label: "Holdings", get: (f) => f.data.keyFacts.holdings },
];
```

Highlight the best value per row (`lowerIsBetter` for fees and volatility) — it is the single most useful visual cue in a comparison table.

## One chart, many funds

Each fund's `indexedPerformance.points` starts at 100 at its own inception. To overlay funds with different launch dates, **rebase all series at the latest common start date**:

```typescript
export function rebase(series: { date: string; fund: number }[][], from: string) {
  return series.map((s) => {
    const start = s.find((p) => p.date >= from)!.fund;
    return s.filter((p) => p.date >= from).map((p) => ({ date: p.date, value: (p.fund / start) * 100 }));
  });
}
const commonStart = series.map((s) => s[0].date).sort().at(-1)!;
```

Then render one line per fund. The [growth chart on our landing page](https://fundfactsapi.com/#showcase) is a 60-line SVG component you can copy.

## Exposure and overlap

Use `data.sector` and `data.geography` for donuts, and compute overlap between `topHoldings` arrays as described in the [ETF holdings guide](https://fundfactsapi.com/blog/etf-holdings-api). Showing "these two ETFs share 38 % of their top-10 weight" is often the insight that changes the decision.

## PDF export

Server-render the comparison page and print it with the browser's print CSS, or use a headless browser to produce a PDF. Because every chart is inline SVG, there is nothing to rasterise. Put `generatedAt` and `data.dataAsOf` in the footer — compliance will ask.

## Python variant

If your stack is Python, the same proxy is a dozen lines with `requests` and `functools.lru_cache` or Redis; the JSON shape is identical. See the [ISIN API tutorial](https://fundfactsapi.com/blog/fund-data-api-from-isin) for the client code.

## Costs

A comparison of five funds costs at most five requests the first time and zero afterwards for 24 hours. On the Pro plan (1,000 requests a month) that is about six fresh comparisons a day; on Scale (10,000 a month, about 330 a day) it is a screener over a few hundred funds refreshed nightly. Start on the [free plan](https://fundfactsapi.com/signup) to build it, upgrade when you ship.

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