Phase 2.1-2.3: Add exchange endpoints

Add exchange trading endpoints:
- POST /api/exchange: Create exchange trade
  - Validates slot, price staleness, EUR amount limits
  - Calculates sats from EUR and agreed price
  - Direction-specific premium (buy=+5%, sell=-5%)

- GET /api/trades: List user's exchanges
- POST /api/trades/{id}/cancel: Cancel user's exchange

Add schemas:
- ExchangeRequest, ExchangeResponse
- ExchangeUserContact, AdminExchangeResponse (for Phase 2.4)
- PaginatedExchanges, PaginatedAdminExchanges
This commit is contained in:
counterweight 2025-12-22 18:28:56 +01:00
parent d88d69cf9f
commit ce9159c5b0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
3 changed files with 382 additions and 15 deletions

View file

@ -41,7 +41,6 @@ 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)
@ -51,3 +50,5 @@ for r in invites_routes.routers:
app.include_router(r)
for r in booking_routes.routers:
app.include_router(r)
for r in exchange_routes.routers:
app.include_router(r)

View file

@ -1,26 +1,50 @@
"""Exchange routes for Bitcoin trading."""
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import desc, select
from sqlalchemy import and_, desc, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from auth import require_permission
from database import get_db
from models import Permission, PriceHistory, User
from date_validation import validate_date_in_range
from models import (
Availability,
Exchange,
ExchangeStatus,
Permission,
PriceHistory,
TradeDirection,
User,
)
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from schemas import ExchangeRequest, ExchangeResponse
from shared_constants import (
EUR_TRADE_INCREMENT,
EUR_TRADE_MAX,
EUR_TRADE_MIN,
PREMIUM_PERCENTAGE,
PRICE_STALENESS_SECONDS,
SLOT_DURATION_MINUTES,
)
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
# =============================================================================
# Constants for satoshi calculations
# =============================================================================
SATS_PER_BTC = 100_000_000
# =============================================================================
# Pydantic models for price endpoint
# =============================================================================
class ExchangeConfigResponse(BaseModel):
"""Exchange configuration for the frontend."""
@ -49,20 +73,53 @@ class ExchangePriceResponse(BaseModel):
error: str | None = None
def apply_premium(market_price: float, premium_percentage: int) -> float:
# =============================================================================
# Helper functions
# =============================================================================
def apply_premium_for_direction(
market_price: float,
premium_percentage: int,
direction: TradeDirection,
) -> float:
"""
Apply premium to market price.
Apply premium to market price based on trade direction.
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.
- When user BUYS BTC: user pays MORE (market * (1 + premium/100))
- When user SELLS BTC: user receives LESS (market * (1 - premium/100))
"""
return market_price * (1 + premium_percentage / 100)
if direction == TradeDirection.BUY:
return market_price * (1 + premium_percentage / 100)
else: # SELL
return market_price * (1 - premium_percentage / 100)
def apply_premium(market_price: float, premium_percentage: int) -> float:
"""Apply buy-side premium (for price display)."""
return apply_premium_for_direction(
market_price, premium_percentage, TradeDirection.BUY
)
def calculate_sats_amount(
eur_cents: int,
price_eur_per_btc: float,
) -> int:
"""
Calculate satoshi amount from EUR cents and price.
Args:
eur_cents: Amount in EUR cents (e.g., 10000 = 100)
price_eur_per_btc: Price in EUR per BTC
Returns:
Amount in satoshis
"""
eur_amount = eur_cents / 100
btc_amount = eur_amount / price_eur_per_btc
return int(btc_amount * SATS_PER_BTC)
async def get_latest_price(db: AsyncSession) -> PriceHistory | None:
@ -85,6 +142,36 @@ def is_price_stale(price_timestamp: datetime) -> bool:
return age_seconds > PRICE_STALENESS_SECONDS
def _to_exchange_response(
exchange: Exchange,
user_email: str | None = None,
) -> ExchangeResponse:
"""Convert an Exchange model to ExchangeResponse schema."""
email = user_email if user_email is not None else exchange.user.email
return ExchangeResponse(
id=exchange.id,
user_id=exchange.user_id,
user_email=email,
slot_start=exchange.slot_start,
slot_end=exchange.slot_end,
direction=exchange.direction.value,
eur_amount=exchange.eur_amount,
sats_amount=exchange.sats_amount,
market_price_eur=exchange.market_price_eur,
agreed_price_eur=exchange.agreed_price_eur,
premium_percentage=exchange.premium_percentage,
status=exchange.status.value,
created_at=exchange.created_at,
cancelled_at=exchange.cancelled_at,
completed_at=exchange.completed_at,
)
# =============================================================================
# Price Endpoint
# =============================================================================
@router.get("/price", response_model=ExchangePriceResponse)
async def get_exchange_price(
db: AsyncSession = Depends(get_db),
@ -172,3 +259,214 @@ async def get_exchange_price(
),
config=config,
)
# =============================================================================
# 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.BOOK_APPOINTMENT)),
) -> 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
"""
slot_date = request.slot_start.date()
validate_date_in_range(slot_date, context="book")
# Validate direction
try:
direction = TradeDirection(request.direction)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'.",
) from None
# Validate EUR amount
if request.eur_amount < EUR_TRADE_MIN * 100:
raise HTTPException(
status_code=400,
detail=f"EUR amount must be at least €{EUR_TRADE_MIN}",
)
if request.eur_amount > EUR_TRADE_MAX * 100:
raise HTTPException(
status_code=400,
detail=f"EUR amount must be at most €{EUR_TRADE_MAX}",
)
if request.eur_amount % (EUR_TRADE_INCREMENT * 100) != 0:
raise HTTPException(
status_code=400,
detail=f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}",
)
# Validate slot timing
valid_minutes = (0, 15, 30, 45)
if request.slot_start.minute not in valid_minutes:
raise HTTPException(
status_code=400,
detail=f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary",
)
if request.slot_start.second != 0 or request.slot_start.microsecond != 0:
raise HTTPException(
status_code=400,
detail="Slot start time must not have seconds or microseconds",
)
# Verify slot falls within availability
slot_start_time = request.slot_start.time()
slot_end_dt = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
slot_end_time = slot_end_dt.time()
result = await db.execute(
select(Availability).where(
and_(
Availability.date == slot_date,
Availability.start_time <= slot_start_time,
Availability.end_time >= slot_end_time,
)
)
)
matching_availability = result.scalar_one_or_none()
if not matching_availability:
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=400,
detail=f"Selected slot at {slot_str} UTC is not available",
)
# Get and validate price
cached_price = await get_latest_price(db)
if cached_price is None:
raise HTTPException(
status_code=503,
detail="Price data unavailable. Please try again later.",
)
if is_price_stale(cached_price.timestamp):
raise HTTPException(
status_code=503,
detail="Price is stale. Please refresh and try again.",
)
# Calculate agreed price based on direction
market_price = cached_price.price
agreed_price = apply_premium_for_direction(
market_price, PREMIUM_PERCENTAGE, direction
)
# Calculate sats amount based on agreed price
sats_amount = calculate_sats_amount(request.eur_amount, agreed_price)
# Create the exchange
exchange = Exchange(
user_id=current_user.id,
slot_start=request.slot_start,
slot_end=slot_end_dt,
direction=direction,
eur_amount=request.eur_amount,
sats_amount=sats_amount,
market_price_eur=market_price,
agreed_price_eur=agreed_price,
premium_percentage=PREMIUM_PERCENTAGE,
status=ExchangeStatus.BOOKED,
)
db.add(exchange)
try:
await db.commit()
await db.refresh(exchange)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=409,
detail="This slot has already been booked. Select another slot.",
) from None
return _to_exchange_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_APPOINTMENTS)),
) -> 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 [_to_exchange_response(ex, current_user.email) for ex in exchanges]
@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse)
async def cancel_my_trade(
exchange_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)),
) -> ExchangeResponse:
"""Cancel one of the current user's exchanges."""
# Get the exchange with eager loading of user relationship
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
)
# Verify ownership
if exchange.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Cannot cancel another user's trade",
)
# Check if already in a final state
if exchange.status != ExchangeStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel: status is '{exchange.status.value}'",
)
# Cancel the exchange (no time restriction per spec)
exchange.status = ExchangeStatus.CANCELLED_BY_USER
exchange.cancelled_at = datetime.now(UTC)
await db.commit()
await db.refresh(exchange)
return _to_exchange_response(exchange, current_user.email)
# All routers from this module for easy registration
routers = [router, trades_router]

