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