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:
parent
d88d69cf9f
commit
ce9159c5b0
3 changed files with 382 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue