Source code for openbasket.identifiers

"""ISIN and CUSIP helpers.

An ISIN is a two-letter country code, a nine-character national number and a
check digit. For the US and Canada the national number is the CUSIP, so a
valid CUSIP plus the country gives the ISIN.
"""

from __future__ import annotations

import re

_ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
_CUSIP_RE = re.compile(r"^[A-Z0-9*@#]{8}[0-9]$")

#: Countries whose ISIN national number is the CUSIP.
CUSIP_COUNTRIES = frozenset({"US", "CA"})


def _char_value(ch: str) -> int:
    if ch.isdigit():
        return int(ch)
    if ch.isalpha():
        return ord(ch) - ord("A") + 10
    return {"*": 36, "@": 37, "#": 38}[ch]


[docs] def isin_check_digit(payload: str) -> str: """Return the check digit for the first 11 characters of an ISIN.""" digits = "".join(str(_char_value(ch)) for ch in payload.upper()) total = 0 for i, ch in enumerate(reversed(digits)): d = int(ch) if i % 2 == 0: d *= 2 total += d // 10 + d % 10 return str((10 - total % 10) % 10)
[docs] def is_valid_isin(value: object) -> bool: """True when ``value`` is a well-formed ISIN with a correct check digit.""" if not isinstance(value, str): return False value = value.strip().upper() return bool(_ISIN_RE.match(value)) and isin_check_digit(value[:11]) == value[11]
[docs] def cusip_check_digit(payload: str) -> str: """Return the check digit for the first 8 characters of a CUSIP.""" total = 0 for i, ch in enumerate(payload.upper()): v = _char_value(ch) if i % 2 == 1: v *= 2 total += v // 10 + v % 10 return str((10 - total % 10) % 10)
[docs] def is_valid_cusip(value: object) -> bool: """True when ``value`` is a well-formed CUSIP with a correct check digit.""" if not isinstance(value, str): return False value = value.strip().upper() return bool(_CUSIP_RE.match(value)) and cusip_check_digit(value[:8]) == value[8]
[docs] def cusip_to_isin(cusip: object, country: object = "US") -> str | None: """Derive an ISIN from a CUSIP, or ``None`` when that is not safe. Only US and Canadian CUSIPs are converted. CINS numbers (first character a letter) belong to securities issued elsewhere whose ISIN uses another country code, so they are left alone. """ if not is_valid_cusip(cusip): return None cusip = str(cusip).strip().upper() country = (str(country).strip().upper() if isinstance(country, str) else "") or "US" if country not in CUSIP_COUNTRIES or cusip[0].isalpha(): return None payload = country + cusip return payload + isin_check_digit(payload)
[docs] def isin_to_cusip(isin: object) -> str | None: """Return the CUSIP inside a US or Canadian ISIN, else ``None``.""" if not is_valid_isin(isin): return None isin = str(isin).strip().upper() return isin[2:11] if isin[:2] in CUSIP_COUNTRIES else None
[docs] def normalise_ticker(ticker: object) -> str | None: """Fold share-class punctuation so ``BRK.B``, ``BRK/B`` and ``BRK-B`` match.""" if not isinstance(ticker, str) or not ticker.strip(): return None return re.sub(r"[\s./\-]", "", ticker.strip().upper())