Add trading journal and portfolio tracking

This commit is contained in:
kai
2026-09-09 19:01:30 +02:00
parent 1db3b008ec
commit c683bc74c0
24 changed files with 1043 additions and 8 deletions
+132
View File
@@ -0,0 +1,132 @@
import csv
import io
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Query, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from services import trading_service as service
from services.asset_service import list_assets, create_asset
from trading_models import TYPES, SOURCES, STRATEGIES, TradingValidationError
from routes.export import safe_cell
from views import render
router = APIRouter()
def choices():
return dict(trade_types=TYPES, sources=SOURCES, strategies=STRATEGIES)
def trade_form(request, data, error=None, status=200, transaction_id=None):
assets = [a for a in list_assets() if a['asset_type'] in {'stock','etf','bond','crypto'}]
return render(request, 'trading_form.html', dict(data=data, assets=assets, error=error, transaction_id=transaction_id, **choices()), status)
@router.get('/trading')
def index(request: Request, currency: Annotated[str, Query(pattern='^[A-Z]{3}$')] = 'EUR'):
return render(request, 'trading.html', dict(stats=service.trading_stats(currency), entries=service.list_transactions(limit=20), **choices()))
@router.get('/trading/positions')
def positions(request: Request):
return render(request, 'trading_positions.html', {'positions': service.positions()})
@router.get('/trading/transactions')
def history(request: Request, page: Annotated[int, Query(ge=1, le=1000000)] = 1):
filters = {}
for field, maximum in [('year',9999), ('month',12), ('asset_id',9223372036854775807)]:
value = request.query_params.get(field)
if value:
try:
parsed = int(value)
if not 1 <= parsed <= maximum:
raise ValueError
except ValueError:
raise HTTPException(422, 'Ungültiger Filter.') from None
filters[field] = parsed
for field, allowed in [('transaction_type',TYPES), ('source',SOURCES), ('strategy_tag',{**STRATEGIES,'untagged':'Ohne Tag'})]:
value = request.query_params.get(field)
if value:
if value not in allowed:
raise HTTPException(422, 'Ungültiger Filter.')
filters[field] = value
rows = service.list_transactions(**filters, limit=101, offset=(page-1)*100)
return render(request, 'trading_history.html', dict(entries=rows[:100], more=len(rows)>100, page=page,
filters=filters, assets=list_assets(), years=service.available_years(), **choices()))
@router.get('/trading/assets/new')
def new_asset(request: Request):
return render(request, 'trading_asset_form.html', {'data': {}})
@router.post('/trading/assets/new')
def save_asset(request: Request, name: Annotated[str, Form()] = '', asset_type: Annotated[str, Form()] = 'stock', ticker: Annotated[str, Form()] = ''):
try:
if asset_type not in {'stock','etf','bond','crypto'}:
raise ValueError('Bitte Aktie, ETF, Anleihe oder Krypto auswählen.')
asset = create_asset(name, asset_type, ticker, reuse=True)
except ValueError as error:
return render(request, 'trading_asset_form.html', {'data':dict(name=name,asset_type=asset_type,ticker=ticker),'error':str(error)}, 422)
return RedirectResponse(f"/trading/transactions/new?asset_id={asset['id']}", status_code=303)
@router.get('/trading/assets/{asset_id}')
def asset_detail(request: Request, asset_id: int):
return render(request, 'trading_asset.html', {**service.asset_detail(asset_id), **choices()})
@router.get('/trading/transactions/new')
def new(request: Request):
return trade_form(request, dict(date=date.today().isoformat(), currency='EUR', fees='0', transaction_type='buy', source='manual', asset_id=request.query_params.get('asset_id','')))
@router.get('/trading/transactions/{transaction_id}/edit')
def edit(request: Request, transaction_id: int):
return trade_form(request, service.get_transaction(transaction_id), transaction_id=transaction_id)
@router.post('/trading/transactions/new')
@router.post('/trading/transactions/{transaction_id}/edit')
def save(request: Request, date: Annotated[str, Form()] = '', asset_id: Annotated[str, Form()] = '',
transaction_type: Annotated[str, Form()] = 'buy', quantity: Annotated[str, Form()] = '',
price_per_unit: Annotated[str, Form()] = '', currency: Annotated[str, Form()] = 'EUR',
fees: Annotated[str, Form()] = '0', source: Annotated[str, Form()] = 'manual',
strategy_tag: Annotated[str, Form()] = '', note: Annotated[str, Form()] = '', transaction_id: int | None = None):
data = dict(date=date,asset_id=asset_id,transaction_type=transaction_type,quantity=quantity,price_per_unit=price_per_unit,
currency=currency,fees=fees,source=source,strategy_tag=strategy_tag,note=note)
try:
service.save_transaction(data, transaction_id)
except TradingValidationError as error:
return trade_form(request, data, str(error), 422, transaction_id)
return RedirectResponse('/trading?message=trade_saved', status_code=303)
@router.post('/trading/transactions/{transaction_id}/delete')
def delete(request: Request, transaction_id: int):
try:
service.delete_transaction(transaction_id)
except TradingValidationError as error:
return render(request, 'trading_error.html', {'error':str(error)}, 422)
return RedirectResponse('/trading?message=trade_deleted', status_code=303)
def export_rows():
buffer = io.StringIO(newline='')
writer = csv.writer(buffer, delimiter=';', lineterminator='\r\n')
yield '\ufeff'
writer.writerow(['Datum','Position','Typ','Stückzahl','Kurs','Währung','Gebühren','Gesamtbetrag','Source','Strategie-Tag','Notiz'])
yield buffer.getvalue()
buffer.seek(0); buffer.truncate(0)
for row in service.list_transactions():
writer.writerow([row['date'], safe_cell(row['asset']), TYPES[row['transaction_type']], row['quantity'].replace('.',','),
row['price_per_unit'].replace('.',','), row['currency'], row['fees'].replace('.',','),
row['total_amount'].replace('.',','), SOURCES[row['source']], STRATEGIES.get(row['strategy_tag'],'Ohne Tag'), safe_cell(row['note'])])
yield buffer.getvalue()
buffer.seek(0); buffer.truncate(0)
@router.get('/export/trading.csv')
def export():
return StreamingResponse(export_rows(), media_type='text/csv; charset=utf-8', headers={'Content-Disposition':'attachment; filename="trading.csv"'})