Add SQLite passive income dashboard with Excel import and deployment tools

This commit is contained in:
kai
2026-09-09 08:46:27 +02:00
parent 729a13bfca
commit afc6f74f36
27 changed files with 1109 additions and 67 deletions
+69
View File
@@ -0,0 +1,69 @@
"""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)