"""Trading ledger and weighted-average open cost; independent of income entries.""" from datetime import date from decimal import Decimal, localcontext, ROUND_DOWN, ROUND_HALF_UP from fastapi import HTTPException from database import connect from trading_models import (CENT, ZERO, SOURCES, STRATEGIES, TradingValidationError, validate_trade, fixed, exact, currency_code) FIELDS = ('date', 'asset_id', 'transaction_type', 'quantity', 'price_per_unit', 'currency', 'fees', 'source', 'strategy_tag', 'note') SELECT = 'SELECT t.*, a.name asset, a.ticker, a.asset_type FROM transactions t JOIN assets a ON a.id=t.asset_id' def _get(db, transaction_id): if not 1 <= transaction_id <= 9223372036854775807: raise HTTPException(404, 'Transaktion nicht gefunden.') row = db.execute(SELECT + ' WHERE t.id=?', (transaction_id,)).fetchone() if row is None: raise HTTPException(404, 'Transaktion nicht gefunden.') return dict(row) def amounts(row): with localcontext() as ctx: ctx.prec = 60 gross = (Decimal(row['quantity']) * Decimal(row['price_per_unit'])).quantize(CENT, rounding=ROUND_HALF_UP) fees = Decimal(row['fees']) total = gross + fees if row['transaction_type'] == 'buy' else gross - fees return dict(gross_amount=fixed(gross), total_amount=fixed(total), total_cost=fixed(total) if row['transaction_type'] == 'buy' else None, net_proceeds=fixed(total) if row['transaction_type'] == 'sell' else None) def _reduce_buckets(buckets, removal, total): """Distribute disposed cost by largest remainder, keeping every cent accounted for.""" if not removal or not total: return if removal == total: for key in buckets: buckets[key] = ZERO return allocations = {key: (value * removal / total).quantize(CENT, rounding=ROUND_DOWN) for key, value in buckets.items()} remainders = sorted(buckets, key=lambda key: (-(buckets[key] * removal / total - allocations[key]), key)) missing = int((removal - sum(allocations.values(), ZERO)) / CENT) for key in remainders[:missing]: allocations[key] += CENT for key in buckets: buckets[key] -= allocations[key] def replay(rows): """Deterministic date/id order. Also validates historical inventory after mutations.""" with localcontext() as ctx: ctx.prec = 60 positions, ledger = {}, [] for original in rows: row = dict(original) aid = row['asset_id'] if aid not in positions: positions[aid] = dict(asset_id=aid, asset=row['asset'], ticker=row['ticker'], asset_type=row['asset_type'], currency=row['currency'], quantity=ZERO, invested_capital=ZERO, realized_profit_loss=ZERO, total_buys=ZERO, total_sells=ZERO, buy_count=0, sell_count=0, first_transaction=row['date'], last_transaction=row['date'], sources={key: ZERO for key in SOURCES}, strategies={key: ZERO for key in [*STRATEGIES, 'untagged']}) position = positions[aid] if position['currency'] != row['currency']: raise TradingValidationError('Eine Position muss in einer einheitlichen Währung geführt werden. Keine automatische Währungsumrechnung.') quantity = Decimal(row['quantity']) row.update(amounts(row)) row['quantity_before'] = exact(position['quantity']) realized = ZERO if row['transaction_type'] == 'buy': cost = Decimal(row['total_cost']) position['quantity'] += quantity position['invested_capital'] += cost position['total_buys'] += cost position['buy_count'] += 1 position['sources'][row['source']] += cost position['strategies'][row['strategy_tag'] or 'untagged'] += cost else: if quantity > position['quantity']: raise TradingValidationError('Verkauf übersteigt den Bestand am Buchungsdatum. Auch spätere Verkäufe müssen nach Änderungen gedeckt bleiben.') cost = position['invested_capital'] removed = cost if quantity == position['quantity'] else (cost * quantity / position['quantity']).quantize(CENT, rounding=ROUND_HALF_UP) _reduce_buckets(position['sources'], removed, cost) _reduce_buckets(position['strategies'], removed, cost) position['quantity'] -= quantity position['invested_capital'] -= removed proceeds = Decimal(row['net_proceeds']) realized = proceeds - removed position['realized_profit_loss'] += realized position['total_sells'] += proceeds position['sell_count'] += 1 position['last_transaction'] = row['date'] row['quantity_after'] = exact(position['quantity']) row['realized_profit_loss'] = fixed(realized) ledger.append(row) for position in positions.values(): position['average_cost'] = position['invested_capital'] / position['quantity'] if position['quantity'] else ZERO return list(positions.values()), ledger def _asset_history(db, asset_id): return db.execute(SELECT + ' WHERE t.asset_id=? ORDER BY t.date,t.id', (asset_id,)).fetchall() def save_transaction(data, transaction_id=None): with connect() as db: db.execute('BEGIN IMMEDIATE') old = _get(db, transaction_id) if transaction_id is not None else None merged = {**old, **data} if old else data values = validate_trade(merged) asset = db.execute('SELECT * FROM assets WHERE id=?', (values['asset_id'],)).fetchone() if asset is None: raise TradingValidationError('Position existiert nicht.') if asset['asset_type'] not in {'stock', 'etf', 'bond', 'crypto'}: raise TradingValidationError('Trading ist für Aktien, ETFs, Anleihen und Krypto möglich.') if not asset['active'] and (old is None or old['asset_id'] != asset['id']) and values['transaction_type'] == 'buy': raise TradingValidationError('Neue Käufe für inaktive Positionen sind nicht möglich.') if old: # Field names are a fixed internal tuple, never supplied by the request. db.execute('UPDATE transactions SET ' + ','.join(field+'=?' for field in FIELDS) + ", updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (*[values[field] for field in FIELDS], transaction_id)) else: transaction_id = db.execute('INSERT INTO transactions ('+','.join(FIELDS)+') VALUES (?,?,?,?,?,?,?,?,?,?)', [values[field] for field in FIELDS]).lastrowid affected = {values['asset_id']} if old: affected.add(old['asset_id']) for aid in affected: replay(_asset_history(db, aid)) return {**_get(db, transaction_id), **amounts(values)} def delete_transaction(transaction_id): with connect() as db: db.execute('BEGIN IMMEDIATE') old = _get(db, transaction_id) db.execute('DELETE FROM transactions WHERE id=?', (transaction_id,)) replay(_asset_history(db, old['asset_id'])) def get_transaction(transaction_id): with connect() as db: row = _get(db, transaction_id) return {**row, **amounts(row)} def list_transactions(year=None, month=None, asset_id=None, transaction_type=None, source=None, strategy_tag=None, limit=None, offset=0): clauses, args = [], [] for clause, value in [("substr(t.date,1,4)=?", f'{year:04}' if year else None), ("substr(t.date,6,2)=?", f'{month:02}' if month else None), ('t.asset_id=?', asset_id), ('t.transaction_type=?', transaction_type), ('t.source=?', source)]: if value is not None: clauses.append(clause) args.append(value) if strategy_tag == 'untagged': clauses.append('t.strategy_tag IS NULL') elif strategy_tag is not None: clauses.append('t.strategy_tag=?') args.append(strategy_tag) query = SELECT + (' WHERE ' + ' AND '.join(clauses) if clauses else '') + ' ORDER BY t.date DESC,t.id DESC' if limit is not None: query += ' LIMIT ? OFFSET ?' args.extend([limit, offset]) with connect() as db: return [{**dict(row), **amounts(row)} for row in db.execute(query, args)] def available_years(): with connect() as db: return [int(row[0]) for row in db.execute('SELECT DISTINCT substr(date,1,4) FROM transactions ORDER BY 1')] def portfolio(): with connect() as db: rows = db.execute(SELECT + ' ORDER BY t.date,t.id').fetchall() return replay(rows) def position_response(position): return {key: (exact(value) if key == 'quantity' else fixed(value, 12 if key == 'average_cost' else 2)) if isinstance(value, Decimal) else value for key, value in position.items() if key not in {'sources', 'strategies'}} def positions(currency=None, include_closed=False): items, _ = portfolio() return [position_response(p) for p in items if (include_closed or p['quantity'] > 0) and (currency is None or p['currency'] == currency)] def asset_detail(asset_id): from services.asset_service import get_asset asset = get_asset(asset_id) with connect() as db: items, ledger = replay(_asset_history(db, asset_id)) return dict(asset=asset, position=position_response(items[0]) if items else None, entries=list(reversed(ledger))) def trading_stats(currency='EUR', today=None): currency = currency_code(currency) today = today or date.today() with localcontext() as ctx: ctx.prec = 60 all_positions, all_ledger = portfolio() ps = [p for p in all_positions if p['currency'] == currency] ledger = [r for r in all_ledger if r['currency'] == currency] current = [r for r in ledger if int(r['date'][:4]) == today.year] invested = sum((p['invested_capital'] for p in ps), ZERO) def breakdown(field, keys): rows = [] for key in keys: amount = sum((p[field][key] for p in ps), ZERO) rows.append({'key': key, 'amount': fixed(amount), 'percentage': fixed(amount * 100 / invested) if invested else None}) return rows years = sorted({int(r['date'][:4]) for r in ledger} | {today.year}) monthly = {y: [0]*12 for y in years} for row in ledger: if row['transaction_type'] == 'buy': monthly[int(row['date'][:4])][int(row['date'][5:7])-1] += 1 return dict(currency=currency, invested_capital=fixed(invested), active_positions=sum(p['quantity'] > 0 for p in ps), buys_current_year=sum(r['transaction_type'] == 'buy' for r in current), sells_current_year=sum(r['transaction_type'] == 'sell' for r in current), realized_profit_loss_current_year=fixed(sum((Decimal(r['realized_profit_loss']) for r in current), ZERO)), transactions_total=len(ledger), by_source=breakdown('sources', SOURCES), by_strategy=breakdown('strategies', [*STRATEGIES, 'untagged']), monthly=[{'year': y, 'counts': monthly[y]} for y in years], currencies=sorted({p['currency'] for p in all_positions} | {'EUR'}), positions=[position_response(p) for p in ps if p['quantity'] > 0])