"""Exact trading values, vocabulary and validation (no binary floats).""" import re from decimal import Decimal, localcontext, ROUND_HALF_UP from models import valid_date TYPES = {'buy': 'Kauf', 'sell': 'Verkauf'} SOURCES = {'manual': 'Manuell', 'savings_plan': 'Sparplan', 'roundup': 'Round-up', 'cashback': 'Cashback', 'rebalancing': 'Rebalancing', 'other': 'Sonstiges'} STRATEGIES = {'core': 'Core', 'income': 'Income', 'conviction': 'Conviction', 'dip_buy': 'Dip Buy', 'speculation': 'Spekulation', 'rebalancing': 'Rebalancing', 'other': 'Sonstiges'} CENT = Decimal('0.01') ZERO = Decimal(0) class TradingValidationError(ValueError): """Safe, user-facing domain error, never contains database internals.""" def decimal_value(value, label, places=12, positive=False): if not isinstance(value, (str, Decimal)): raise TradingValidationError(f'{label} als Dezimalstring eingeben.') text = str(value).strip().replace(',', '.') if not re.fullmatch(r'\d{1,12}(?:\.\d{1,' + str(places) + r'})?', text): raise TradingValidationError(f'{label}: maximal 12 Vorkomma- und {places} Nachkommastellen, ohne Tausendertrennzeichen.') number = Decimal(text) if positive and number <= 0: raise TradingValidationError(f'{label} muss größer als 0 sein.') return number def currency_code(value): code = str(value).strip().upper() if not re.fullmatch('[A-Z]{3}', code): raise TradingValidationError('Währung als dreistelligen Code eingeben, z. B. EUR.') return code def fixed(value, places=2): with localcontext() as ctx: ctx.prec = 60 return format(Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP), f'.{places}f') def exact(value): return format(Decimal(value), 'f') def display_decimal(value, places=2): return fixed(value, places).replace('.', ',') def validate_trade(data): try: day = valid_date(data.get('date', '')) except ValueError as error: raise TradingValidationError(str(error)) from None try: asset_id = int(data.get('asset_id', '')) if isinstance(data.get('asset_id'), bool) or not 1 <= asset_id <= 9223372036854775807: raise ValueError except (ValueError, TypeError): raise TradingValidationError('Bitte eine gültige Position auswählen.') from None kind, source = data.get('transaction_type', 'buy'), data.get('source', 'manual') strategy = data.get('strategy_tag') or None if kind not in TYPES or source not in SOURCES or (strategy is not None and strategy not in STRATEGIES): raise TradingValidationError('Ungültiger Typ, Source oder Strategie-Tag.') quantity = decimal_value(data.get('quantity', ''), 'Stückzahl', positive=True) price = decimal_value(data.get('price_per_unit', ''), 'Kurs') fees = decimal_value(data.get('fees', '0'), 'Gebühren', places=2) note = (data.get('note') or '').strip() if len(note) > 2000: raise TradingValidationError('Notiz darf maximal 2000 Zeichen enthalten.') return dict(date=day, asset_id=asset_id, transaction_type=kind, quantity=exact(quantity), price_per_unit=exact(price), currency=currency_code(data.get('currency', 'EUR')), fees=fixed(fees), source=source, strategy_tag=strategy, note=note or None)