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
+3
View File
@@ -10,6 +10,7 @@ from database import connect
from models import CATEGORIES, decimal_string
from services import asset_service, income_service
from api.auth import require_token
from trading_models import TradingValidationError
from api.schemas import (
AssetCreate, AssetPatch, AssetResponse, AssetShareResponse, Category,
CategoryShareResponse, IncomeCreate, IncomePatch, IncomeResponse,
@@ -30,6 +31,8 @@ class SafeAPIRoute(APIRoute):
# Never echo raw request bodies, credentials or internal exception context.
details = [{'loc': e['loc'], 'msg': e['msg'], 'type': e['type']} for e in error.errors()]
return JSONResponse({'detail': details}, status_code=422)
except TradingValidationError as error:
return JSONResponse({'detail': str(error)}, status_code=422)
except ValueError:
return JSONResponse({'detail': 'Ungültige Werte. Bitte Felder und Position prüfen.'}, status_code=422)
except sqlite3.IntegrityError:
+165
View File
@@ -0,0 +1,165 @@
from typing import Annotated, Literal
from fastapi import APIRouter, Query, Response
from pydantic import BeforeValidator, Field, model_validator
from api.routes import SafeAPIRoute
from api.schemas import RequestModel, Identifier, Day
from pydantic import BaseModel
from services import trading_service as service
from trading_models import decimal_value, currency_code, exact, fixed
TradeType = Literal['buy', 'sell']
Source = Literal['manual', 'savings_plan', 'roundup', 'cashback', 'rebalancing', 'other']
Strategy = Literal['core', 'income', 'conviction', 'dip_buy', 'speculation', 'rebalancing', 'other']
StrategyFilter = Literal['core', 'income', 'conviction', 'dip_buy', 'speculation', 'rebalancing', 'other', 'untagged']
Quantity = Annotated[str, BeforeValidator(lambda v: exact(decimal_value(v, 'Stückzahl', positive=True)))]
Price = Annotated[str, BeforeValidator(lambda v: exact(decimal_value(v, 'Kurs')))]
Fees = Annotated[str, BeforeValidator(lambda v: fixed(decimal_value(v, 'Gebühren', places=2)))]
Currency = Annotated[str, BeforeValidator(currency_code)]
class TradeCreate(RequestModel):
date: Day
asset_id: Identifier
transaction_type: TradeType = 'buy'
quantity: Quantity
price_per_unit: Price
currency: Currency = 'EUR'
fees: Fees = '0.00'
source: Source = 'manual'
strategy_tag: Strategy | None = None
note: str | None = Field(default=None, max_length=2000)
class TradePatch(RequestModel):
date: Day | None = None
asset_id: Identifier | None = None
transaction_type: TradeType | None = None
quantity: Quantity | None = None
price_per_unit: Price | None = None
currency: Currency | None = None
fees: Fees | None = None
source: Source | None = None
strategy_tag: Strategy | None = None
note: str | None = Field(default=None, max_length=2000)
@model_validator(mode='before')
@classmethod
def required_not_null(cls, values):
if isinstance(values, dict) and any(v is None and k not in {'strategy_tag','note'} for k,v in values.items()):
raise ValueError('Nur Strategie-Tag und Notiz dürfen null sein.')
return values
class TradeResponse(BaseModel):
id: int
date: str
asset_id: int
asset: str
transaction_type: TradeType
quantity: str
price_per_unit: str
currency: str
fees: str
gross_amount: str
total_amount: str
total_cost: str | None
net_proceeds: str | None
source: Source
strategy_tag: Strategy | None
note: str | None
created_at: str
updated_at: str
class PositionResponse(BaseModel):
asset_id: int
asset: str
ticker: str | None
asset_type: str
currency: str
quantity: str
average_cost: str
invested_capital: str
total_buys: str
total_sells: str
realized_profit_loss: str
buy_count: int
sell_count: int
first_transaction: str
last_transaction: str
class TradingStatsResponse(BaseModel):
currency: str
invested_capital: str
active_positions: int
buys_current_year: int
sells_current_year: int
realized_profit_loss_current_year: str
transactions_total: int
class ShareResponse(BaseModel):
key: str
amount: str
percentage: str | None
class BreakdownResponse(BaseModel):
currency: str
items: list[ShareResponse]
router = APIRouter(route_class=SafeAPIRoute, tags=['Trading'])
@router.get('/transactions', response_model=list[TradeResponse])
def transactions(year: Annotated[int | None, Query(ge=1, le=9999)] = None,
month: Annotated[int | None, Query(ge=1, le=12)] = None,
asset_id: Annotated[int | None, Query(ge=1, le=9223372036854775807)] = None,
transaction_type: TradeType | None = None, source: Source | None = None,
strategy_tag: StrategyFilter | None = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
offset: Annotated[int, Query(ge=0, le=9223372036854775807)] = 0):
return [TradeResponse(**row) for row in service.list_transactions(year, month, asset_id, transaction_type, source, strategy_tag, limit, offset)]
@router.get('/transactions/{transaction_id}', response_model=TradeResponse)
def transaction(transaction_id: int):
return TradeResponse(**service.get_transaction(transaction_id))
@router.post('/transactions', response_model=TradeResponse, status_code=201)
def create(data: TradeCreate):
return TradeResponse(**service.save_transaction(data.model_dump()))
@router.patch('/transactions/{transaction_id}', response_model=TradeResponse)
def update(transaction_id: int, data: TradePatch):
return TradeResponse(**service.save_transaction(data.model_dump(exclude_unset=True), transaction_id))
@router.delete('/transactions/{transaction_id}', status_code=204)
def delete(transaction_id: int):
service.delete_transaction(transaction_id)
return Response(status_code=204)
@router.get('/positions', response_model=list[PositionResponse])
def positions(currency: Annotated[str | None, Query(pattern='^[A-Z]{3}$')] = None, include_closed: bool = False):
return [PositionResponse(**row) for row in service.positions(currency, include_closed)]
@router.get('/trading/stats', response_model=TradingStatsResponse)
def stats(currency: Annotated[str, Query(pattern='^[A-Z]{3}$')] = 'EUR'):
return TradingStatsResponse(**service.trading_stats(currency))
@router.get('/trading/by-source', response_model=BreakdownResponse)
def by_source(currency: Annotated[str, Query(pattern='^[A-Z]{3}$')] = 'EUR'):
return BreakdownResponse(currency=currency, items=service.trading_stats(currency)['by_source'])
@router.get('/trading/by-strategy', response_model=BreakdownResponse)
def by_strategy(currency: Annotated[str, Query(pattern='^[A-Z]{3}$')] = 'EUR'):
return BreakdownResponse(currency=currency, items=service.trading_stats(currency)['by_strategy'])
+25 -2
View File
@@ -83,8 +83,9 @@ def initialize(path=None):
('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]:
ensure_asset(db, name, kind)
_update_interest_positions(db)
if db.execute('PRAGMA user_version').fetchone()[0] < 2:
db.execute('PRAGMA user_version = 2')
_create_trading_schema(db)
if db.execute('PRAGMA user_version').fetchone()[0] < 3:
db.execute('PRAGMA user_version = 3')
def _update_interest_positions(db):
@@ -98,3 +99,25 @@ def _update_interest_positions(db):
db.execute('UPDATE assets SET active=0 WHERE normalized_name IN (?,?)',
(name_key('Zinsen'), name_key('Steuerrückzahlung')))
db.execute('INSERT INTO data_migrations (name) VALUES (?)', (migration,))
def _create_trading_schema(db):
# Transactional DDL: retain every existing table and income entry.
db.execute("""CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
transaction_type TEXT NOT NULL CHECK(transaction_type IN ('buy','sell')),
quantity TEXT NOT NULL CHECK(typeof(quantity)='text'),
price_per_unit TEXT NOT NULL CHECK(typeof(price_per_unit)='text'),
currency TEXT NOT NULL CHECK(length(currency)=3),
fees TEXT NOT NULL DEFAULT '0.00' CHECK(typeof(fees)='text'),
source TEXT NOT NULL DEFAULT 'manual' CHECK(source IN ('manual','savings_plan','roundup','cashback','rebalancing','other')),
strategy_tag TEXT CHECK(strategy_tag IN ('core','income','conviction','dip_buy','speculation','rebalancing','other')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)""")
db.execute('CREATE INDEX IF NOT EXISTS transactions_asset_date ON transactions(asset_id,date,id)')
db.execute('CREATE INDEX IF NOT EXISTS transactions_date ON transactions(date DESC,id DESC)')
db.execute('INSERT OR IGNORE INTO data_migrations(name) VALUES (?)', ('2026-09-09-trading-schema',))
+5
View File
@@ -8,6 +8,10 @@ from fastapi.staticfiles import StaticFiles
from database import initialize
from routes import dashboard, income, export
from api.routes import router as api_router
from api.trading import router as trading_api_router
from routes.trading import router as trading_router
api_router.include_router(trading_api_router)
@asynccontextmanager
@@ -22,6 +26,7 @@ app.include_router(dashboard.router)
app.include_router(income.router)
app.include_router(export.router)
app.include_router(api_router)
app.include_router(trading_router)
@app.middleware('http')
+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"'})
+228
View File
@@ -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])
+3
View File
@@ -1 +1,4 @@
:root{color-scheme:dark;--bg:#0b111c;--panel:#141e2c;--border:#293548;--text:#e7edf5;--muted:#a4b3c7;--green:#72e2b0;--red:#ff9696;--yellow:#f4c272}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:var(--green);text-decoration:none}a:hover{text-decoration:underline}button,input,select,textarea{font:inherit}button,.button{background:var(--green);color:#10241d;border:1px solid transparent;border-radius:9px;padding:11px 17px;cursor:pointer;font-weight:650;display:inline-block;text-align:center}button:hover,.button:hover{background:#98edc7;text-decoration:none}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid var(--green);outline-offset:3px}.topbar{border-bottom:1px solid var(--border);padding:20px max(24px,calc((100vw - 1440px)/2));display:flex;justify-content:space-between;gap:20px;align-items:center}.brand{font-size:20px;font-weight:700;color:var(--text)}nav{display:flex;gap:24px;flex-wrap:wrap}main{max-width:1488px;margin:auto;padding:32px 24px}h1{font-size:clamp(25px,4vw,34px);line-height:1.2;letter-spacing:-.03em;margin:8px 0 12px}h2{font-size:18px;line-height:1.4;margin:0 0 10px}p{margin:8px 0 18px}.eyebrow{font-size:12px;letter-spacing:.14em;font-weight:700;color:var(--green);margin-bottom:8px}.page-heading,.section-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}.muted,small{color:var(--muted)}.positive{color:var(--green)}.negative{color:var(--red)}.warning{color:var(--yellow)}.kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px}.kpi,.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:24px}.kpi h2{font-size:13px;font-weight:500;color:var(--muted)}.kpi strong{display:block;font-size:clamp(23px,2.5vw,30px);font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.kpi small{display:block;margin-top:9px;font-size:12px}.panel{margin-bottom:24px;min-width:0}.table-scroll{overflow:auto;max-width:100%}table{width:100%;border-collapse:collapse;text-align:left;font-size:14px}th,td{padding:13px 12px;border-bottom:1px solid var(--border)}th{color:var(--muted);font-weight:600}tbody tr:hover{background:#ffffff03}.numeric,.comparison td,.comparison th:not(:first-child){text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.comparison th:first-child{position:sticky;left:0;background:var(--panel)}tfoot{font-weight:700}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.chart-wide{grid-column:1/-1}.chart{position:relative;height:300px}.amount-list{list-style:none;padding:0}.amount-list li{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding:8px 0}.amount-list strong{white-space:nowrap}.notice{padding:16px 20px;border:1px solid currentColor;border-radius:10px;margin-bottom:24px}.success{color:var(--green)}.form-panel{max-width:650px;margin:0 auto 24px}.entry-form{display:grid;gap:18px}label{display:grid;gap:6px;font-size:14px}input,select,textarea{width:100%;padding:11px 12px;border:1px solid #43516a;border-radius:8px;background:#0c1420;color:var(--text);min-width:0}textarea{resize:vertical}.checkbox{display:flex;align-items:center;gap:12px}.checkbox input{width:18px;height:18px;accent-color:var(--green)}.actions{display:flex;align-items:center;gap:14px}.actions form{margin:0}.danger{color:var(--red);background:transparent;border-color:#754343}.danger:hover{background:#45272d}.small{font-size:13px;padding:5px 9px}.nowrap,.badge{white-space:nowrap}.badge{font-size:12px}.note{min-width:120px;max-width:300px;overflow-wrap:anywhere;white-space:pre-wrap}.empty{text-align:center;color:var(--muted);padding:35px}.filters{display:flex;align-items:end;flex-wrap:wrap;gap:16px;margin-bottom:24px}.filters label{flex:1;min-width:140px}.pagination{display:flex;justify-content:center;gap:24px;flex-wrap:wrap;margin-top:22px;color:var(--muted)}footer{max-width:1488px;padding:0 24px 24px;margin:auto;color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:16px}summary{cursor:pointer;color:var(--green);margin-top:15px}@media(max-width:1000px){.kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.topbar{align-items:flex-start;flex-direction:column;padding:18px}nav{gap:18px;font-size:14px}main{padding:24px 14px}.page-heading,.section-heading{align-items:flex-start;flex-direction:column}.page-heading .button{width:100%}.kpi-grid{gap:10px}.kpi{padding:16px 12px}.kpi strong{font-size:23px}.panel{padding:18px 12px}.chart-grid{grid-template-columns:1fr}.chart{height:280px}.section-heading{gap:5px}footer{flex-direction:column}.filters{gap:12px}.actions{gap:10px}.comparison th:first-child{min-width:100px}}
.trading-nav { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.tag { display: inline-block; white-space: nowrap; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); }
+28
View File
@@ -0,0 +1,28 @@
'use strict';
(() => {
const status = document.getElementById('trading-chart-status');
if (typeof Chart === 'undefined') {
status.textContent = 'Diagramme konnten nicht geladen werden. Alle Werte sind in den Tabellen verfügbar.';
return;
}
const {stats, sources, strategies, months} = JSON.parse(document.getElementById('trading-data').textContent);
const palette = ['#72e2b0','#79b6ff','#a6b7ca','#bda7d8','#d5bf8d','#78adb1','#8fa494','#b7bac6'];
Chart.defaults.color = '#a4b3c7';
Chart.defaults.borderColor = '#293548';
for (const field of ['by_source', 'by_strategy']) {
const rows = stats[field];
const names = field === 'by_source' ? sources : {...strategies, untagged: 'Ohne Tag'};
new Chart(document.getElementById(field), {
type: 'doughnut', data: {labels: rows.map(r => names[r.key]), datasets:[{data:rows.map(r => Number(r.amount)), backgroundColor:palette, borderWidth:0}]},
options:{responsive:true,maintainAspectRatio:false,animation:false,cutout:'65%',plugins:{legend:{position:'bottom'},tooltip:{callbacks:{label:context => {
const row = rows[context.dataIndex];
return `${names[row.key]}: ${row.amount.replace('.', ',')} ${stats.currency} (${row.percentage === null ? '' : row.percentage.replace('.', ',') + ' %'})`;
}}}}}
});
}
new Chart(document.getElementById('trading-monthly'), {
type:'bar', data:{labels:months,datasets:stats.monthly.map((row,i) => ({label:String(row.year),data:row.counts,backgroundColor:palette[i % palette.length]}))},
options:{responsive:true,maintainAspectRatio:false,animation:false,scales:{y:{beginAtZero:true,ticks:{precision:0},title:{display:true,text:'Anzahl Käufe'}}}}
});
status.textContent = 'Diagramme zeigen den offenen Einstand und die Anzahl echter Käufe in der gewählten Währung.';
})();
+7
View File
@@ -0,0 +1,7 @@
<div class="table-scroll"><table><thead><tr><th>Datum</th><th>Position</th><th>Typ</th><th>Stückzahl</th><th>Kurs</th><th>Währung</th><th>Gebühren</th><th>Gesamtbetrag</th><th>Source</th><th>Strategie</th>{% if show_balance %}<th>Bestand vorher</th><th>Bestand danach</th>{% endif %}<th>Notiz</th><th>Aktionen</th></tr></thead>
<tbody>{% for entry in entries %}<tr>
<td class="nowrap">{{ entry.date[8:10] }}.{{ entry.date[5:7] }}.{{ entry.date[:4] }}</td><td><a href="/trading/assets/{{ entry.asset_id }}">{{ entry.asset }}</a></td><td><span class="badge {{ 'positive' if entry.transaction_type == 'buy' else 'muted' }}">{{ trade_types[entry.transaction_type] }}</span></td>
<td class="numeric">{{ entry.quantity|replace('.', ',') }}</td><td class="numeric">{{ entry.price_per_unit|replace('.', ',') }}</td><td>{{ entry.currency }}</td><td class="numeric">{{ entry.fees|decimal }}</td><td class="numeric">{{ entry.total_amount|decimal }}</td><td><span class="tag">{{ sources[entry.source] }}</span></td><td><span class="tag">{{ strategies.get(entry.strategy_tag, 'Ohne Tag') }}</span></td>
{% if show_balance %}<td class="numeric">{{ entry.quantity_before|replace('.', ',') }}</td><td class="numeric">{{ entry.quantity_after|replace('.', ',') }}</td>{% endif %}
<td class="note">{{ entry.note or '' }}</td><td><div class="actions"><a href="/trading/transactions/{{ entry.id }}/edit">Bearbeiten</a><form method="post" action="/trading/transactions/{{ entry.id }}/delete" data-confirm="Diese Transaktion wirklich löschen? Der Bestand wird neu berechnet."><button class="danger small">Löschen</button></form></div></td></tr>
{% else %}<tr><td colspan="{{ 14 if show_balance else 12 }}" class="empty">Noch keine Transaktionen. Trage einen Kauf mit bekanntem Datum und Kurs ein.</td></tr>{% endfor %}</tbody></table></div>
+1
View File
@@ -0,0 +1 @@
<nav class="trading-nav" aria-label="Trading-Navigation"><a href="/trading">Trading-Übersicht</a><a href="/trading/positions">Positionen</a><a href="/trading/transactions">Alle Transaktionen</a><a href="/export/trading.csv">Trading-CSV</a></nav>
+3
View File
@@ -0,0 +1,3 @@
<div class="table-scroll"><table><thead><tr><th>Position</th><th>Stückzahl</th><th>Ø Einstand</th><th>Investiertes Kapital</th><th>Währung</th><th>Käufe</th><th>Verkäufe</th><th>Realisiert G/V</th></tr></thead><tbody>
{% for position in positions %}<tr><td><a href="/trading/assets/{{ position.asset_id }}">{{ position.asset }}</a></td><td class="numeric">{{ position.quantity|replace('.', ',') }}</td><td class="numeric">{{ position.average_cost|decimal(8) }}</td><td class="numeric">{{ position.invested_capital|decimal }}</td><td>{{ position.currency }}</td><td>{{ position.buy_count }}</td><td>{{ position.sell_count }}</td><td class="numeric {{ 'negative' if position.realized_profit_loss.startswith('-') else 'positive' }}">{{ position.realized_profit_loss|decimal }}</td></tr>
{% else %}<tr><td colspan="8" class="empty">Noch keine offenen Positionen aus erfassten Käufen.</td></tr>{% endfor %}</tbody></table></div>
+3 -2
View File
@@ -7,13 +7,14 @@
<script defer src="{{ url_for('static', path='js/forms.js') }}"></script>
</head>
<body>
<header class="topbar"><a class="brand" href="/">💶 Finance Dashboard</a><nav aria-label="Hauptnavigation"><a href="/">Übersicht</a><a href="/income">Alle Zahlungen</a><a href="/export/income.csv">CSV-Export</a></nav></header>
<header class="topbar"><a class="brand" href="/">💶 Finance Dashboard</a><nav aria-label="Hauptnavigation"><a href="/">Übersicht</a><a href="/income">Alle Zahlungen</a><a href="/trading">Trading-Buch</a><a href="/export/income.csv">CSV-Export</a></nav></header>
<main>
{% set message = request.query_params.get('message') %}
{% if message in ['saved', 'deleted'] %}<p class="notice success" role="status">{{ 'Zahlung gespeichert.' if message == 'saved' else 'Zahlung gelöscht.' }}</p>{% endif %}
{% if message in ['trade_saved', 'trade_deleted'] %}<p class="notice success" role="status">{{ 'Transaktion gespeichert.' if message == 'trade_saved' else 'Transaktion gelöscht.' }}</p>{% endif %}
{% block content %}{% endblock %}
</main>
<footer>pinguAurora meldet sich zum Dienst. <span>Private Finanzen · Beträge in EUR</span></footer>
<footer>pinguAurora meldet sich zum Dienst. <span>Private Finanzen · Währungen separat</span></footer>
{% block scripts %}{% endblock %}
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block title %}Trading-Buch · Finance Dashboard{% endblock %}
{% block content %}
{% include '_trading_nav.html' %}
<div class="page-heading"><div><p class="eyebrow">DEPOT & TRANSAKTIONEN</p><h1>Trading-Buch</h1><p class="muted">Deine Käufe, Verkäufe und offenen Einstandswerte.</p></div><a class="button" href="/trading/transactions/new">+ Transaktion eintragen</a></div>
<form class="filters" method="get"><label>Auswertungswährung<select name="currency">{% for currency in stats.currencies %}<option {{ 'selected' if currency == stats.currency else '' }}>{{ currency }}</option>{% endfor %}</select></label><button>Auswerten</button><span class="muted">Alle Kennzahlen und Diagramme nur in {{ stats.currency }} · keine Währungsumrechnung</span></form>
<div class="kpi-grid">
<article class="kpi"><h2>Investiertes Kapital</h2><strong>{{ stats.invested_capital|decimal }} {{ stats.currency }}</strong><small>Einstand der offenen Positionen inkl. Gebühren</small></article>
<article class="kpi"><h2>Aktive Positionen</h2><strong>{{ stats.active_positions }}</strong><small>Mit positivem Bestand</small></article>
<article class="kpi"><h2>Käufe dieses Jahr</h2><strong>{{ stats.buys_current_year }}</strong><small>Anzahl Kauftransaktionen</small></article>
<article class="kpi"><h2>Verkäufe dieses Jahr</h2><strong>{{ stats.sells_current_year }}</strong><small>Anzahl Verkaufstransaktionen</small></article>
<article class="kpi"><h2>Realisiert G/V dieses Jahr</h2><strong class="{{ 'negative' if stats.realized_profit_loss_current_year.startswith('-') else 'positive' }}">{{ stats.realized_profit_loss_current_year|decimal }} {{ stats.currency }}</strong><small>Nach Gebühren · Durchschnittseinstand</small></article>
<article class="kpi"><h2>Transaktionen gesamt</h2><strong>{{ stats.transactions_total }}</strong><small>Alle Jahre · {{ stats.currency }}</small></article>
</div>
<p class="notice warning">Portfolioanalyse mit gleitendem Durchschnittseinstand. Keine deutsche steuerliche FIFO-Berechnung.</p>
<section class="panel"><div class="section-heading"><h2>Aktuelle Positionen · {{ stats.currency }}</h2><a href="/trading/positions">Alle Währungen →</a></div>{% set positions = stats.positions %}{% include '_trading_positions.html' %}</section>
<div class="chart-grid">
{% for field,title in [('by_source','Investiertes Kapital nach Source'),('by_strategy','Investiertes Kapital nach Strategie')] %}
<section class="panel"><h2>{{ title }}</h2><p class="muted">Offener Einstand · Verkäufe reduzieren ursprüngliche Anteile proportional.</p><div class="chart"><canvas id="{{ field }}" role="img" aria-label="{{ title }}; Werte in der nachfolgenden Tabelle"></canvas></div><div class="table-scroll"><table><thead><tr><th>{{ 'Source' if field == 'by_source' else 'Strategie' }}</th><th>Betrag ({{ stats.currency }})</th><th>Anteil</th></tr></thead><tbody>{% for row in stats[field] %}<tr><td><span class="tag">{{ sources[row.key] if field == 'by_source' else strategies.get(row.key, 'Ohne Tag') }}</span></td><td class="numeric">{{ row.amount|decimal }}</td><td class="numeric">{{ row.percentage|decimal if row.percentage is not none else '' }}{{ ' %' if row.percentage is not none else '' }}</td></tr>{% endfor %}</tbody></table></div></section>
{% endfor %}
<section class="panel chart-wide"><h2>Käufe pro Monat und Jahr</h2><div class="chart"><canvas id="trading-monthly" role="img" aria-label="Anzahl Käufe nach Monat und Jahr"></canvas></div><details><summary>Monatswerte anzeigen</summary><div class="table-scroll"><table><thead><tr><th>Jahr</th>{% for month in months %}<th>{{ month }}</th>{% endfor %}</tr></thead><tbody>{% for year in stats.monthly %}<tr><th>{{ year.year }}</th>{% for count in year.counts %}<td>{{ count }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div></details></section>
</div><p class="muted" id="trading-chart-status">Diagramme werden geladen. Alle Werte sind auch als Tabellen verfügbar.</p>
<section class="panel"><div class="section-heading"><h2>Letzte Transaktionen · alle Währungen</h2><a href="/trading/transactions">Vollständige Historie →</a></div>{% include '_trading_entries.html' %}</section>
{% endblock %}
{% block scripts %}<script id="trading-data" type="application/json">{{ {'stats':stats, 'sources':sources, 'strategies':strategies, 'months':months}|tojson }}</script><script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script><script defer src="{{ url_for('static', path='js/trading.js') }}"></script>{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
{% extends 'base.html' %}{% block title %}{{ asset.name }} · Trading-Buch{% endblock %}{% block content %}
{% include '_trading_nav.html' %}<div class="page-heading"><div><h1>{{ asset.name }}</h1><p class="muted">{{ asset.ticker or 'Kein Ticker' }} · {{ asset_types[asset.asset_type] }}</p></div><a class="button" href="/trading/transactions/new?asset_id={{ asset.id }}">+ Transaktion eintragen</a></div>
{% if position %}<div class="kpi-grid">{% for label,value in [('Stückzahl',position.quantity|replace('.',',')),('Ø Einstand',position.average_cost|decimal(8)),('Investiertes Kapital',position.invested_capital|decimal),('Realisiert G/V',position.realized_profit_loss|decimal),('Summe Käufe inkl. Gebühren',position.total_buys|decimal),('Summe Verkäufe nach Gebühren',position.total_sells|decimal)] %}<article class="kpi"><h2>{{ label }}</h2><strong>{{ value }}</strong><small>{{ position.currency if label != 'Stückzahl' else 'Stück' }}</small></article>{% endfor %}</div><p class="muted">Erste Buchung: {{ position.first_transaction }} · Letzte Buchung: {{ position.last_transaction }} · {{ position.buy_count }} Käufe / {{ position.sell_count }} Verkäufe</p>{% else %}<p class="notice">Noch keine erfassten Trades. Es wurden keine Anfangsbestände oder Preise angenommen.</p>{% endif %}
<section class="panel"><h2>Vollständige Positionshistorie</h2>{% set show_balance = true %}{% include '_trading_entries.html' %}</section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}{% include '_trading_nav.html' %}<section class="panel form-panel"><h1>Neue Trading-Position</h1>{% if error %}<p class="notice negative">{{ error }}</p>{% endif %}<form method="post" class="entry-form"><label>Name<input name="name" required maxlength="150" value="{{ data.get('name','') }}"></label><label>Ticker (optional)<input name="ticker" maxlength="30" value="{{ data.get('ticker','') }}"></label><label>Asset-Typ<select name="asset_type">{% for key in ['stock','etf','bond','crypto'] %}<option value="{{ key }}" {{ 'selected' if data.get('asset_type') == key else '' }}>{{ asset_types[key] }}</option>{% endfor %}</select></label><div class="actions"><button>Position speichern</button><a href="/trading/transactions/new">Zurück</a></div></form></section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}<section class="panel form-panel"><h1>Transaktion nicht geändert</h1><p class="notice negative" role="alert">{{ error }}</p><a href="/trading/transactions">Zurück zur Historie</a></section>{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'base.html' %}{% block title %}Transaktion {{ 'bearbeiten' if transaction_id else 'eintragen' }}{% endblock %}
{% block content %}{% include '_trading_nav.html' %}<section class="form-panel panel"><h1>Transaktion {{ 'bearbeiten' if transaction_id else 'eintragen' }}</h1>{% if error %}<p class="notice negative" role="alert">{{ error }}</p>{% endif %}
<form class="entry-form" method="post">
<label>Datum<input type="date" name="date" required value="{{ data.get('date','') }}"></label>
<label>Position<select name="asset_id" required><option value="">Bitte auswählen</option>{% for asset in assets %}<option value="{{ asset.id }}" {{ 'selected' if asset.id|string == data.get('asset_id')|string else '' }}>{{ asset.name }}{{ ' (inaktiv nur Verkauf/Bestandskorrektur)' if not asset.active else '' }}</option>{% endfor %}</select></label><a href="/trading/assets/new">+ Neue Position</a>
<label>Typ<select name="transaction_type">{% for key,label in trade_types.items() %}<option value="{{ key }}" {{ 'selected' if data.get('transaction_type') == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<label>Stückzahl<input name="quantity" required inputmode="decimal" maxlength="25" value="{{ data.get('quantity','') }}" placeholder="0,092262"></label>
<label>Kurs je Stück<input name="price_per_unit" required inputmode="decimal" maxlength="25" value="{{ data.get('price_per_unit','') }}" placeholder="Bekannten Kauf-/Verkaufskurs eingeben"></label>
<label>Währung<input name="currency" required minlength="3" maxlength="3" value="{{ data.get('currency','EUR') }}" placeholder="EUR"><small>Einheitliche Währung je Position. Keine automatische Umrechnung.</small></label>
<label>Gebühren<input name="fees" required inputmode="decimal" maxlength="15" value="{{ data.get('fees','0') }}"></label>
<label>Source<select name="source">{% for key,label in sources.items() %}<option value="{{ key }}" {{ 'selected' if data.get('source') == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<label>Strategie-Tag<select name="strategy_tag"><option value="">Ohne Tag</option>{% for key,label in strategies.items() %}<option value="{{ key }}" {{ 'selected' if data.get('strategy_tag') == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<label>Notiz<textarea name="note" maxlength="2000" rows="3">{{ data.get('note') or '' }}</textarea></label>
<p class="muted">Stückzahl und Kurs: bis zu 12 Nachkommastellen; Gebühren: maximal 2. Komma und Punkt werden akzeptiert. Käufe/Verkäufe am gleichen Tag werden nach Erfassungsreihenfolge verrechnet.</p>
<div class="actions"><button>Transaktion speichern</button><a href="/trading">Abbrechen</a></div></form></section>{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends 'base.html' %}{% block title %}Transaktionshistorie · Trading-Buch{% endblock %}{% block content %}
{% include '_trading_nav.html' %}<div class="page-heading"><h1>Alle Transaktionen</h1><a class="button" href="/trading/transactions/new">+ Transaktion eintragen</a></div>
<section class="panel"><form method="get" class="filters">
<label>Jahr<select name="year"><option value="">Alle Jahre</option>{% for year in years %}<option value="{{ year }}" {{ 'selected' if filters.get('year') == year else '' }}>{{ year }}</option>{% endfor %}</select></label>
<label>Monat<select name="month"><option value="">Alle Monate</option>{% for month in months %}<option value="{{ loop.index }}" {{ 'selected' if filters.get('month') == loop.index else '' }}>{{ month }}</option>{% endfor %}</select></label>
<label>Position<select name="asset_id"><option value="">Alle Positionen</option>{% for asset in assets %}<option value="{{ asset.id }}" {{ 'selected' if filters.get('asset_id') == asset.id else '' }}>{{ asset.name }}</option>{% endfor %}</select></label>
{% for field,label,options in [('transaction_type','Typ',trade_types), ('source','Source',sources), ('strategy_tag','Strategie',strategies)] %}<label>{{ label }}<select name="{{ field }}"><option value="">Alle</option>{% for key,title in options.items() %}<option value="{{ key }}" {{ 'selected' if filters.get(field) == key else '' }}>{{ title }}</option>{% endfor %}{% if field == 'strategy_tag' %}<option value="untagged" {{ 'selected' if filters.get(field) == 'untagged' else '' }}>Ohne Tag</option>{% endif %}</select></label>{% endfor %}
<button>Filtern</button><a href="/trading/transactions">Zurücksetzen</a></form>
{% include '_trading_entries.html' %}<div class="pagination">{% if page > 1 %}<a href="{{ request.url.include_query_params(page=page-1) }}">← Zurück</a>{% endif %}<span>Seite {{ page }} · bis zu 100 Transaktionen</span>{% if more %}<a href="{{ request.url.include_query_params(page=page+1) }}">Weiter →</a>{% endif %}</div></section>{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
{% extends 'base.html' %}{% block title %}Positionen · Trading-Buch{% endblock %}
{% block content %}{% include '_trading_nav.html' %}<div class="page-heading"><div><h1>Aktuelle Positionen</h1><p class="muted">Offene Bestände in ihrer jeweiligen Währung · Durchschnittseinstand inklusive Kaufgebühren</p></div><a class="button" href="/trading/transactions/new">+ Transaktion eintragen</a></div><section class="panel">{% include '_trading_positions.html' %}</section>{% endblock %}
+73
View File
@@ -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)
+2 -1
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from trading_models import display_decimal
from fastapi.templating import Jinja2Templates
from models import money, percent, percent_text, CATEGORIES, ASSET_TYPES, MONTHS
templates = Jinja2Templates(directory=str(Path(__file__).parent / 'templates'))
templates.env.filters.update(money=money, percent=percent_text)
templates.env.filters.update(money=money, percent=percent_text, decimal=display_decimal)
templates.env.globals.update(compare=percent, categories=CATEGORIES, asset_types=ASSET_TYPES, months=MONTHS)