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:
parent
d838d1be96
commit
d317939ad0
5 changed files with 145 additions and 44 deletions
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue