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)
This commit is contained in:
counterweight 2025-12-22 18:22:46 +01:00
parent 61e95e56d5
commit 2702b66fd2
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
2 changed files with 176 additions and 0 deletions

View file

@ -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)

174
backend/routes/exchange.py Normal file
View file

@ -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,
)