bilalkurban/Ownership
0
1"""2Malta Finance Ownership Explorer3--------------------------------4Enter a Maltese company name -> resolve it in the GLEIF LEI registry ->5show its ownership chain (direct parent, ultimate parent, subsidiaries)6with the country of each owner.7 8Data source: GLEIF public JSON:API (https://api.gleif.org/api/v1) - free, no key.9"""10 11import requests12import pandas as pd13import gradio as gr14 15BASE = "https://api.gleif.org/api/v1"16HEADERS = {17 "Accept": "application/vnd.api+json",18 "User-Agent": "malta-ownership-explorer/0.1",19}20TIMEOUT = 2521 22# ISO2 -> display name for the countries you'll actually hit. Falls back to the code.23COUNTRY_NAMES = {24 "MT": "Malta", "GB": "United Kingdom", "IE": "Ireland", "LU": "Luxembourg",25 "NL": "Netherlands", "DE": "Germany", "FR": "France", "IT": "Italy",26 "ES": "Spain", "CH": "Switzerland", "CY": "Cyprus", "US": "United States",27 "AE": "United Arab Emirates", "SG": "Singapore", "HK": "Hong Kong",28 "KY": "Cayman Islands", "VG": "British Virgin Islands", "JE": "Jersey",29 "GG": "Guernsey", "IM": "Isle of Man", "LI": "Liechtenstein",30 "BE": "Belgium", "AT": "Austria", "SE": "Sweden", "DK": "Denmark",31 "NO": "Norway", "FI": "Finland", "PL": "Poland", "PT": "Portugal",32 "GR": "Greece", "TR": "Turkiye", "IL": "Israel", "CA": "Canada",33 "AU": "Australia", "JP": "Japan", "CN": "China", "IN": "India",34 "ZA": "South Africa", "BM": "Bermuda", "BS": "Bahamas", "PA": "Panama",35}36 37EXCEPTION_LABELS = {38 "NO_LEI": "Parent exists but has no LEI",39 "NATURAL_PERSONS": "Owned directly by natural persons",40 "NON_CONSOLIDATING": "Parent does not consolidate this entity",41 "NO_KNOWN_PERSON": "No known controlling person",42 "NON_PUBLIC": "Ownership not publicly disclosable",43 "LEGAL_OBSTACLES": "Legal obstacles to disclosure",44 "CONSENT_NOT_OBTAINED": "Parent consent not obtained",45 "BINDING_LEGAL_COMMITMENTS": "Blocked by binding legal commitments",46 "DISCLOSURE_DETRIMENTAL": "Disclosure deemed detrimental",47 "DETRIMENT_NOT_EXCLUDED": "Detriment could not be excluded",48}49 50 51# --------------------------------------------------------------------------- #52# GLEIF client53# --------------------------------------------------------------------------- #54def _get(path, params=None):55 """GET a GLEIF endpoint. Returns parsed JSON, or None for 404/no-content."""56 try:57 r = requests.get(BASE + path, params=params, headers=HEADERS, timeout=TIMEOUT)58 except requests.RequestException as e:59 raise gr.Error(f"Could not reach the GLEIF API: {e}")60 if r.status_code in (204, 404):61 return None62 if r.status_code >= 400:63 return None64 try:65 return r.json()66 except ValueError:67 return None68 69 70def country_name(code):71 if not code:72 return "-"73 return COUNTRY_NAMES.get(code.upper(), code.upper())74 75 76def summarise(record):77 """Flatten one lei-record resource into a plain dict."""78 a = record.get("attributes", {})79 ent = a.get("entity", {}) or {}80 legal_name = (ent.get("legalName") or {}).get("name", "-")81 addr = ent.get("legalAddress") or {}82 return {83 "lei": a.get("lei", "-"),84 "name": legal_name,85 "country_code": addr.get("country", ""),86 "city": addr.get("city", ""),87 "jurisdiction": ent.get("jurisdiction", ""),88 "status": ent.get("status", ""),89 "registration": (a.get("registration") or {}).get("status", ""),90 }91 92 93def search_entities(query, country="MT", limit=15):94 """Find candidate LEI records by legal name, restricted to a country."""95 query = (query or "").strip()96 if not query:97 return []98 99 # An LEI is 20 alphanumeric characters - treat that as a direct lookup.100 if len(query) == 20 and query.isalnum():101 payload = _get(f"/lei-records/{query.upper()}")102 if payload and payload.get("data"):103 return [summarise(payload["data"])]104 105 params = {106 "filter[entity.legalName]": query,107 "page[size]": limit,108 "page[number]": 1,109 }110 if country:111 params["filter[entity.legalAddress.country]"] = country112 payload = _get("/lei-records", params)113 hits = (payload or {}).get("data", []) or []114 115 # Fallback: fuzzy name completion, then resolve the matched LEIs.116 if not hits:117 fuzzy = _get("/fuzzycompletions", {"field": "entity.legalName", "q": query})118 leis = []119 for item in (fuzzy or {}).get("data", [])[: limit * 3]:120 rel = ((item.get("relationships") or {}).get("lei-records") or {}).get("data")121 if isinstance(rel, dict) and rel.get("id"):122 leis.append(rel["id"])123 if leis:124 payload = _get("/lei-records", {"filter[lei]": ",".join(leis[:50]),125 "page[size]": 50})126 hits = (payload or {}).get("data", []) or []127 if country:128 hits = [h for h in hits129 if ((h.get("attributes", {}).get("entity", {}) or {})130 .get("legalAddress", {}) or {}).get("country") == country]131 132 return [summarise(h) for h in hits[:limit]]133 134 135def ownership_ratio(relationship_payload):136 """Pull a reported ownership percentage out of a relationship record, if any."""137 data = (relationship_payload or {}).get("data")138 if not data:139 return None140 rel = (data.get("attributes") or {}).get("relationship") or {}141 for q in rel.get("quantifiers") or []:142 ratio = q.get("amount", q.get("ratio"))143 if ratio is None:144 continue145 try:146 val = float(ratio)147 except (TypeError, ValueError):148 continue149 return f"{val * 100:.1f}%" if val <= 1 else f"{val:.1f}%"150 return None151 152 153def get_parent(lei, kind):154 """kind is 'direct-parent' or 'ultimate-parent'. Returns (dict|None, note|None)."""155 rec = _get(f"/lei-records/{lei}/{kind}")156 if rec and rec.get("data"):157 row = summarise(rec["data"])158 row["share"] = ownership_ratio(_get(f"/lei-records/{lei}/{kind}-relationship")) or "not reported"159 return row, None160 161 exc = _get(f"/lei-records/{lei}/{kind}-reporting-exception")162 data = (exc or {}).get("data")163 if data:164 reason = (data.get("attributes") or {}).get("reason", "")165 return None, EXCEPTION_LABELS.get(reason, reason or "No parent reported")166 return None, "No parent reported to GLEIF"167 168 169def get_children(lei, limit=25):170 payload = _get(f"/lei-records/{lei}/direct-children", {"page[size]": limit})171 return [summarise(d) for d in (payload or {}).get("data", []) or []]172 173 174# --------------------------------------------------------------------------- #175# Gradio callbacks176# --------------------------------------------------------------------------- #177def do_search(query, country):178 code = "" if country == "Any country" else "MT"179 results = search_entities(query, country=code)180 if not results:181 return (gr.update(choices=[], value=None),182 "No match in the GLEIF registry. Try fewer words, or switch to "183 "**Any country** in case the entity is registered abroad.",184 None, None, {})185 186 choices = [f"{r['name']} - {r['lei']} ({country_name(r['country_code'])})"187 for r in results]188 lookup = {c: r for c, r in zip(choices, results)}189 msg = f"Found **{len(results)}** match(es). Pick one below."190 return gr.update(choices=choices, value=choices[0]), msg, None, None, lookup191 192 193def do_ownership(selection, lookup):194 if not selection or not lookup or selection not in lookup:195 raise gr.Error("Search for a company and select a match first.")196 197 subject = lookup[selection]198 lei = subject["lei"]199 200 direct, direct_note = get_parent(lei, "direct-parent")201 ultimate, ult_note = get_parent(lei, "ultimate-parent")202 children = get_children(lei)203 204 rows = []205 if direct:206 rows.append(["Direct parent", direct["name"], country_name(direct["country_code"]),207 direct["share"], direct["lei"]])208 else:209 rows.append(["Direct parent", f"- ({direct_note})", "-", "-", "-"])210 211 if ultimate:212 rows.append(["Ultimate parent", ultimate["name"], country_name(ultimate["country_code"]),213 ultimate["share"], ultimate["lei"]])214 else:215 rows.append(["Ultimate parent", f"- ({ult_note})", "-", "-", "-"])216 217 for c in children:218 rows.append(["Subsidiary", c["name"], country_name(c["country_code"]), "-", c["lei"]])219 220 df = pd.DataFrame(rows, columns=["Relationship", "Entity", "Country",221 "Reported share", "LEI"])222 223 card = (224 f"### {subject['name']}\n"225 f"**LEI** `{subject['lei']}` \n"226 f"**Registered address** {subject['city'] or '-'}, "227 f"{country_name(subject['country_code'])} \n"228 f"**Jurisdiction** {subject['jurisdiction'] or '-'} \n"229 f"**Entity status** {subject['status'] or '-'} - "230 f"**LEI status** {subject['registration'] or '-'}\n\n"231 "---\n"232 "*GLEIF publishes accounting-consolidation parents, not a full share register. "233 "Percentages appear only where the entity reported them. For the actual "234 "register of members, see the MBR filings.*"235 )236 return card, df237 238 239DISCLAIMER = """240**What this shows.** Ownership relationships reported to the Global LEI system241(GLEIF) - direct parent, ultimate parent and known subsidiaries, each with its242country of registration.243 244**What it does not show.** A complete shareholder list with percentages. That245lives in the Malta Business Registry's register of members and UBO register,246neither of which has a public API. Where GLEIF has no parent on file, the app247tells you the reported reason (for example, *owned directly by natural persons*).248"""249 250 251with gr.Blocks(title="Malta Finance Ownership Explorer") as demo:252 gr.Markdown("# Malta Finance Ownership Explorer")253 gr.Markdown(DISCLAIMER)254 255 state = gr.State({})256 257 with gr.Row():258 query = gr.Textbox(label="Company name or LEI", scale=3,259 placeholder="e.g. Bank of Valletta")260 country = gr.Radio(["Malta only", "Any country"], value="Malta only",261 label="Registered in", scale=1)262 search_btn = gr.Button("Search", variant="primary", scale=1)263 264 status = gr.Markdown()265 matches = gr.Dropdown(label="Matches", choices=[], interactive=True)266 go_btn = gr.Button("Show ownership", variant="primary")267 268 card = gr.Markdown()269 table = gr.Dataframe(label="Ownership chain", wrap=True, interactive=False)270 271 search_btn.click(do_search, [query, country], [matches, status, card, table, state])272 query.submit(do_search, [query, country], [matches, status, card, table, state])273 go_btn.click(do_ownership, [matches, state], [card, table])274 275 gr.Examples(276 examples=[["Bank of Valletta"], ["MeDirect"], ["FIMBank"], ["Lombard Bank"]],277 inputs=query,278 )279 280if __name__ == "__main__":281 demo.launch()282 