166 lines
5.8 KiB
Python
166 lines
5.8 KiB
Python
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'])
|