Files

74 lines
3.3 KiB
Python

"""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)