ISIN / app.py
antaris's picture
Update app.py
d26086d verified
Raw History Blame Contribute Delete
12.4 kB
from __future__ import annotations
import csv
import io
import random
import re
from functools import lru_cache
import gradio as gr
import requests
# ------------------------------------------------------------
# ISIN core
# ------------------------------------------------------------
_ISIN_BODY_RE = re.compile(r"^[A-Z0-9]{11}$")
_ISIN_FULL_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
def normalize_isin(s: str) -> str:
return re.sub(r"[\s-]+", "", (s or "")).upper()
def expand_isin_body(body: str) -> str:
"""
Expand an 11-char ISIN body to digits:
A -> 10, ..., Z -> 35, digits stay unchanged.
"""
body = normalize_isin(body)
if not _ISIN_BODY_RE.fullmatch(body):
raise ValueError(
"ISIN-Body muss aus genau 11 alphanumerischen Zeichen bestehen."
)
out: list[str] = []
for ch in body:
if ch.isdigit():
out.append(ch)
else:
out.append(str(ord(ch) - ord("A") + 10))
return "".join(out)
def luhn_mod10_check_digit_from_expanded(expanded_digits: str) -> str:
"""
Main implementation:
traverse from right to left, double every second digit.
"""
if not expanded_digits.isdigit():
raise ValueError("expanded_digits muss nur aus Ziffern bestehen.")
total = 0
double = True # because the check digit will be appended on the right
for ch in reversed(expanded_digits):
n = int(ch)
if double:
n *= 2
if n > 9:
n -= 9
total += n
double = not double
return str((10 - (total % 10)) % 10)
def compute_isin_check_digit(body: str) -> str:
expanded = expand_isin_body(body)
return luhn_mod10_check_digit_from_expanded(expanded)
def complete_isin(body: str) -> str:
body = normalize_isin(body)
return body + compute_isin_check_digit(body)
def validate_isin(isin: str) -> bool:
isin = normalize_isin(isin)
if not _ISIN_FULL_RE.fullmatch(isin):
return False
return compute_isin_check_digit(isin[:-1]) == isin[-1]
# ------------------------------------------------------------
# Independent reference implementation
# (different formulation, same mathematical result)
# ------------------------------------------------------------
def compute_isin_check_digit_reference(body: str) -> str:
"""
Reference implementation of the same rule, but written independently:
- expand letters
- reason from LEFT using parity of the final full length
"""
digits = [int(ch) for ch in expand_isin_body(body)]
# final full number length = len(digits) + 1 (for the check digit)
full_len = len(digits) + 1
parity = full_len % 2
total = 0
for i, n in enumerate(digits):
if i % 2 == parity:
n *= 2
if n > 9:
n -= 9
total += n
return str((10 - (total % 10)) % 10)
def validate_isin_reference(isin: str) -> bool:
isin = normalize_isin(isin)
if not _ISIN_FULL_RE.fullmatch(isin):
return False
return compute_isin_check_digit_reference(isin[:-1]) == isin[-1]
# ------------------------------------------------------------
# Manual UI helpers
# ------------------------------------------------------------
def run_manual_isin_tool(user_input: str) -> str:
s = normalize_isin(user_input)
if _ISIN_BODY_RE.fullmatch(s):
full = complete_isin(s)
ref = compute_isin_check_digit_reference(s)
return (
f"Eingabe erkannt als 11-stelliger ISIN-Body.\n\n"
f"Vollständige ISIN: {full}\n"
f"Prüfziffer: {full[-1]}\n"
f"Referenz-Prüfziffer: {ref}\n"
f"Konsistenz: {'OK' if ref == full[-1] else 'ABWEICHUNG'}"
)
if _ISIN_FULL_RE.fullmatch(s):
ok_main = validate_isin(s)
ok_ref = validate_isin_reference(s)
return (
f"Eingabe erkannt als vollständige 12-stellige ISIN.\n\n"
f"ISIN: {s}\n"
f"Validierung (Hauptimplementierung): {'gültig' if ok_main else 'ungültig'}\n"
f"Validierung (Referenzimplementierung): {'gültig' if ok_ref else 'ungültig'}\n"
f"Konsistenz: {'OK' if ok_main == ok_ref else 'ABWEICHUNG'}"
)
return (
"Ungültige Eingabe.\n\n"
"Erwartet wird entweder:\n"
"- ein 11-stelliger ISIN-Body (zur Berechnung), oder\n"
"- eine 12-stellige vollständige ISIN (zur Validierung).\n\n"
"Erlaubt sind nur A-Z und 0-9; Leerzeichen und Bindestriche werden ignoriert."
)
# ------------------------------------------------------------
# Online dataset self-test
# ------------------------------------------------------------
REMOTE_SOURCES = {
"NSDL mirror (India ISIN dataset on GitHub)": {
"url": "https://raw.githubusercontent.com/captn3m0/india-isin-data/main/ISIN.csv",
"description": (
"Öffentliches CSV-Repo mit realen ISINs; gut für externe Regressionstests."
),
}
}
def _extract_isins_from_csv_text(text: str) -> list[str]:
"""
Parse robustly:
1) try DictReader and a column named 'isin'
2) fallback to regex extraction from whole text
"""
out: list[str] = []
try:
reader = csv.DictReader(io.StringIO(text))
if reader.fieldnames:
field_map = {f.strip().lower(): f for f in reader.fieldnames if f}
isin_field = field_map.get("isin")
if isin_field:
for row in reader:
candidate = normalize_isin(row.get(isin_field, ""))
if _ISIN_FULL_RE.fullmatch(candidate):
out.append(candidate)
except csv.Error:
pass
if not out:
for candidate in re.findall(r"\b[A-Z]{2}[A-Z0-9]{9}[0-9]\b", text.upper()):
if _ISIN_FULL_RE.fullmatch(candidate):
out.append(candidate)
# de-duplicate but preserve deterministic order
seen = set()
deduped = []
for x in out:
if x not in seen:
seen.add(x)
deduped.append(x)
return deduped
@lru_cache(maxsize=4)
def fetch_remote_isins(source_name: str) -> tuple[str, ...]:
source = REMOTE_SOURCES[source_name]
resp = requests.get(
source["url"],
timeout=30,
headers={"User-Agent": "ISIN-Validator-Demo/1.0"},
)
resp.raise_for_status()
isins = _extract_isins_from_csv_text(resp.text)
if not isins:
raise RuntimeError("Keine gültigen ISINs im Online-Datensatz gefunden.")
return tuple(isins)
def _parse_seed(seed_text: str | None) -> int | None:
s = (seed_text or "").strip()
if not s:
return None
return int(s)
def _render_result_table(rows: list[dict[str, str]], max_rows: int = 1000) -> str:
head = "| # | modus | ISIN | result_main | result_ref | ok |\n|---:|---|---|---|---|---|\n"
body_lines = []
for row in rows[:max_rows]:
body_lines.append(
f"| {row['idx']} | {row['mode']} | `{row['isin']}` | "
f"`{row['result_main']}` | `{row['result_ref']}` | {row['ok']} |"
)
tail = ""
if len(rows) > max_rows:
tail = f"\n\n… weitere {len(rows) - max_rows} Zeilen ausgeblendet."
return head + "\n".join(body_lines) + tail
def run_online_self_test(
sample_count: int,
mode: str,
seed_text: str,
source_name: str,
) -> str:
try:
seed = _parse_seed(seed_text)
rng = random.Random(seed)
dataset = list(fetch_remote_isins(source_name))
if not dataset:
return "Fehler: Online-Datensatz leer."
n = max(1, min(int(sample_count), len(dataset)))
sample = rng.sample(dataset, k=n)
rows: list[dict[str, str]] = []
failures: list[dict[str, str]] = []
for idx, isin in enumerate(sample, start=1):
op = mode
if mode == "mixed":
op = rng.choice(["compute", "verify"])
if op == "verify":
main = str(validate_isin(isin))
ref = str(validate_isin_reference(isin))
ok = (main == "True") and (ref == "True")
else:
# compute
main = complete_isin(isin[:-1])
ref = isin[:-1] + compute_isin_check_digit_reference(isin[:-1])
ok = (main == isin) and (ref == isin)
row = {
"idx": str(idx),
"mode": op,
"isin": isin,
"result_main": main,
"result_ref": ref,
"ok": "✅" if ok else "❌",
}
rows.append(row)
if not ok:
failures.append(row)
passed = len(rows) - len(failures)
rate = 100.0 * passed / len(rows)
summary = (
f"## Online-Selbsttest\n\n"
f"- Quelle: **{source_name}**\n"
f"- Geladene Datensätze: **{len(dataset)}**\n"
f"- Stichprobe: **{len(rows)}**\n"
f"- Modus: **{mode}**\n"
f"- Seed: **{seed if seed is not None else 'zufällig'}**\n"
f"- Bestanden: **{passed}/{len(rows)} ({rate:.1f}%)**\n"
)
note = (
"\n**Hinweis:** Dieser Test vergleicht gegen reale Online-ISINs "
"und nutzt zusätzlich eine unabhängig formulierte Referenzimplementierung. "
"Das ist ein starker externer Check, aber kein mathematischer Universalbeweis.\n"
)
if failures:
fail_block = "\n### Fehlgeschlagene Fälle\n\n" + _render_result_table(
failures, max_rows=1000
)
else:
fail_block = "\n### Erste Stichproben\n\n" + _render_result_table(
rows, max_rows=1000
)
return summary + note + fail_block
except requests.RequestException as e:
return f"Netzwerkfehler beim Laden des Online-Datensatzes:\n\n`{type(e).__name__}: {e}`"
except Exception as e:
return f"Fehler im Selbsttest:\n\n`{type(e).__name__}: {e}`"
# ------------------------------------------------------------
# Gradio UI
# ------------------------------------------------------------
with gr.Blocks() as demo:
gr.Markdown(
"""
# ISIN Rechner / Validator
Diese App kann:
1. eine ISIN-Prüfziffer berechnen,
2. eine vollständige ISIN validieren,
3. einen Online-Selbsttest gegen reale veröffentlichte ISINs ausführen.
"""
)
with gr.Tab("Manuell"):
with gr.Row():
manual_input = gr.Textbox(
label="ISIN oder ISIN-Body",
placeholder="z.B. US037833100 oder US0378331005",
lines=1,
)
manual_button = gr.Button("Berechnen / Validieren")
manual_output = gr.Textbox(label="Ergebnis", lines=10)
manual_button.click(
fn=run_manual_isin_tool,
inputs=manual_input,
outputs=manual_output,
)
with gr.Tab("Online-Selbsttest"):
gr.Markdown(
"""
Der Selbsttest lädt zufällige echte ISINs aus einem öffentlichen Online-Datensatz,
wählt eine Stichprobe und prüft dann `compute`, `verify` oder beides gemischt.
"""
)
with gr.Row():
source_name = gr.Dropdown(
choices=list(REMOTE_SOURCES.keys()),
value=list(REMOTE_SOURCES.keys())[0],
label="Datenquelle",
)
mode = gr.Dropdown(
choices=["compute", "verify", "mixed"],
value="mixed",
label="Testmodus",
)
with gr.Row():
sample_count = gr.Slider(
minimum=1,
maximum=1000,
value=25,
step=1,
label="Anzahl zufälliger ISINs",
)
seed_text = gr.Textbox(
label="Seed (optional)",
placeholder="z.B. 42",
lines=1,
)
online_button = gr.Button("Online-Selbsttest starten")
online_output = gr.Markdown()
online_button.click(
fn=run_online_self_test,
inputs=[sample_count, mode, seed_text, source_name],
outputs=online_output,
)
demo.launch()