Add trading journal and portfolio tracking
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user