"""Exchange routes for Bitcoin trading.""" from datetime import UTC, datetime from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission from database import get_db from models import Permission, PriceHistory, User from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price from shared_constants import ( EUR_TRADE_INCREMENT, EUR_TRADE_MAX, EUR_TRADE_MIN, PREMIUM_PERCENTAGE, PRICE_STALENESS_SECONDS, ) router = APIRouter(prefix="/api/exchange", tags=["exchange"]) class ExchangeConfigResponse(BaseModel): """Exchange configuration for the frontend.""" eur_min: int eur_max: int eur_increment: int premium_percentage: int class PriceResponse(BaseModel): """Current BTC/EUR price with premium applied.""" market_price: float # Raw price from exchange agreed_price: float # Price with premium applied premium_percentage: int timestamp: datetime is_stale: bool class ExchangePriceResponse(BaseModel): """Combined price and configuration response.""" price: PriceResponse | None # None if price fetch failed config: ExchangeConfigResponse error: str | None = None def apply_premium(market_price: float, premium_percentage: int) -> float: """ Apply premium to market price. The premium is always favorable to the admin: - When user BUYS BTC (admin sells): user pays MORE (add premium) - When user SELLS BTC (admin buys): user receives LESS (subtract premium) For simplicity, we return the "buy" price here (with premium added). The sell price would be market_price * (1 - premium/100). The frontend/booking logic handles direction-specific pricing. """ return market_price * (1 + premium_percentage / 100) async def get_latest_price(db: AsyncSession) -> PriceHistory | None: """Get the most recent price from the database.""" query = ( select(PriceHistory) .where( PriceHistory.source == SOURCE_BITFINEX, PriceHistory.pair == PAIR_BTC_EUR ) .order_by(desc(PriceHistory.timestamp)) .limit(1) ) result = await db.execute(query) return result.scalar_one_or_none() def is_price_stale(price_timestamp: datetime) -> bool: """Check if a price is older than the staleness threshold.""" age_seconds = (datetime.now(UTC) - price_timestamp).total_seconds() return age_seconds > PRICE_STALENESS_SECONDS @router.get("/price", response_model=ExchangePriceResponse) async def get_exchange_price( db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), ) -> ExchangePriceResponse: """ Get the current BTC/EUR price for trading. Returns the latest price from the database. If no price exists or the price is stale, attempts to fetch a fresh price from Bitfinex. The response includes: - market_price: The raw price from the exchange - agreed_price: The price with admin premium applied - is_stale: Whether the price is older than 5 minutes - config: Trading configuration (min/max EUR, increment) """ config = ExchangeConfigResponse( eur_min=EUR_TRADE_MIN, eur_max=EUR_TRADE_MAX, eur_increment=EUR_TRADE_INCREMENT, premium_percentage=PREMIUM_PERCENTAGE, ) # Try to get the latest cached price cached_price = await get_latest_price(db) # If no cached price or it's stale, try to fetch a new one if cached_price is None or is_price_stale(cached_price.timestamp): try: price_value, timestamp = await fetch_btc_eur_price() # Store the new price new_price = PriceHistory( source=SOURCE_BITFINEX, pair=PAIR_BTC_EUR, price=price_value, timestamp=timestamp, ) db.add(new_price) await db.commit() await db.refresh(new_price) return ExchangePriceResponse( price=PriceResponse( market_price=price_value, agreed_price=apply_premium(price_value, PREMIUM_PERCENTAGE), premium_percentage=PREMIUM_PERCENTAGE, timestamp=timestamp, is_stale=False, ), config=config, ) except Exception as e: # If fetch fails and we have a cached price, return it with stale flag if cached_price is not None: return ExchangePriceResponse( price=PriceResponse( market_price=cached_price.price, agreed_price=apply_premium( cached_price.price, PREMIUM_PERCENTAGE ), premium_percentage=PREMIUM_PERCENTAGE, timestamp=cached_price.timestamp, is_stale=True, ), config=config, error=f"Failed to fetch fresh price: {e}", ) # No cached price and fetch failed return ExchangePriceResponse( price=None, config=config, error=f"Price unavailable: {e}", ) # Return the cached price (not stale) return ExchangePriceResponse( price=PriceResponse( market_price=cached_price.price, agreed_price=apply_premium(cached_price.price, PREMIUM_PERCENTAGE), premium_percentage=PREMIUM_PERCENTAGE, timestamp=cached_price.timestamp, is_stale=is_price_stale(cached_price.timestamp), ), config=config, )