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)
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
"""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,
|
|
)
|