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:
parent
61e95e56d5
commit
2702b66fd2
2 changed files with 176 additions and 0 deletions
|
|
@ -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
174
backend/routes/exchange.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue