1. The problem it addresses
Financial-crime and customer-due-diligence analysts are required, by regulation, to screen clients against sanctions, politically-exposed-person, and adverse-media data, to assess whether any hit is a real risk, and to record their reasoning. The daily friction is not the screening itself, it is the volume of false positives. A common name matches many list entries, and an analyst can spend most of their time clearing matches that were never the same person.
This system targets that specific friction. It performs the screening, but more importantly applies a secondary identity check, date of birth and nationality, before an alert reaches a human, so obvious non-matches are pre-classified and attention goes where it is warranted. I built the workflow and the responsibilities from real first-line CDD analyst job specifications, so the tool matches the job as the industry actually defines it, not as I imagined it. That is where the requirements came from.
2. Data sources
The system screens against three real, public, free sanctions lists, loaded live from their official government endpoints, not a sample. At the time of writing the loaded watchlist holds 26,387 real sanctioned entities: 19,122 from OFAC, 1,002 from the UN, and 6,263 from the UK. Using full real data rather than a synthetic set is deliberate, it exposes the genuine data-quality problems a screening engine has to survive: inconsistent name formats, transliteration, names split across fields, free-text dates of birth, headerless files, and null records.
Getting this working against live data surfaced friction sample data never would: the UN had moved its file to a new domain, the OFAC file is headerless so a header-assuming parser failed, and the real OFAC data contains vessel and aircraft rows with null names that crash a naive parser. Each parser is defensive, one malformed record is skipped, not the whole load, and inserts are batched so the full load completes in seconds.
On the UK source specifically: the OFSI Consolidated List closed in January 2026 and is no longer updated; the UK Sanctions List is now the single source, and the system targets it accordingly. On PEP and adverse-media data: the real feeds are commercially licensed, so rather than fabricate them, the schema reserves a clearly-labelled SYNTHETIC_PEP source. No synthetic data is ever presented as real.
3. The matching engine
Sanctions names rarely arrive clean. They are inverted (“Bout, Viktor” vs “Viktor Bout”), abbreviated, transliterated, or carry patronymics and titles. Exact string matching would miss most real hits, so the engine uses fuzzy matching via RapidFuzz, MIT-licensed so a real compliance team's legal review would not block it, and C++-backed so it scales.
Four scorers, combined by maximum
No single fuzzy scorer handles every name variation, so the engine computes four and takes the highest. The fourth was added on evidence, not assumption: screening “Viktor Bout” against the real entry “BOUT, Viktor Anatolijevich” scored only 71 on token_set_ratio, because that scorer penalises the extra patronymic. At a threshold of 85 the real match would have been missed. partial_token_sort_ratio scores the same pair at 91, so it was added, found by testing against real data.
Tuning at scale: the token-overlap guard
Against the live watchlist, screening an innocent name produced over fifty false-positive alerts scoring 86–100. The culprit was that same fourth scorer, matching short fragments like “ARIA” against “ARADHNA” at 86 while every other scorer correctly scored them around 33. A scorer added to catch one genuine case was, at scale, generating most of the noise.
Rather than remove it and re-break Viktor Bout, the engine now only lets that scorer contribute when the two names share a whole token of at least four characters. “Viktor Bout” and “BOUT, Viktor Anatolijevich” share BOUT and VIKTOR, so the match holds; “Aradhna” and “ARIA” share no such token, so the fragment collapses. The guard removes the noise without sacrificing the case the scorer exists for.
4. Identity resolution, the false-positive lever
A name match is a question, not an answer. Once a name clears the threshold, the engine compares the client's date of birth and nationality against the matched entity and assigns a priority before anyone sees the alert.
The critical rule: missing data never suppresses a match. Only a confirmed mismatch downgrades an alert. If a date of birth or nationality is simply unknown, and sanctions data is frequently incomplete, the alert is not downgraded. Treating “we don't know” the same as “confirmed different” would be a compliance failure. This rule is enforced in code, not left to the analyst to remember. Because OFAC's primary file carries no nationality, OFAC matches show nationality as unknown and are never downgraded on that basis, while UN and UK records do carry it, so the system behaves differently depending on the richness of the source data, correctly and visibly.
5. Case grouping across watchlists
One person is frequently listed on several watchlists, three alerts for what is really one subject and one decision. The system groups alerts that share a matched name into a single case: the analyst sees one case flagged on three lists, ticks which list-hits the decision covers, and clears or escalates them together with one shared justification. Grouping is a workflow convenience, not a shortcut around the record, each list-hit still gets its own audit entry, written together in a single database transaction so a case can never end up half-decided.
6. The audit trail
In a regulated process, a decision that is not documented did not happen, and a record that can be altered is not a reliable record. Three decisions follow. Every clear or escalate requires a written justification of at least ten characters, validated at the API boundary. The audit log is insert-only, enforced at the database level, the app's role has UPDATE and DELETE revoked, and triggers raise an exception on any such attempt, so even a more privileged connection is refused. A real consequence, observed in development: the watchlist-refresh routine cannot delete audit rows even when it wants a clean slate, which is exactly how an audit trail should behave. The log is searchable and date-filterable for review.
7. Architecture
The system runs as three free-tier services: a React frontend on Vercel, a FastAPI backend on Render, and a PostgreSQL database on Supabase, deployed from GitHub with automatic redeploy on push. End to end: a client is submitted; their name is scored against every entity; anything at or above threshold becomes an alert; identity resolution sets each alert's priority; alerts group into cases on the triage queue; the analyst clears or escalates with a justification; and the decision is written immutably to the audit log, one entry per list-hit.
8. Limitations, stated plainly
- Matching scans the watchlist row by row in Python, fine under a second at 26k entities, but at multi-hundred-thousand commercial scale it would move into the database or an indexed candidate search.
- Case grouping keys on the matched name string, so the same person stored under materially different formats appears as separate cases. Full cross-list entity resolution is the next step, not a solved feature.
- Aliases and nationality are not yet fully wired from every source's linked files; missing fields are stored as null, never guessed.
- The threshold is tuned against the names tested, not labelled production outcomes.
- No transaction monitoring or document OCR, both out of scope for first-line client screening.
What I'd build next
Cross-list entity resolution so one person is recognised despite name-format differences; wiring aliases and nationality from every source's linked files; scheduled automatic watchlist refresh; and a proper threshold-tuning exercise against labelled match outcomes.