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
+97 -1
View File
@@ -48,7 +48,7 @@ Standardpfad: **/data/finance.db**, persistent auf dem Host als **./data/finance
`income_entries.amount` enthält **ganze Cent (INTEGER)**, keine Euro-Floats. Python summiert Integer und berechnet Prozentwerte mit Decimal. Formulare akzeptieren `0,04`, `0.04` und `28,00`, ohne Tausendertrennzeichen. Mehr als zwei Nachkommastellen werden abgelehnt. Negative Beträge sind für Korrekturen erlaubt. Nur Chart.js verwendet für die grafische Anzeige JavaScript-Zahlen; das ändert keine Finanzwerte in SQLite.
SQLite nutzt Foreign Keys, WAL, kurze Transaktionen und fünf Sekunden Wartezeit bei Locks. Referenzierte Positionen können nicht physisch gelöscht werden; `active=0` deaktiviert sie für neue Buchungen, vorhandene Historie bleibt erhalten. Bei temporären Datenbankproblemen antwortet die App mit HTTP 503. Schema-Version 2 wird über `PRAGMA user_version` geführt.
SQLite nutzt Foreign Keys, WAL, kurze Transaktionen und fünf Sekunden Wartezeit bei Locks. Referenzierte Positionen können nicht physisch gelöscht werden; `active=0` deaktiviert sie für neue Buchungen, vorhandene Historie bleibt erhalten. Bei temporären Datenbankproblemen antwortet die App mit HTTP 503. Schema-Version 3 wird über `PRAGMA user_version` geführt.
`expected=1, received=0` bezeichnet eine offene/ausgefallene erwartete Zahlung. Der Betrag enthält dann die Erwartung, fließt aber **nicht** in tatsächliche Summen ein. Eine teilweise erhaltene Zahlung wird als erhaltene Buchung plus separate offene Restbuchung erfasst. Es gibt noch keine automatische Prognose oder Fälligkeitsverwaltung.
@@ -255,3 +255,99 @@ Beim ersten Start dieser Version wird automatisch eine einmalige Datenmigration
- Vorhandene Buchungen und ihre Zuordnung bleiben unverändert; sie bleiben in Historie und Auswertungen sichtbar.
Die Migration wird zusammen mit ihrer Ausführungsmarkierung in einer SQLite-Transaktion gespeichert. Spätere Starts überschreiben keine danach vorgenommenen Positionsänderungen. Auf pinguAurora reicht das normale Deployment mit `./deploy.sh`; die Datenbank wird nicht über Git übertragen.
## Trading-Buch und Positionshistorie
Das **Trading-Buch** (`/trading`) erfasst Käufe und Verkäufe getrennt von Dividenden/Zinsen. `/trading/positions` zeigt offene Positionen, `/trading/transactions` die vollständige filterbare Historie und `/trading/assets/{asset_id}` die Historie einer Position einschließlich Bestand **vor und nach** jeder Buchung. Geschlossene Positionen bleiben über ihre Transaktionen erreichbar. Neue Positionen lassen sich direkt aus dem Trading-Formular ergänzen.
Unterstützte Asset-Typen: Aktien (`stock`), ETFs (`etf`), Anleihen (`bond`) und Krypto (`crypto`). Transaktionstypen: `buy` und `sell`. Es gibt keine automatische Kursabfrage, keine Performance auf Basis aktueller Marktpreise und keine steuerliche Gewinnermittlung.
### Genauigkeit und Durchschnittseinstand
Stückzahlen und Kurse werden als kanonische Dezimalstrings in SQLite gespeichert, mit bis zu **12 Vor- und 12 Nachkommastellen**. Gebühren haben höchstens zwei Nachkommastellen. Eingaben akzeptieren Komma oder Punkt, keine Tausendertrennzeichen. JSON-Zahlen/Floats werden für diese Felder abgelehnt; die API erwartet Strings.
Berechnungen verwenden Python Decimal mit 60 Stellen Rechenpräzision:
- `gross_amount = quantity × price_per_unit`, kaufmännisch auf zwei Nachkommastellen gerundet (`ROUND_HALF_UP`).
- Kauf: `total_cost = gross_amount + fees`.
- Verkauf: `net_proceeds = gross_amount - fees`.
- Offenes investiertes Kapital ist der verbleibende Einstand inklusive Kaufgebühren, **nicht** die Summe aller historischen Einzahlungen oder der aktuelle Marktwert.
- Einstand je Stück = offenes investiertes Kapital / aktueller Bestand. Die API gibt den Durchschnitt mit zwölf Nachkommastellen aus, die UI zeigt acht.
- Beim Verkauf wird der anteilige Durchschnittseinstand auf Cent gerundet ausgebucht. Realisierter G/V = Nettoerlös minus ausgebuchter Einstand. Beim vollständigen Verkauf wird der gesamte restliche Einstand ausgebucht, ohne Rundungsrest.
Beispiel: 10 Stück zu 10,00 mit 2,00 Gebühren plus 10 Stück zu 20,00 mit 2,00 Gebühren ergeben 304,00 Einstand und einen Durchschnitt von 15,20 pro Stück. Verkauf von 5 Stück zu 30,00 mit 1,00 Gebühren: Nettoerlös 149,00, ausgebuchter Einstand 76,00, realisierter Gewinn 73,00. Offen bleiben 15 Stück mit 228,00 Einstand.
**Dies ist keine deutsche steuerliche FIFO-Berechnung.** FIFO, Steuerberechnung und steuerliche Verlusttöpfe sind nicht implementiert.
Die Reihenfolge ist deterministisch: Datum aufsteigend, bei gleichem Datum ID aufsteigend (Erfassungsreihenfolge). Jede Änderung wird in einer Schreibtransaktion gegen die gesamte Historie der betroffenen Position(en) geprüft. Überverkäufe werden auch bei Rückdatierung, Änderung des Assets oder Löschen eines früheren Kaufs verhindert. Solche Änderungen werden vollständig zurückgerollt. Quantity muss positiv sein; Kurs und Gebühren dürfen 0, aber nicht negativ sein. Ohne bekannten Kurs keine Buchung speichern; 0 ist nur für tatsächlich kostenlose Erwerbe gedacht.
### Währungen
Jede Position wird in genau einer Währung geführt, auch über zwischenzeitliche Komplettverkäufe hinweg. Für denselben Asset-Datensatz dürfen keine unterschiedlichen Währungen gemischt werden. Es gibt **keine Wechselkursumrechnung**. Die Trading-KPIs und Diagramme beziehen sich auf die gewählte Währung (Standard EUR); Bestandslisten zeigen die Währung pro Position. Der dreistellige Währungscode muss zum dokumentierten Abrechnungskurs passen. Monetäre Beträge werden in dieser Version für alle Codes auf zwei Nachkommastellen geführt.
### Source und Strategie
| Feld | Werte |
| --- | --- |
| `source` | `manual` (Manuell), `savings_plan` (Sparplan), `roundup` (Round-up), `cashback`, `rebalancing`, `other` |
| `strategy_tag` (optional) | `core`, `income`, `conviction`, `dip_buy`, `speculation`, `rebalancing`, `other` |
Beispiele: SpaceX Round-up → `roundup` / `conviction`; normaler SpaceX-Nachkauf → `manual` / `conviction`; FTSE-Sparplan → `savings_plan` / `core`; CSWC-Sparplan → `savings_plan` / `income`.
Die Diagramme zeigen **den noch offenen Einstand nach Source und Strategie der ursprünglichen Käufe**. Ein Verkauf reduziert diese Anteile proportional. Cent-Reste werden deterministisch nach dem größten Nachkomma-Rest verteilt, sodass die Anteile zusammen exakt dem offenen Einstand entsprechen. Tags des Verkaufs verändern nicht die Herkunft des bisherigen Einstands. Ohne Strategie wird `untagged`/„Ohne Tag“ als separate Auswertungsgruppe gezeigt. Das dritte Diagramm zählt echte Käufe pro Monat/Jahr. Alle Diagramme besitzen Tabellen als Alternative ohne CDN-Zugriff.
### Trading-API und Export
Alle Endpunkte verwenden die vorhandene Bearer-Authentifizierung mit `FINANCE_API_TOKEN`, dokumentiert unter `/docs` im Tag **Trading**:
| Methode | Pfad |
| --- | --- |
| GET, POST | `/api/v1/transactions` |
| GET, PATCH, DELETE | `/api/v1/transactions/{id}` |
| GET | `/api/v1/positions` |
| GET | `/api/v1/trading/stats` |
| GET | `/api/v1/trading/by-source` |
| GET | `/api/v1/trading/by-strategy` |
Transaktionsfilter in Web und API: `year`, `month`, `asset_id`, `transaction_type`, `source`, `strategy_tag`. `strategy_tag=untagged` findet Einträge ohne Strategie. API zusätzlich `limit` (11000, Standard 100) und `offset` (Standard 0). Listen kommen neueste zuerst, nach Datum und ID absteigend. `POST` liefert 201, `PATCH` 200 und `DELETE` 204. Überschrittene Bestände oder unzulässige Änderungen liefern 422 mit einer fachlichen Fehlermeldung. Ein API-POST ist immer eine neue Buchung, nicht idempotent.
`/positions` liefert aktuelle Positionen aller Währungen; optional `currency=EUR` und `include_closed=true`. Felder: Asset-ID/-Name, Ticker, Asset-Typ, Währung, Stückzahl, Durchschnittseinstand, offenes Kapital, Summe Käufe inkl. Gebühren, Summe Verkäufe nach Gebühren, realisierter G/V, Kauf-/Verkaufsanzahl und erstes/letztes Datum. `/trading/stats`, `/by-source` und `/by-strategy` unterstützen `currency` (Standard EUR). Anteils-Endpunkte liefern `{currency, items: [{key, amount, percentage}]}`. Prozentwerte sind Strings bzw. `null` bei Gesamteinstand 0. Alle Geld- und Stückzahlwerte der API sind Dezimalstrings.
`PATCH` ändert nur übergebene Felder; nur `strategy_tag` und `note` dürfen explizit `null` sein. Inaktive Assets bleiben für Verkäufe und die Korrektur vorhandener Trades verfügbar; neue Käufe für inaktive Positionen sind gesperrt.
CSV unter **`/export/trading.csv`** enthält Datum, Position, Typ, Stückzahl, Kurs, Währung, Gebühren, Gesamtbetrag, Source, Strategie und Notiz. UTF-8 mit BOM, Semikolon, Dezimalkomma und CRLF; Textfelder sind gegen Excel-Formelinjektion geschützt. Gesamtbetrag bedeutet beim Kauf Gesamtkosten und beim Verkauf Nettoerlös.
### Migration, Backup und Deployment
Schema-Version **3** ergänzt automatisch die Tabelle `transactions`, zwei Indizes und einen Eintrag in `data_migrations`. DDL und Migrationsmarkierung werden atomar ausgeführt. Bestehende Tabellen werden weder gelöscht noch ersetzt. **Income-Einträge werden nicht verändert.** Es werden keine Trades und keine aktuellen Bestände automatisch eingetragen.
Das oben beschriebene konsistente SQLite-Backup sichert jetzt auch Trading-Daten. Ein CSV-Export ersetzt weiterhin kein vollständiges Backup. Das Deployment auf pinguAurora bleibt unverändert:
```bash
git pull
DOCKER_BUILDKIT=0 docker build \
-t finance-dashboard-finance-dashboard:latest \
.
docker compose up -d --no-build
curl --fail http://127.0.0.1:8081/health
```
Alternativ `./deploy.sh`. Niemals `docker compose up -d --build` auf dem Raspberry verwenden, solange der bekannte Buildx-Konflikt besteht. `/data/finance.db` bleibt persistent.
### Referenzbestand nicht importiert
Diese vom Nutzer genannten Stückzahlen sind ausschließlich eine spätere Referenz, **keine Buchungen und kein verifizierter aktueller Depotstand**:
| Position | Stückzahl |
| --- | ---: |
| AGNC | 153,816 |
| FTSE Global All Cap | 244,284 |
| STOXX Global Select Dividend 100 | 18 |
| Main Street Capital | 14,4758 |
| AI ETF | 2,14 |
| SpaceX, nach Round-up | 2,692262 |
| Capital Southwest | 8,28 |
| Ares Capital | 7,33 |
| Enbridge | 0,22 |
SpaceX-Beispiel: 2,600000 vor Round-up + 0,092262 = 2,692262 danach; `buy`, `source=roundup`, `strategy_tag=conviction`. **Kein verlässlicher Kaufkurs liegt vor, deshalb wurde kein Preis und keine Transaktion eingetragen.** Eine technische Anfangsposition (Opening Balance) ist noch nicht implementiert. Sie müsste künftig separat von echten Käufen modelliert werden und darf keine Kaufstatistiken erhöhen. Bis echte historische Käufe mit Datum und Kurs erfasst sind, zeigt das Trading-Buch entsprechend keine daraus abgeleiteten Bestände.
+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)
+2 -2
View File
@@ -31,7 +31,7 @@ class PositionMigrationTests(unittest.TestCase):
initialize(path)
with connect(path) as db:
self.assertEqual(db.execute("SELECT active FROM assets WHERE name='Zinsen NG'").fetchone()[0], 0)
self.assertEqual(db.execute('SELECT COUNT(*) FROM data_migrations').fetchone()[0], 1)
self.assertEqual(db.execute('SELECT COUNT(*) FROM data_migrations WHERE name="2026-09-09-interest-positions"').fetchone()[0], 1)
self.assertEqual(db.execute("SELECT COUNT(*) FROM assets WHERE name='Anleihezinsen'").fetchone()[0], 1)
def test_fresh_database(self):
@@ -41,4 +41,4 @@ class PositionMigrationTests(unittest.TestCase):
with connect(path) as db:
self.assertEqual(db.execute("SELECT asset_type FROM assets WHERE name='Anleihezinsen'").fetchone()[0], 'bond')
self.assertEqual(db.execute("SELECT asset_type FROM assets WHERE name='Zinsen NG'").fetchone()[0], 'interest')
self.assertEqual(db.execute('PRAGMA user_version').fetchone()[0], 2)
self.assertEqual(db.execute('PRAGMA user_version').fetchone()[0], 3)
+209
View File
@@ -0,0 +1,209 @@
import csv
from datetime import date
from decimal import Decimal
import io
import os
from pathlib import Path
import secrets
import sqlite3
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'app'))
from fastapi.testclient import TestClient
from main import app
from database import connect, initialize
from services import trading_service as service
from services.asset_service import create_asset
from trading_models import TradingValidationError
class TradingTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / 'finance.db'
self.token = secrets.token_urlsafe(32)
self.env = patch.dict(os.environ, {'FINANCE_DB_PATH':str(self.path), 'FINANCE_API_TOKEN':self.token})
self.env.start()
self.client = TestClient(app)
self.client.__enter__()
self.asset = create_asset('SpaceX', 'stock')['id']
self.headers = {'Authorization':'Bearer '+self.token}
def tearDown(self):
self.client.__exit__(None,None,None)
self.env.stop()
self.tmp.cleanup()
def trade(self, **changes):
values = dict(date='2026-09-01',asset_id=self.asset,transaction_type='buy',quantity='10',price_per_unit='10',currency='EUR',fees='0',source='manual',strategy_tag=None)
values.update(changes)
return service.save_transaction(values)
def api(self, method, path, **kwargs):
return self.client.request(method,'/api/v1'+path,headers=self.headers,**kwargs)
def test_buy_multiple_average_partial_sale_and_realized(self):
self.trade(fees='2')
self.trade(price_per_unit='20',fees='2',source='savings_plan',strategy_tag='core')
self.trade(transaction_type='sell',quantity='5',price_per_unit='30',fees='1')
position = service.positions()[0]
self.assertEqual(Decimal(position['quantity']),15)
self.assertEqual(position['invested_capital'],'228.00')
self.assertEqual(Decimal(position['average_cost']),Decimal('15.2'))
self.assertEqual(position['realized_profit_loss'],'73.00')
self.assertEqual(position['total_buys'],'304.00')
self.assertEqual(position['total_sells'],'149.00')
self.assertEqual((position['buy_count'],position['sell_count']),(2,1))
stats = service.trading_stats(today=date(2026,9,9))
self.assertEqual(stats['realized_profit_loss_current_year'],'73.00')
self.assertEqual(stats['transactions_total'],3)
source = {r['key']:r['amount'] for r in stats['by_source']}
self.assertEqual(source['manual'],'76.50')
self.assertEqual(source['savings_plan'],'151.50')
for field in ['by_source','by_strategy']:
self.assertEqual(sum(Decimal(r['amount']) for r in stats[field]),Decimal('228'))
def test_roundup_cashback_savings_plan_and_precise_balance(self):
self.trade(quantity='2.600000',price_per_unit='100',strategy_tag='conviction')
roundup = self.trade(quantity='0,092262',price_per_unit='127,24',source='roundup',strategy_tag='conviction')
self.assertEqual(roundup['gross_amount'],'11.74')
detail = service.asset_detail(self.asset)
self.assertEqual(Decimal(detail['position']['quantity']),Decimal('2.692262'))
self.assertEqual(Decimal(detail['entries'][0]['quantity_before']),Decimal('2.600000'))
self.assertEqual(Decimal(detail['entries'][0]['quantity_after']),Decimal('2.692262'))
self.trade(quantity='0.000000000001',price_per_unit='1',source='cashback')
self.trade(quantity='0.1',price_per_unit='100',source='savings_plan',strategy_tag='core')
self.assertEqual(len(service.list_transactions(source='roundup')),1)
self.assertEqual(len(service.list_transactions(source='cashback')),1)
self.assertEqual(len(service.list_transactions(source='savings_plan')),1)
self.assertEqual(len(service.list_transactions(strategy_tag='conviction')),2)
self.assertEqual(Decimal(service.positions()[0]['quantity']),Decimal('2.792262000001'))
def test_oversell_and_backdated_sale(self):
self.trade()
for values in [dict(transaction_type='sell',quantity='10.000000000001'),dict(transaction_type='sell',date='2026-08-01',quantity='1')]:
with self.assertRaises(TradingValidationError):
self.trade(**values)
self.assertEqual(len(service.list_transactions()),1)
def test_edit_delete_validate_full_history_and_rollback(self):
buy = self.trade()
sell = self.trade(transaction_type='sell',quantity='8',date='2026-09-02')
for changes in [{'quantity':'7'}, {'date':'2026-09-03'}, {'transaction_type':'sell'}]:
with self.assertRaises(TradingValidationError):
service.save_transaction(changes,buy['id'])
with self.assertRaises(TradingValidationError):
service.delete_transaction(buy['id'])
self.assertEqual(service.get_transaction(buy['id'])['quantity'],'10')
updated = service.save_transaction({'price_per_unit':'20'},buy['id'])
self.assertEqual(updated['source'],'manual')
self.assertEqual(service.positions()[0]['realized_profit_loss'],'-80.00')
service.delete_transaction(sell['id'])
self.assertEqual(Decimal(service.positions()[0]['quantity']),10)
service.delete_transaction(buy['id'])
self.assertEqual(service.positions(),[])
def test_asset_move_cannot_leave_old_asset_short(self):
second = create_asset('ETF Test','etf')['id']
buy = self.trade()
self.trade(transaction_type='sell',quantity='1')
with self.assertRaises(TradingValidationError):
service.save_transaction({'asset_id':second},buy['id'])
self.assertEqual(service.get_transaction(buy['id'])['asset_id'],self.asset)
def test_full_disposal_resets_cost_and_reopening(self):
self.trade(quantity='3',price_per_unit='0.01',fees='0.01')
self.trade(transaction_type='sell',quantity='1',price_per_unit='0.02')
self.trade(transaction_type='sell',quantity='2',price_per_unit='0.02')
self.assertEqual(service.positions(),[])
position = service.positions(include_closed=True)[0]
self.assertEqual(position['invested_capital'],'0.00')
self.assertEqual(position['realized_profit_loss'],'0.02')
self.trade(quantity='1',price_per_unit='10')
self.assertEqual(Decimal(service.positions()[0]['average_cost']),10)
def test_multi_currency_separation(self):
self.trade()
second = create_asset('USD ETF','etf')['id']
self.trade(asset_id=second,currency='USD',price_per_unit='99')
self.assertEqual(service.trading_stats('EUR')['invested_capital'],'100.00')
self.assertEqual(service.trading_stats('USD')['invested_capital'],'990.00')
with self.assertRaises(TradingValidationError):
self.trade(currency='USD')
self.assertEqual(len(service.positions()),2)
def test_invalid_values(self):
for change in [{'quantity':'0'},{'quantity':'-1'},{'quantity':0.1},{'price_per_unit':'-1'},{'fees':'-1'},{'fees':'0.001'},
{'quantity':'NaN'},{'price_per_unit':'Infinity'},{'quantity':'0.0000000000001'}, {'asset_id':99999},
{'date':'2026-02-30'},{'currency':'EURO'},{'source':'invalid'},{'strategy_tag':'invalid'}]:
with self.subTest(change=change), self.assertRaises(TradingValidationError):
self.trade(**change)
self.assertEqual(service.list_transactions(),[])
def test_api_auth_crud_and_filters(self):
paths = [('GET','/transactions'),('POST','/transactions'),('GET','/transactions/1'),('PATCH','/transactions/1'),('DELETE','/transactions/1'),
('GET','/positions'),('GET','/trading/stats'),('GET','/trading/by-source'),('GET','/trading/by-strategy')]
for method,path in paths:
self.assertEqual(self.client.request(method,'/api/v1'+path).status_code,401)
response = self.api('POST','/transactions',json={'date':'2026-09-01','asset_id':self.asset,'quantity':'0.092262','price_per_unit':'127.24','source':'roundup','strategy_tag':'conviction'})
self.assertEqual(response.status_code,201,response.text)
entry = response.json()
self.assertEqual(entry['quantity'],'0.092262')
self.assertEqual(entry['total_cost'],'11.74')
self.assertEqual(self.api('GET',f"/transactions/{entry['id']}").json(),entry)
response = self.api('PATCH',f"/transactions/{entry['id']}",json={'note':'Test','strategy_tag':None})
self.assertEqual(response.status_code,200,response.text)
self.assertIsNone(response.json()['strategy_tag'])
self.assertEqual(len(self.api('GET','/transactions?source=roundup&strategy_tag=untagged&year=2026&month=9').json()),1)
self.assertEqual(len(self.api('GET','/positions').json()),1)
self.assertEqual(self.api('GET','/trading/stats').json()['invested_capital'],'11.74')
self.assertEqual(self.api('GET','/trading/by-source').json()['currency'],'EUR')
self.assertEqual(self.api('GET','/trading/by-strategy').status_code,200)
self.assertEqual(self.api('POST','/transactions',json={'date':'2026-09-02','asset_id':self.asset,'transaction_type':'sell','quantity':'1','price_per_unit':'1'}).status_code,422)
self.assertEqual(self.api('DELETE',f"/transactions/{entry['id']}").content,b'')
self.assertEqual(self.api('GET',f"/transactions/{entry['id']}").status_code,404)
self.assertEqual(self.api('PATCH','/transactions/999',json={'note':'x'}).status_code,404)
for query in ['source=invalid','month=13','limit=0','strategy_tag=invalid','offset=-1']:
self.assertEqual(self.api('GET','/transactions?'+query).status_code,422)
def test_web_csv_and_income_untouched(self):
with connect() as db:
db.execute("INSERT INTO income_entries(date,asset_id,category,amount) VALUES ('2026-09-01',?,'dividend',4)",(self.asset,))
before = [tuple(r) for r in db.execute('SELECT * FROM income_entries')]
form = dict(date='2026-09-01',asset_id=self.asset,transaction_type='buy',quantity='0,092262',price_per_unit='127,24',currency='EUR',fees='0',source='roundup',strategy_tag='conviction',note='<script>test</script>')
response = self.client.post('/trading/transactions/new',data=form,follow_redirects=False)
self.assertEqual(response.status_code,303,response.text)
for path in ['/','/health','/income','/trading','/trading/positions','/trading/transactions','/trading/transactions/new',f'/trading/assets/{self.asset}','/trading/assets/new']:
response = self.client.get(path)
self.assertEqual(response.status_code,200,(path,response.text))
self.assertIn('&lt;script&gt;',self.client.get('/trading/transactions').text)
self.assertNotIn('<script>test</script>',self.client.get('/trading/transactions').text)
export = self.client.get('/export/trading.csv')
self.assertEqual(export.status_code,200)
self.assertTrue(export.content.startswith(b'\xef\xbb\xbf'))
rows = list(csv.reader(io.StringIO(export.content.decode('utf-8-sig')),delimiter=';'))
self.assertEqual(rows[1][3],'0,092262')
self.assertEqual(rows[1][7],'11,74')
trade = service.list_transactions()[0]
self.assertEqual(self.client.get(f"/trading/transactions/{trade['id']}/edit").status_code,200)
self.assertEqual(self.client.post('/trading/transactions/new',data={**form,'quantity':'0'}).status_code,422)
initialize()
with connect() as db:
self.assertEqual(before,[tuple(r) for r in db.execute('SELECT * FROM income_entries')])
def test_empty_trading_and_schema_migration(self):
self.assertEqual(service.trading_stats()['invested_capital'],'0.00')
self.assertEqual(self.client.get('/trading').status_code,200)
with connect() as db:
before = [tuple(r) for r in db.execute('SELECT * FROM income_entries')]
db.execute('PRAGMA user_version=2')
initialize()
initialize()
with connect() as db:
self.assertEqual(db.execute('PRAGMA user_version').fetchone()[0],3)
self.assertEqual(db.execute('SELECT COUNT(*) FROM transactions').fetchone()[0],0)
self.assertEqual(before,[tuple(r) for r in db.execute('SELECT * FROM income_entries')])
self.assertEqual(db.execute("SELECT COUNT(*) FROM data_migrations WHERE name='2026-09-09-trading-schema'").fetchone()[0],1)