View file

@ -234,6 +234,74 @@ class PriceHistoryResponse(BaseModel):
created_at: datetime
# =============================================================================
# Exchange Schemas
# =============================================================================
class ExchangeRequest(BaseModel):
"""Request to create an exchange trade."""
slot_start: datetime
direction: str # "buy" or "sell"
eur_amount: int # EUR cents (e.g., 10000 = €100)
class ExchangeResponse(BaseModel):
"""Response model for an exchange trade."""
id: int
user_id: int
user_email: str
slot_start: datetime
slot_end: datetime
direction: str
eur_amount: int # EUR cents
sats_amount: int # Satoshis
market_price_eur: float
agreed_price_eur: float
premium_percentage: int
status: str
created_at: datetime
cancelled_at: datetime | None
completed_at: datetime | None
class ExchangeUserContact(BaseModel):
"""User contact info for admin view."""
email: str
contact_email: str | None
telegram: str | None
signal: str | None
nostr_npub: str | None
class AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact)."""
id: int
user_id: int
user_email: str
user_contact: ExchangeUserContact
slot_start: datetime
slot_end: datetime
direction: str
eur_amount: int
sats_amount: int
market_price_eur: float
agreed_price_eur: float
premium_percentage: int
status: str
created_at: datetime
cancelled_at: datetime | None
completed_at: datetime | None
PaginatedExchanges = PaginatedResponse[ExchangeResponse]
PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse]
# =============================================================================
# Meta/Constants Schemas
# =============================================================================