499 lines
17 KiB
Python
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]
|