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
This commit is contained in:
counterweight 2025-12-26 20:20:23 +01:00
parent d838d1be96
commit d317939ad0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
5 changed files with 145 additions and 44 deletions

View file

@ -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,19 +61,32 @@ 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",
)
price_repo = PriceRepository(db)
service = ExchangeService(db)
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),
),

View file

@ -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

View file

@ -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
# =============================================================================

View file

@ -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 (

View file

@ -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}