diff --git a/README.md b/README.md index 8a36b83..f1e000e 100644 --- a/README.md +++ b/README.md @@ -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` (1–1000, 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. diff --git a/app/api/routes.py b/app/api/routes.py index 77cf22d..ace8d08 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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: diff --git a/app/api/trading.py b/app/api/trading.py new file mode 100644 index 0000000..ca74617 --- /dev/null +++ b/app/api/trading.py @@ -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']) diff --git a/app/database.py b/app/database.py index b12accc..4544e8a 100644 --- a/app/database.py +++ b/app/database.py @@ -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',)) diff --git a/app/main.py b/app/main.py index 1518625..8014d40 100644 --- a/app/main.py +++ b/app/main.py @@ -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') diff --git a/app/routes/trading.py b/app/routes/trading.py new file mode 100644 index 0000000..504218f --- /dev/null +++ b/app/routes/trading.py @@ -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"'}) diff --git a/app/services/trading_service.py b/app/services/trading_service.py new file mode 100644 index 0000000..414288c --- /dev/null +++ b/app/services/trading_service.py @@ -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]) diff --git a/app/static/css/style.css b/app/static/css/style.css index 659300f..1aefec2 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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); } diff --git a/app/static/js/trading.js b/app/static/js/trading.js new file mode 100644 index 0000000..3598e9d --- /dev/null +++ b/app/static/js/trading.js @@ -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.'; +})(); diff --git a/app/templates/_trading_entries.html b/app/templates/_trading_entries.html new file mode 100644 index 0000000..e4d8e46 --- /dev/null +++ b/app/templates/_trading_entries.html @@ -0,0 +1,7 @@ +
{% if show_balance %}{% endif %} +{% for entry in entries %} + + +{% if show_balance %}{% endif %} + +{% else %}{% endfor %}
DatumPositionTypStückzahlKursWährungGebührenGesamtbetragSourceStrategieBestand vorherBestand danachNotizAktionen
{{ entry.date[8:10] }}.{{ entry.date[5:7] }}.{{ entry.date[:4] }}{{ entry.asset }}{{ trade_types[entry.transaction_type] }}{{ entry.quantity|replace('.', ',') }}{{ entry.price_per_unit|replace('.', ',') }}{{ entry.currency }}{{ entry.fees|decimal }}{{ entry.total_amount|decimal }}{{ sources[entry.source] }}{{ strategies.get(entry.strategy_tag, 'Ohne Tag') }}{{ entry.quantity_before|replace('.', ',') }}{{ entry.quantity_after|replace('.', ',') }}{{ entry.note or '–' }}
Bearbeiten
Noch keine Transaktionen. Trage einen Kauf mit bekanntem Datum und Kurs ein.
diff --git a/app/templates/_trading_nav.html b/app/templates/_trading_nav.html new file mode 100644 index 0000000..013764d --- /dev/null +++ b/app/templates/_trading_nav.html @@ -0,0 +1 @@ + diff --git a/app/templates/_trading_positions.html b/app/templates/_trading_positions.html new file mode 100644 index 0000000..8f775b1 --- /dev/null +++ b/app/templates/_trading_positions.html @@ -0,0 +1,3 @@ +
+{% for position in positions %} +{% else %}{% endfor %}
PositionStückzahlØ EinstandInvestiertes KapitalWährungKäufeVerkäufeRealisiert G/V
{{ position.asset }}{{ position.quantity|replace('.', ',') }}{{ position.average_cost|decimal(8) }}{{ position.invested_capital|decimal }}{{ position.currency }}{{ position.buy_count }}{{ position.sell_count }}{{ position.realized_profit_loss|decimal }}
Noch keine offenen Positionen aus erfassten Käufen.
diff --git a/app/templates/base.html b/app/templates/base.html index f1cf6bb..202dbad 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,13 +7,14 @@ -
💶 Finance Dashboard
+
💶 Finance Dashboard
{% set message = request.query_params.get('message') %} {% if message in ['saved', 'deleted'] %}

{{ 'Zahlung gespeichert.' if message == 'saved' else 'Zahlung gelöscht.' }}

{% endif %} +{% if message in ['trade_saved', 'trade_deleted'] %}

{{ 'Transaktion gespeichert.' if message == 'trade_saved' else 'Transaktion gelöscht.' }}

{% endif %} {% block content %}{% endblock %}
- + {% block scripts %}{% endblock %} diff --git a/app/templates/trading.html b/app/templates/trading.html new file mode 100644 index 0000000..5bc4672 --- /dev/null +++ b/app/templates/trading.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} +{% block title %}Trading-Buch · Finance Dashboard{% endblock %} +{% block content %} +{% include '_trading_nav.html' %} +

DEPOT & TRANSAKTIONEN

Trading-Buch

Deine Käufe, Verkäufe und offenen Einstandswerte.

+ Transaktion eintragen
+
Alle Kennzahlen und Diagramme nur in {{ stats.currency }} · keine Währungsumrechnung
+
+

Investiertes Kapital

{{ stats.invested_capital|decimal }} {{ stats.currency }}Einstand der offenen Positionen inkl. Gebühren
+

Aktive Positionen

{{ stats.active_positions }}Mit positivem Bestand
+

Käufe dieses Jahr

{{ stats.buys_current_year }}Anzahl Kauftransaktionen
+

Verkäufe dieses Jahr

{{ stats.sells_current_year }}Anzahl Verkaufstransaktionen
+

Realisiert G/V dieses Jahr

{{ stats.realized_profit_loss_current_year|decimal }} {{ stats.currency }}Nach Gebühren · Durchschnittseinstand
+

Transaktionen gesamt

{{ stats.transactions_total }}Alle Jahre · {{ stats.currency }}
+
+

Portfolioanalyse mit gleitendem Durchschnittseinstand. Keine deutsche steuerliche FIFO-Berechnung.

+

Aktuelle Positionen · {{ stats.currency }}

Alle Währungen →
{% set positions = stats.positions %}{% include '_trading_positions.html' %}
+
+{% for field,title in [('by_source','Investiertes Kapital nach Source'),('by_strategy','Investiertes Kapital nach Strategie')] %} +

{{ title }}

Offener Einstand · Verkäufe reduzieren ursprüngliche Anteile proportional.

{% for row in stats[field] %}{% endfor %}
{{ 'Source' if field == 'by_source' else 'Strategie' }}Betrag ({{ stats.currency }})Anteil
{{ sources[row.key] if field == 'by_source' else strategies.get(row.key, 'Ohne Tag') }}{{ row.amount|decimal }}{{ row.percentage|decimal if row.percentage is not none else '–' }}{{ ' %' if row.percentage is not none else '' }}
+{% endfor %} +

Käufe pro Monat und Jahr

Monatswerte anzeigen
{% for month in months %}{% endfor %}{% for year in stats.monthly %}{% for count in year.counts %}{% endfor %}{% endfor %}
Jahr{{ month }}
{{ year.year }}{{ count }}
+

Diagramme werden geladen. Alle Werte sind auch als Tabellen verfügbar.

+

Letzte Transaktionen · alle Währungen

Vollständige Historie →
{% include '_trading_entries.html' %}
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/trading_asset.html b/app/templates/trading_asset.html new file mode 100644 index 0000000..454ff18 --- /dev/null +++ b/app/templates/trading_asset.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %}{% block title %}{{ asset.name }} · Trading-Buch{% endblock %}{% block content %} +{% include '_trading_nav.html' %}

{{ asset.name }}

{{ asset.ticker or 'Kein Ticker' }} · {{ asset_types[asset.asset_type] }}

+ Transaktion eintragen
+{% if position %}
{% 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)] %}

{{ label }}

{{ value }}{{ position.currency if label != 'Stückzahl' else 'Stück' }}
{% endfor %}

Erste Buchung: {{ position.first_transaction }} · Letzte Buchung: {{ position.last_transaction }} · {{ position.buy_count }} Käufe / {{ position.sell_count }} Verkäufe

{% else %}

Noch keine erfassten Trades. Es wurden keine Anfangsbestände oder Preise angenommen.

{% endif %} +

Vollständige Positionshistorie

{% set show_balance = true %}{% include '_trading_entries.html' %}
{% endblock %} diff --git a/app/templates/trading_asset_form.html b/app/templates/trading_asset_form.html new file mode 100644 index 0000000..d52f0be --- /dev/null +++ b/app/templates/trading_asset_form.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}{% include '_trading_nav.html' %}

Neue Trading-Position

{% if error %}

{{ error }}

{% endif %}
Zurück
{% endblock %} diff --git a/app/templates/trading_error.html b/app/templates/trading_error.html new file mode 100644 index 0000000..39803a5 --- /dev/null +++ b/app/templates/trading_error.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}

Transaktion nicht geändert

Zurück zur Historie
{% endblock %} diff --git a/app/templates/trading_form.html b/app/templates/trading_form.html new file mode 100644 index 0000000..6d38aaa --- /dev/null +++ b/app/templates/trading_form.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %}{% block title %}Transaktion {{ 'bearbeiten' if transaction_id else 'eintragen' }}{% endblock %} +{% block content %}{% include '_trading_nav.html' %}

Transaktion {{ 'bearbeiten' if transaction_id else 'eintragen' }}

{% if error %}{% endif %} +
+ ++ Neue Position + + + + + + + + +

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.

+
Abbrechen
{% endblock %} diff --git a/app/templates/trading_history.html b/app/templates/trading_history.html new file mode 100644 index 0000000..07dd0ba --- /dev/null +++ b/app/templates/trading_history.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %}{% block title %}Transaktionshistorie · Trading-Buch{% endblock %}{% block content %} +{% include '_trading_nav.html' %}

Alle Transaktionen

+ Transaktion eintragen
+
+ + + +{% for field,label,options in [('transaction_type','Typ',trade_types), ('source','Source',sources), ('strategy_tag','Strategie',strategies)] %}{% endfor %} +Zurücksetzen
+{% include '_trading_entries.html' %}
{% endblock %} diff --git a/app/templates/trading_positions.html b/app/templates/trading_positions.html new file mode 100644 index 0000000..1ae5bfa --- /dev/null +++ b/app/templates/trading_positions.html @@ -0,0 +1,2 @@ +{% extends 'base.html' %}{% block title %}Positionen · Trading-Buch{% endblock %} +{% block content %}{% include '_trading_nav.html' %}

Aktuelle Positionen

Offene Bestände in ihrer jeweiligen Währung · Durchschnittseinstand inklusive Kaufgebühren

+ Transaktion eintragen
{% include '_trading_positions.html' %}
{% endblock %} diff --git a/app/trading_models.py b/app/trading_models.py new file mode 100644 index 0000000..3945b63 --- /dev/null +++ b/app/trading_models.py @@ -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) diff --git a/app/views.py b/app/views.py index 101fe24..e330e39 100644 --- a/app/views.py +++ b/app/views.py @@ -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) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index e5114fa..eea1228 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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) diff --git a/tests/test_trading.py b/tests/test_trading.py new file mode 100644 index 0000000..0760b7e --- /dev/null +++ b/tests/test_trading.py @@ -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='') + 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('<script>',self.client.get('/trading/transactions').text) + self.assertNotIn('',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)