From 2702b66fd23398814b019bfc3384bd146b7ede7d Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 18:22:46 +0100 Subject: [PATCH] Phase 1.3: Create price endpoint for users Add GET /api/exchange/price endpoint: - Available to regular users (BOOK_APPOINTMENT permission) - Returns current BTC/EUR price with admin premium applied - Uses cached price from PriceHistory if not stale - Fetches fresh price from Bitfinex if needed - Returns is_stale flag when price is older than 5 minutes - Includes exchange configuration (min/max EUR, increment) - Handles fetch failures gracefully (returns stale price with error) --- backend/main.py | 2 + backend/routes/exchange.py | 174 +++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 backend/routes/exchange.py diff --git a/backend/main.py b/backend/main.py index 2b0a13f..ad871c5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ from routes import audit as audit_routes from routes import auth as auth_routes from routes import availability as availability_routes from routes import booking as booking_routes +from routes import exchange as exchange_routes from routes import invites as invites_routes from routes import meta as meta_routes from routes import profile as profile_routes @@ -40,6 +41,7 @@ app.add_middleware( # Include routers - modules with single router app.include_router(auth_routes.router) app.include_router(audit_routes.router) +app.include_router(exchange_routes.router) app.include_router(profile_routes.router) app.include_router(availability_routes.router) app.include_router(meta_routes.router) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py new file mode 100644 index 0000000..8e4775f --- /dev/null +++ b/backend/routes/exchange.py @@ -0,0 +1,174 @@ +"""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, + )