arbret/backend/routes/exchange.py
2025-12-25 00:59:57 +01:00

499 lines
17 KiB
Python

"""Exchange routes for Bitcoin trading."""
import uuid
from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from auth import require_permission
from database import get_db
from date_validation import validate_date_in_range
from exceptions import BadRequestError
from mappers import ExchangeMapper
from models import (
Availability,
BitcoinTransferMethod,
Exchange,
ExchangeStatus,
Permission,
PriceHistory,
TradeDirection,
User,
)
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from repositories.price import PriceRepository
from schemas import (
AdminExchangeResponse,
AvailableSlotsResponse,
BookableSlot,
ExchangeConfigResponse,
ExchangePriceResponse,
ExchangeRequest,
ExchangeResponse,
PriceResponse,
UserSearchResult,
)
from services.exchange import ExchangeService
from shared_constants import (
EUR_TRADE_INCREMENT,
EUR_TRADE_MAX,
EUR_TRADE_MIN,
PREMIUM_PERCENTAGE,
SLOT_DURATION_MINUTES,
)
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
# =============================================================================
# Helper functions
# =============================================================================
# =============================================================================
# Price Endpoint
# =============================================================================
@router.get("/price", response_model=ExchangePriceResponse)
async def get_exchange_price(
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)),
) -> 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
- premium_percentage: The premium to apply to trades
- 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,
)
price_repo = PriceRepository(db)
service = ExchangeService(db)
# Try to get the latest cached price
cached_price = await price_repo.get_latest()
# If no cached price or it's stale, try to fetch a new one
if cached_price is None or service.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,
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,
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,
premium_percentage=PREMIUM_PERCENTAGE,
timestamp=cached_price.timestamp,
is_stale=service.is_price_stale(cached_price.timestamp),
),
config=config,
)
# =============================================================================
# Available Slots Endpoint
# =============================================================================
def _expand_availability_to_slots(
avail: Availability, slot_date: date, booked_starts: set[datetime]
) -> list[BookableSlot]:
"""
Expand an availability block into individual slots, filtering out booked ones.
"""
slots: list[BookableSlot] = []
# Start from the availability's start time
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
# Only include if not already booked
if current_start not in booked_starts:
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
current_start = slot_end
return slots
@router.get("/slots", response_model=AvailableSlotsResponse)
async def get_available_slots(
date_param: date = Query(..., alias="date"),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)),
) -> AvailableSlotsResponse:
"""
Get available booking slots for a specific date.
Returns all slots that:
- Fall within admin-defined availability windows
- Are not already booked by another user
"""
validate_date_in_range(date_param, context="book")
# Get availability for the date
result = await db.execute(
select(Availability).where(Availability.date == date_param)
)
availabilities = result.scalars().all()
if not availabilities:
return AvailableSlotsResponse(date=date_param, slots=[])
# Get already booked slots for the date
date_start = datetime.combine(date_param, time.min, tzinfo=UTC)
date_end = datetime.combine(date_param, time.max, tzinfo=UTC)
result = await db.execute(
select(Exchange.slot_start).where(
and_(
Exchange.slot_start >= date_start,
Exchange.slot_start <= date_end,
Exchange.status == ExchangeStatus.BOOKED,
)
)
)
booked_starts = {row[0] for row in result.all()}
# Expand each availability into slots
all_slots: list[BookableSlot] = []
for avail in availabilities:
slots = _expand_availability_to_slots(avail, date_param, booked_starts)
all_slots.extend(slots)
# Sort by start time
all_slots.sort(key=lambda s: s.start_time)
return AvailableSlotsResponse(date=date_param, slots=all_slots)
# =============================================================================
# Create Exchange Endpoint
# =============================================================================
@router.post("", response_model=ExchangeResponse)
async def create_exchange(
request: ExchangeRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)),
) -> ExchangeResponse:
"""
Create a new exchange trade booking.
Validates:
- Slot is on a valid date and time boundary
- Slot is within admin availability
- Slot is not already booked
- Price is not stale
- EUR amount is within configured limits
"""
# Validate direction
try:
direction = TradeDirection(request.direction)
except ValueError:
raise BadRequestError(
f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'."
) from None
# Validate bitcoin transfer method
try:
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
except ValueError:
raise BadRequestError(
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
"Must be 'onchain' or 'lightning'."
) from None
# Use service to create exchange (handles all validation)
service = ExchangeService(db)
exchange = await service.create_exchange(
user=current_user,
slot_start=request.slot_start,
direction=direction,
bitcoin_transfer_method=bitcoin_transfer_method,
eur_amount=request.eur_amount,
)
return ExchangeMapper.to_response(exchange, current_user.email)
# =============================================================================
# User's Exchanges Endpoints
# =============================================================================
trades_router = APIRouter(prefix="/api/trades", tags=["trades"])
@trades_router.get("", response_model=list[ExchangeResponse])
async def get_my_trades(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> list[ExchangeResponse]:
"""Get the current user's exchanges, sorted by date (newest first)."""
result = await db.execute(
select(Exchange)
.where(Exchange.user_id == current_user.id)
.order_by(Exchange.slot_start.desc())
)
exchanges = result.scalars().all()
return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges]
@trades_router.get("/{public_id}", response_model=ExchangeResponse)
async def get_my_trade(
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> ExchangeResponse:
"""Get a specific trade by public ID. User can only access their own trades."""
service = ExchangeService(db)
exchange = await service.get_exchange_by_public_id(public_id, user=current_user)
return ExchangeMapper.to_response(exchange, current_user.email)
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
async def cancel_my_trade(
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
) -> ExchangeResponse:
"""Cancel one of the current user's exchanges."""
service = ExchangeService(db)
# Get exchange without user filter first to check ownership separately
exchange = await service.get_exchange_by_public_id(public_id)
# Check ownership - return 403 if user doesn't own it
if exchange.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot cancel another user's trade",
)
exchange = await service.cancel_exchange(exchange, current_user, is_admin=False)
return ExchangeMapper.to_response(exchange, current_user.email)
# =============================================================================
# Admin Exchanges Endpoints
# =============================================================================
admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"])
@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse])
async def get_upcoming_trades(
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
) -> list[AdminExchangeResponse]:
"""Get all upcoming booked trades, sorted by slot time ascending."""
now = datetime.now(UTC)
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(
and_(
Exchange.slot_start > now,
Exchange.status == ExchangeStatus.BOOKED,
)
)
.order_by(Exchange.slot_start.asc())
)
exchanges = result.scalars().all()
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse])
async def get_past_trades(
status: str | None = None,
start_date: date | None = None,
end_date: date | None = None,
user_search: str | None = None,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
) -> list[AdminExchangeResponse]:
"""
Get past trades with optional filters.
Filters:
- status: Filter by exchange status
- start_date, end_date: Filter by slot_start date range
- user_search: Search by user email (partial match)
"""
now = datetime.now(UTC)
# Start with base query for past trades (slot_start <= now OR not booked)
query = (
select(Exchange)
.options(joinedload(Exchange.user))
.where(
(Exchange.slot_start <= now) | (Exchange.status != ExchangeStatus.BOOKED)
)
)
# Apply status filter
if status:
try:
status_enum = ExchangeStatus(status)
query = query.where(Exchange.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}",
) from None
# Apply date range filter
if start_date:
start_dt = datetime.combine(start_date, time.min, tzinfo=UTC)
query = query.where(Exchange.slot_start >= start_dt)
if end_date:
end_dt = datetime.combine(end_date, time.max, tzinfo=UTC)
query = query.where(Exchange.slot_start <= end_dt)
# Apply user search filter (join with User table)
if user_search:
query = query.join(Exchange.user).where(User.email.ilike(f"%{user_search}%"))
# Order by most recent first
query = query.order_by(Exchange.slot_start.desc())
result = await db.execute(query)
exchanges = result.scalars().all()
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
async def complete_trade(
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse:
"""Mark a trade as completed. Only possible after slot time has passed."""
service = ExchangeService(db)
exchange = await service.get_exchange_by_public_id(public_id)
exchange = await service.complete_exchange(exchange)
return ExchangeMapper.to_admin_response(exchange)
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
async def mark_no_show(
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse:
"""Mark a trade as no-show. Only possible after slot time has passed."""
service = ExchangeService(db)
exchange = await service.get_exchange_by_public_id(public_id)
exchange = await service.mark_no_show(exchange)
return ExchangeMapper.to_admin_response(exchange)
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
async def admin_cancel_trade(
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
) -> AdminExchangeResponse:
"""Cancel any trade (admin only)."""
service = ExchangeService(db)
exchange = await service.get_exchange_by_public_id(public_id)
exchange = await service.cancel_exchange(exchange, _current_user, is_admin=True)
return ExchangeMapper.to_admin_response(exchange)
# =============================================================================
# Admin User Search Endpoint
# =============================================================================
admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
@admin_users_router.get("/search", response_model=list[UserSearchResult])
async def search_users(
q: str = Query(..., min_length=1, description="Search query for user email"),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
) -> list[UserSearchResult]:
"""
Search users by email for autocomplete.
Returns users whose email contains the search query (case-insensitive).
Limited to 10 results for autocomplete purposes.
"""
result = await db.execute(
select(User).where(User.email.ilike(f"%{q}%")).order_by(User.email).limit(10)
)
users = result.scalars().all()
return [UserSearchResult(id=u.id, email=u.email) for u in users]
# All routers from this module for easy registration
routers = [router, trades_router, admin_trades_router, admin_users_router]