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
+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'])