Compliance and suitability monitoring on daily fund data
Suitability is not a one-off check at onboarding. Risk ratings drift, funds change objective, holdings concentrate and documents go stale. This page describes a monitoring loop that re-reads each fund's published facts daily, compares them with the last known state and raises alerts an operations team can act on.The problem
A client is placed in a fund with risk rating 4 that fits their profile. Eighteen months later the fund house republishes the KID with a rating of 5. Nothing in the portfolio system changes; the position looks the same. Unless someone re-reads the document, the mismatch goes unnoticed until an audit or a complaint.
The same applies to concentration, where a fund's top holding grows from 6 percent to 12 percent, and to document freshness, where the most recent factsheet available is months old. Compliance teams know these are the checks to run; the difficulty is running them across every fund, every day, without a data team.
What you need from the data
A monitoring loop compares a small set of attributes against their previous values:
- Risk rating (1–7) and the profile risk band
- Top holding weight and the number of holdings, as concentration proxies
- Region focus and sector tilt, to detect mandate drift
- Investment objective text, to detect wording changes
- Asset allocation, for multi-asset funds that should stay within bands
dataAsOf, to detect stale documents- Whether the fund still exists (a 404 on a held fund)
For each, you need both the current value and a stored previous value, plus a rule that says what change is material.
How it works with FundFacts API
Run a daily job over held ISINs. For each, load the fund, compute a snapshot of the monitored attributes, diff it against yesterday's snapshot and emit alerts.
typescripttype Snapshot = {riskRating?: number;riskBand?: string;topWeight?: number;regionFocus?: string;objective?: string;dataAsOf?: string;};export async function snapshot(isin: string): Promise<Snapshot | "gone"> {const res = await fetch(`https://fundfactsapi.com/api/v1/funds/${isin}`, {headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },});if (res.status === 404) return "gone";if (!res.ok) throw new Error(`${res.status} loading ${isin}`);const { data } = await res.json();return {riskRating: data.riskRating,riskBand: data.profile?.riskBand,topWeight: data.topHoldings?.[0]?.weight,regionFocus: data.profile?.regionFocus,objective: data.investmentObjective,dataAsOf: data.dataAsOf,};}export function alerts(prev: Snapshot, next: Snapshot, staleDays = 120): string[] {const out: string[] = [];if (prev.riskRating !== next.riskRating)out.push(`risk rating ${prev.riskRating} -> ${next.riskRating}`);if ((next.topWeight ?? 0) > 10 && (prev.topWeight ?? 0) <= 10)out.push(`top holding above 10%: ${next.topWeight}%`);if (prev.regionFocus !== next.regionFocus)out.push(`region focus ${prev.regionFocus} -> ${next.regionFocus}`);if (prev.objective !== next.objective) out.push("investment objective text changed");if (next.dataAsOf) {const age = (Date.now() - Date.parse(next.dataAsOf)) / 86_400_000;if (age > staleDays) out.push(`documents ${Math.round(age)} days old`);}return out;}
Route alerts by severity: a risk-rating change on a fund held by clients at the edge of their band is urgent; a stale document is a ticket. The SRRI explainer covers how the rating is derived and why it moves.
Design notes
Caching and cadence. The API refreshes every 24 hours, so a daily job is the right cadence; running more often gains nothing. Store every snapshot with its expiresAt and dataAsOf. The history is your audit trail and lets you answer "when did we first see this rating".
Quota. One request per held ISIN per day. A firm holding a few hundred distinct funds fits a Pro or Scale plan; watch X-RateLimit-Remaining and finish the run over two days if needed rather than dropping funds. On 429, pause until X-RateLimit-Reset.
Errors. Treat 502 upstream_error as "no snapshot today", not as a change. Do not raise a false alert because the fund house's site was unavailable. A 404 fund_not_found on a fund that resolved yesterday is itself an alert.
Thresholds are yours. The API supplies facts; what counts as material concentration or an acceptable document age is a policy decision that belongs in your compliance manual, not in code defaults.
Text diffs. Objective wording changes for trivial reasons (a typo fix, a reformatted PDF). Normalise whitespace and case before comparing, and show the diff to a reviewer rather than auto-escalating.
Disclaimers. Alerts describe changes in published facts and are not a judgement on the fund. Display dataAsOf on every alert, and keep the suitability decision with the adviser and the firm's process. Related reading: MiFID II and KID.