74 lines
3.3 KiB
Python
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)
|