From d317939ad0ad0197695327427342513084b3849f Mon Sep 17 00:00:00 2001 From: counterweight Date: Fri, 26 Dec 2025 20:20:23 +0100 Subject: [PATCH] Step 5: Update exchange price endpoint to use new pricing config - Update ExchangeConfigResponse schema with direction-specific fields - Remove premium_percentage from PriceResponse (now in config) - Update price endpoint to load pricing config from database - Update frontend to use direction-specific min/max and calculate premium - Update tests to seed pricing config - Add logic to clamp amount when direction changes --- backend/routes/exchange.py | 42 ++++++---- backend/schemas/price.py | 16 ++-- backend/tests/test_exchange.py | 82 ++++++++++++++++--- .../app/exchange/components/PriceDisplay.tsx | 3 +- frontend/app/exchange/page.tsx | 46 +++++++++-- 5 files changed, 145 insertions(+), 44 deletions(-) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 95e20ea..fa6ac85 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -20,6 +20,7 @@ from models import ( from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price from repositories.exchange import ExchangeRepository from repositories.price import PriceRepository +from repositories.pricing import PricingRepository from schemas import ( AdminExchangeResponse, AvailableSlotsResponse, @@ -31,12 +32,7 @@ from schemas import ( UserSearchResult, ) from services.exchange import ExchangeService -from shared_constants import ( - EUR_TRADE_INCREMENT, - EUR_TRADE_MAX, - EUR_TRADE_MIN, - PREMIUM_PERCENTAGE, -) +from shared_constants import EUR_TRADE_INCREMENT from utils.enum_validation import validate_enum router = APIRouter(prefix="/api/exchange", tags=["exchange"]) @@ -65,20 +61,33 @@ async def get_exchange_price( 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: Trading configuration (min/max EUR per direction, premiums, 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) + pricing_repo = PricingRepository(db) service = ExchangeService(db) + # Load pricing config from database + pricing_config = await pricing_repo.get_current() + if pricing_config is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Pricing configuration not available", + ) + + config = ExchangeConfigResponse( + eur_min_buy=pricing_config.eur_min_buy, + eur_max_buy=pricing_config.eur_max_buy, + eur_min_sell=pricing_config.eur_min_sell, + eur_max_sell=pricing_config.eur_max_sell, + eur_increment=EUR_TRADE_INCREMENT, + premium_buy=pricing_config.premium_buy, + premium_sell=pricing_config.premium_sell, + small_trade_threshold_eur=pricing_config.small_trade_threshold_eur, + small_trade_extra_premium=pricing_config.small_trade_extra_premium, + ) + # Try to get the latest cached price cached_price = await price_repo.get_latest() @@ -101,7 +110,6 @@ async def get_exchange_price( return ExchangePriceResponse( price=PriceResponse( market_price=price_value, - premium_percentage=PREMIUM_PERCENTAGE, timestamp=timestamp, is_stale=False, ), @@ -113,7 +121,6 @@ async def get_exchange_price( return ExchangePriceResponse( price=PriceResponse( market_price=cached_price.price, - premium_percentage=PREMIUM_PERCENTAGE, timestamp=cached_price.timestamp, is_stale=True, ), @@ -131,7 +138,6 @@ async def get_exchange_price( 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), ), diff --git a/backend/schemas/price.py b/backend/schemas/price.py index e108a6c..3511a89 100644 --- a/backend/schemas/price.py +++ b/backend/schemas/price.py @@ -17,21 +17,27 @@ class PriceHistoryResponse(BaseModel): class ExchangeConfigResponse(BaseModel): """Exchange configuration for the frontend.""" - eur_min: int - eur_max: int + eur_min_buy: int + eur_max_buy: int + eur_min_sell: int + eur_max_sell: int eur_increment: int - premium_percentage: int + premium_buy: int + premium_sell: int + small_trade_threshold_eur: int + small_trade_extra_premium: int class PriceResponse(BaseModel): """Current BTC/EUR price for trading. Note: The actual agreed price depends on trade direction (buy/sell) - and is calculated by the frontend using market_price and premium_percentage. + and is calculated by the frontend using market_price and premium values. + Premium calculation: base premium for direction + extra premium if + trade <= threshold. """ market_price: float # Raw price from exchange - premium_percentage: int timestamp: datetime is_stale: bool diff --git a/backend/tests/test_exchange.py b/backend/tests/test_exchange.py index e3fc9b7..e799b35 100644 --- a/backend/tests/test_exchange.py +++ b/backend/tests/test_exchange.py @@ -57,7 +57,7 @@ def mock_price_fetcher(price: float = 20000.0): async def setup_availability_and_price(client_factory, admin_user, target_date=None): - """Helper to set up availability and price for tests.""" + """Helper to set up availability, price, and pricing config for tests.""" if target_date is None: target_date = tomorrow() @@ -71,9 +71,22 @@ async def setup_availability_and_price(client_factory, admin_user, target_date=N }, ) - # Create fresh price in DB + # Create fresh price in DB and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0, minutes_ago=0) + from repositories.pricing import PricingRepository + + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) return target_date @@ -89,9 +102,22 @@ class TestExchangePriceEndpoint: @pytest.mark.asyncio async def test_regular_user_can_get_price(self, client_factory, regular_user): """Regular user can access price endpoint.""" - # Create fresh price in DB + # Create fresh price in DB and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0, minutes_ago=0) + from repositories.pricing import PricingRepository + + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) # Mock the price fetcher to prevent real API calls with mock_price_fetcher(20000.0): @@ -103,8 +129,7 @@ class TestExchangePriceEndpoint: assert "price" in data assert "config" in data assert data["price"]["market_price"] == 20000.0 - assert data["price"]["premium_percentage"] == 5 - # Note: agreed_price is calculated on frontend based on direction (buy/sell) + # Note: premium is now in config, agreed_price is calculated on frontend @pytest.mark.asyncio async def test_admin_cannot_get_price(self, client_factory, admin_user): @@ -123,9 +148,22 @@ class TestExchangePriceEndpoint: @pytest.mark.asyncio async def test_stale_price_triggers_fetch(self, client_factory, regular_user): """When cached price is stale, a fresh price is fetched.""" - # Create stale price (6 minutes old, threshold is 5 minutes) + # Create stale price (6 minutes old, threshold is 5 minutes) and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=19000.0, minutes_ago=6) + from repositories.pricing import PricingRepository + + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) # Mock fetcher returns new price with mock_price_fetcher(21000.0): @@ -142,6 +180,19 @@ class TestExchangePriceEndpoint: """Config section contains all required fields.""" async with client_factory.get_db_session() as db: await create_price_in_db(db) + from repositories.pricing import PricingRepository + + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=6, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=12000, + eur_max_sell=320000, + ) with mock_price_fetcher(): async with client_factory.create(cookies=regular_user["cookies"]) as client: @@ -149,13 +200,22 @@ class TestExchangePriceEndpoint: assert response.status_code == 200 config = response.json()["config"] - assert "eur_min" in config - assert "eur_max" in config + assert "eur_min_buy" in config + assert "eur_max_buy" in config + assert "eur_min_sell" in config + assert "eur_max_sell" in config assert "eur_increment" in config - assert "premium_percentage" in config - assert config["eur_min"] == 100 - assert config["eur_max"] == 3000 + assert "premium_buy" in config + assert "premium_sell" in config + assert "small_trade_threshold_eur" in config + assert "small_trade_extra_premium" in config + assert config["eur_min_buy"] == 10000 + assert config["eur_max_buy"] == 300000 + assert config["eur_min_sell"] == 12000 + assert config["eur_max_sell"] == 320000 assert config["eur_increment"] == 20 + assert config["premium_buy"] == 5 + assert config["premium_sell"] == 6 # ============================================================================= diff --git a/frontend/app/exchange/components/PriceDisplay.tsx b/frontend/app/exchange/components/PriceDisplay.tsx index 0c3a299..9fa3e11 100644 --- a/frontend/app/exchange/components/PriceDisplay.tsx +++ b/frontend/app/exchange/components/PriceDisplay.tsx @@ -13,6 +13,7 @@ interface PriceDisplayProps { lastUpdate: Date | null; direction: "buy" | "sell"; agreedPrice: number; + premiumPercent: number; } /** @@ -94,10 +95,10 @@ export function PriceDisplay({ lastUpdate, direction, agreedPrice, + premiumPercent, }: PriceDisplayProps) { const t = useTranslation("exchange"); const marketPrice = priceData?.price?.market_price ?? 0; - const premiumPercent = priceData?.price?.premium_percentage ?? 5; const isPriceStale = priceData?.price?.is_stale ?? false; return ( diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx index 00f8c72..b3df952 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -116,24 +116,42 @@ export default function ExchangePage() { // Config from API const config = priceData?.config; - const eurMin = config?.eur_min ?? 100; - const eurMax = config?.eur_max ?? 3000; + const eurMinBuy = config?.eur_min_buy ?? 10000; + const eurMaxBuy = config?.eur_max_buy ?? 300000; + const eurMinSell = config?.eur_min_sell ?? 10000; + const eurMaxSell = config?.eur_max_sell ?? 300000; const eurIncrement = config?.eur_increment ?? 20; + // Get direction-specific min/max + const eurMin = direction === "buy" ? eurMinBuy : eurMinSell; + const eurMax = direction === "buy" ? eurMaxBuy : eurMaxSell; + // Compute trade details const price = priceData?.price; const marketPrice = price?.market_price ?? 0; - const premiumPercent = price?.premium_percentage ?? 5; + const premiumBuy = config?.premium_buy ?? 5; + const premiumSell = config?.premium_sell ?? 5; + const smallTradeThreshold = config?.small_trade_threshold_eur ?? 0; + const smallTradeExtraPremium = config?.small_trade_extra_premium ?? 0; - // Calculate agreed price based on direction + // Calculate total premium: base premium for direction + extra if small trade + const totalPremiumPercent = useMemo(() => { + const basePremium = direction === "buy" ? premiumBuy : premiumSell; + if (eurAmount <= smallTradeThreshold) { + return basePremium + smallTradeExtraPremium; + } + return basePremium; + }, [direction, premiumBuy, premiumSell, eurAmount, smallTradeThreshold, smallTradeExtraPremium]); + + // Calculate agreed price based on direction and total premium const agreedPrice = useMemo(() => { if (!marketPrice) return 0; if (direction === "buy") { - return marketPrice * (1 + premiumPercent / 100); + return marketPrice * (1 + totalPremiumPercent / 100); } else { - return marketPrice * (1 - premiumPercent / 100); + return marketPrice * (1 - totalPremiumPercent / 100); } - }, [marketPrice, premiumPercent, direction]); + }, [marketPrice, totalPremiumPercent, direction]); // Calculate sats amount const satsAmount = useMemo(() => { @@ -155,6 +173,15 @@ export default function ExchangePage() { } }, [isLightningDisabled, bitcoinTransferMethod]); + // Clamp amount when direction changes (min/max may differ per direction) + useEffect(() => { + if (eurAmount < eurMin) { + setEurAmount(eurMin); + } else if (eurAmount > eurMax) { + setEurAmount(eurMax); + } + }, [direction, eurMin, eurMax]); // eslint-disable-line react-hooks/exhaustive-deps + // Fetch slots when date is selected useEffect(() => { if (selectedDate && user && isAuthorized) { @@ -307,6 +334,7 @@ export default function ExchangePage() { lastUpdate={lastPriceUpdate} direction={direction} agreedPrice={agreedPrice} + premiumPercent={totalPremiumPercent} /> {/* Step Indicator */} @@ -322,8 +350,8 @@ export default function ExchangePage() { eurAmount={eurAmount} onEurAmountChange={setEurAmount} satsAmount={satsAmount} - eurMin={eurMin} - eurMax={eurMax} + eurMin={eurMin / 100} + eurMax={eurMax / 100} eurIncrement={eurIncrement} isPriceStale={isPriceStale} hasPrice={!!priceData?.price}