"""Shared validation and cent-exact money formatting.""" import re import unicodedata from datetime import date from decimal import Decimal, InvalidOperation, ROUND_HALF_UP CATEGORIES = {'dividend': 'Dividende', 'interest': 'Zinsen', 'distribution': 'Ausschüttung', 'other': 'Sonstiges'} ASSET_TYPES = {'stock': 'Aktie', 'etf': 'ETF', 'bond': 'Anleihe', 'crypto': 'Krypto', 'interest': 'Zinskonto', 'other': 'Sonstiges'} MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] def cents(value): text = str(value).strip() if not re.fullmatch(r'-?\d{1,10}(?:[.,]\d{1,2})?', text): raise ValueError('Betrag mit höchstens zwei Nachkommastellen eingeben, ohne Tausendertrennzeichen.') try: number = Decimal(text.replace(',', '.')) except InvalidOperation: raise ValueError('Ungültiger Betrag.') from None if abs(number) > Decimal('999999999.99'): raise ValueError('Betrag ist zu groß.') return int(number * 100) def money(value): return f'{Decimal(value) / 100:,.2f}'.replace(',', '_').replace('.', ',').replace('_', '.') + ' €' def percent(current, previous): if not previous: return None return (Decimal(current - previous) * 100 / abs(Decimal(previous))).quantize(Decimal('.01'), rounding=ROUND_HALF_UP) def percent_text(value): return '–' if value is None else f'{value:.2f}'.replace('.', ',') + ' %' def valid_date(value): if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', str(value)): raise ValueError('Bitte ein gültiges Datum eingeben.') try: return date.fromisoformat(value).isoformat() except ValueError: raise ValueError('Bitte ein gültiges Datum eingeben.') from None def name_key(name): return ''.join(c for c in unicodedata.normalize('NFKC', name).casefold() if c.isalnum()) ALIASES = { 'msc': 'Main Street Capital', 'mainstreet': 'Main Street Capital', 'mainstreetcapital': 'Main Street Capital', 'agnc': 'AGNC', 'agncinvestment': 'AGNC', 'agncinvestmentcorp': 'AGNC', 'csw': 'Capital Southwest', 'cswc': 'Capital Southwest', 'capitalsouthwest': 'Capital Southwest', 'arcc': 'Ares Capital', 'arescapital': 'Ares Capital', 'realtyincome': 'Realty Income', 'enbridge': 'Enbridge', 'bayer': 'Bayer', 'stoxx': 'STOXX Global Select Dividend 100', 'stoxxglobalselectdividend100': 'STOXX Global Select Dividend 100', 'airbaltic': 'airBaltic', 'pc': 'Prospect Capital', 'psec': 'Prospect Capital', 'prospectcapital': 'Prospect Capital', 'komischen26dividende': 'N26 Dividende', } def canonical_name(value): name = ' '.join(str(value).split()) name = re.sub(r'^(dividende[n]?|ausschüttung)\s+', '', name, flags=re.I) if not name or len(name) > 150: raise ValueError('Positionsname muss zwischen 1 und 150 Zeichen lang sein.') return ALIASES.get(name_key(name), name)