Add trading journal and portfolio tracking
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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',))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"'})
|
||||
@@ -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])
|
||||
@@ -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); }
|
||||
|
||||
@@ -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.';
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user