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 price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||||
from repositories.exchange import ExchangeRepository
|
from repositories.exchange import ExchangeRepository
|
||||||
from repositories.price import PriceRepository
|
from repositories.price import PriceRepository
|
||||||
|
from repositories.pricing import PricingRepository
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminExchangeResponse,
|
AdminExchangeResponse,
|
||||||
AvailableSlotsResponse,
|
AvailableSlotsResponse,
|
||||||
|
|
@ -31,12 +32,7 @@ from schemas import (
|
||||||
UserSearchResult,
|
UserSearchResult,
|
||||||
)
|
)
|
||||||
from services.exchange import ExchangeService
|
from services.exchange import ExchangeService
|
||||||
from shared_constants import (
|
from shared_constants import EUR_TRADE_INCREMENT
|
||||||
EUR_TRADE_INCREMENT,
|
|
||||||
EUR_TRADE_MAX,
|
|
||||||
EUR_TRADE_MIN,
|
|
||||||
PREMIUM_PERCENTAGE,
|
|
||||||
)
|
|
||||||
from utils.enum_validation import validate_enum
|
from utils.enum_validation import validate_enum
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
||||||
|
|
@ -65,20 +61,33 @@ async def get_exchange_price(
|
||||||
|
|
||||||
The response includes:
|
The response includes:
|
||||||
- market_price: The raw price from the exchange
|
- 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
|
- 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)
|
price_repo = PriceRepository(db)
|
||||||
|
pricing_repo = PricingRepository(db)
|
||||||
service = ExchangeService(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
|
# Try to get the latest cached price
|
||||||
cached_price = await price_repo.get_latest()
|
cached_price = await price_repo.get_latest()
|
||||||
|
|
||||||
|
|
@ -101,7 +110,6 @@ async def get_exchange_price(
|
||||||
return ExchangePriceResponse(
|
return ExchangePriceResponse(
|
||||||
price=PriceResponse(
|
price=PriceResponse(
|
||||||
market_price=price_value,
|
market_price=price_value,
|
||||||
premium_percentage=PREMIUM_PERCENTAGE,
|
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
is_stale=False,
|
is_stale=False,
|
||||||
),
|
),
|
||||||
|
|
@ -113,7 +121,6 @@ async def get_exchange_price(
|
||||||
return ExchangePriceResponse(
|
return ExchangePriceResponse(
|
||||||
price=PriceResponse(
|
price=PriceResponse(
|
||||||
market_price=cached_price.price,
|
market_price=cached_price.price,
|
||||||
premium_percentage=PREMIUM_PERCENTAGE,
|
|
||||||
timestamp=cached_price.timestamp,
|
timestamp=cached_price.timestamp,
|
||||||
is_stale=True,
|
is_stale=True,
|
||||||
),
|
),
|
||||||
|
|
@ -131,7 +138,6 @@ async def get_exchange_price(
|
||||||
return ExchangePriceResponse(
|
return ExchangePriceResponse(
|
||||||
price=PriceResponse(
|
price=PriceResponse(
|
||||||
market_price=cached_price.price,
|
market_price=cached_price.price,
|
||||||
premium_percentage=PREMIUM_PERCENTAGE,
|
|
||||||
timestamp=cached_price.timestamp,
|
timestamp=cached_price.timestamp,
|
||||||
is_stale=service.is_price_stale(cached_price.timestamp),
|
is_stale=service.is_price_stale(cached_price.timestamp),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,27 @@ class PriceHistoryResponse(BaseModel):
|
||||||
class ExchangeConfigResponse(BaseModel):
|
class ExchangeConfigResponse(BaseModel):
|
||||||
"""Exchange configuration for the frontend."""
|
"""Exchange configuration for the frontend."""
|
||||||
|
|
||||||
eur_min: int
|
eur_min_buy: int
|
||||||
eur_max: int
|
eur_max_buy: int
|
||||||
|
eur_min_sell: int
|
||||||
|
eur_max_sell: int
|
||||||
eur_increment: 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):
|
class PriceResponse(BaseModel):
|
||||||
"""Current BTC/EUR price for trading.
|
"""Current BTC/EUR price for trading.
|
||||||
|
|
||||||
Note: The actual agreed price depends on trade direction (buy/sell)
|
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
|
market_price: float # Raw price from exchange
|
||||||
premium_percentage: int
|
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
is_stale: bool
|
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):
|
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:
|
if target_date is None:
|
||||||
target_date = tomorrow()
|
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:
|
async with client_factory.get_db_session() as db:
|
||||||
await create_price_in_db(db, price=20000.0, minutes_ago=0)
|
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
|
return target_date
|
||||||
|
|
||||||
|
|
@ -89,9 +102,22 @@ class TestExchangePriceEndpoint:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_regular_user_can_get_price(self, client_factory, regular_user):
|
async def test_regular_user_can_get_price(self, client_factory, regular_user):
|
||||||
"""Regular user can access price endpoint."""
|
"""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:
|
async with client_factory.get_db_session() as db:
|
||||||
await create_price_in_db(db, price=20000.0, minutes_ago=0)
|
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
|
# Mock the price fetcher to prevent real API calls
|
||||||
with mock_price_fetcher(20000.0):
|
with mock_price_fetcher(20000.0):
|
||||||
|
|
@ -103,8 +129,7 @@ class TestExchangePriceEndpoint:
|
||||||
assert "price" in data
|
assert "price" in data
|
||||||
assert "config" in data
|
assert "config" in data
|
||||||
assert data["price"]["market_price"] == 20000.0
|
assert data["price"]["market_price"] == 20000.0
|
||||||
assert data["price"]["premium_percentage"] == 5
|
# Note: premium is now in config, agreed_price is calculated on frontend
|
||||||
# Note: agreed_price is calculated on frontend based on direction (buy/sell)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_admin_cannot_get_price(self, client_factory, admin_user):
|
async def test_admin_cannot_get_price(self, client_factory, admin_user):
|
||||||
|
|
@ -123,9 +148,22 @@ class TestExchangePriceEndpoint:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stale_price_triggers_fetch(self, client_factory, regular_user):
|
async def test_stale_price_triggers_fetch(self, client_factory, regular_user):
|
||||||
"""When cached price is stale, a fresh price is fetched."""
|
"""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:
|
async with client_factory.get_db_session() as db:
|
||||||
await create_price_in_db(db, price=19000.0, minutes_ago=6)
|
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
|
# Mock fetcher returns new price
|
||||||
with mock_price_fetcher(21000.0):
|
with mock_price_fetcher(21000.0):
|
||||||
|
|
@ -142,6 +180,19 @@ class TestExchangePriceEndpoint:
|
||||||
"""Config section contains all required fields."""
|
"""Config section contains all required fields."""
|
||||||
async with client_factory.get_db_session() as db:
|
async with client_factory.get_db_session() as db:
|
||||||
await create_price_in_db(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():
|
with mock_price_fetcher():
|
||||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||||
|
|
@ -149,13 +200,22 @@ class TestExchangePriceEndpoint:
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
config = response.json()["config"]
|
config = response.json()["config"]
|
||||||
assert "eur_min" in config
|
assert "eur_min_buy" in config
|
||||||
assert "eur_max" 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 "eur_increment" in config
|
||||||
assert "premium_percentage" in config
|
assert "premium_buy" in config
|
||||||
assert config["eur_min"] == 100
|
assert "premium_sell" in config
|
||||||
assert config["eur_max"] == 3000
|
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["eur_increment"] == 20
|
||||||
|
assert config["premium_buy"] == 5
|
||||||
|
assert config["premium_sell"] == 6
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface PriceDisplayProps {
|
||||||
lastUpdate: Date | null;
|
lastUpdate: Date | null;
|
||||||
direction: "buy" | "sell";
|
direction: "buy" | "sell";
|
||||||
agreedPrice: number;
|
agreedPrice: number;
|
||||||
|
premiumPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,10 +95,10 @@ export function PriceDisplay({
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
direction,
|
direction,
|
||||||
agreedPrice,
|
agreedPrice,
|
||||||
|
premiumPercent,
|
||||||
}: PriceDisplayProps) {
|
}: PriceDisplayProps) {
|
||||||
const t = useTranslation("exchange");
|
const t = useTranslation("exchange");
|
||||||
const marketPrice = priceData?.price?.market_price ?? 0;
|
const marketPrice = priceData?.price?.market_price ?? 0;
|
||||||
const premiumPercent = priceData?.price?.premium_percentage ?? 5;
|
|
||||||
const isPriceStale = priceData?.price?.is_stale ?? false;
|
const isPriceStale = priceData?.price?.is_stale ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -116,24 +116,42 @@ export default function ExchangePage() {
|
||||||
|
|
||||||
// Config from API
|
// Config from API
|
||||||
const config = priceData?.config;
|
const config = priceData?.config;
|
||||||
const eurMin = config?.eur_min ?? 100;
|
const eurMinBuy = config?.eur_min_buy ?? 10000;
|
||||||
const eurMax = config?.eur_max ?? 3000;
|
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;
|
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
|
// Compute trade details
|
||||||
const price = priceData?.price;
|
const price = priceData?.price;
|
||||||
const marketPrice = price?.market_price ?? 0;
|
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(() => {
|
const agreedPrice = useMemo(() => {
|
||||||
if (!marketPrice) return 0;
|
if (!marketPrice) return 0;
|
||||||
if (direction === "buy") {
|
if (direction === "buy") {
|
||||||
return marketPrice * (1 + premiumPercent / 100);
|
return marketPrice * (1 + totalPremiumPercent / 100);
|
||||||
} else {
|
} else {
|
||||||
return marketPrice * (1 - premiumPercent / 100);
|
return marketPrice * (1 - totalPremiumPercent / 100);
|
||||||
}
|
}
|
||||||
}, [marketPrice, premiumPercent, direction]);
|
}, [marketPrice, totalPremiumPercent, direction]);
|
||||||
|
|
||||||
// Calculate sats amount
|
// Calculate sats amount
|
||||||
const satsAmount = useMemo(() => {
|
const satsAmount = useMemo(() => {
|
||||||
|
|
@ -155,6 +173,15 @@ export default function ExchangePage() {
|
||||||
}
|
}
|
||||||
}, [isLightningDisabled, bitcoinTransferMethod]);
|
}, [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
|
// Fetch slots when date is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate && user && isAuthorized) {
|
if (selectedDate && user && isAuthorized) {
|
||||||
|
|
@ -307,6 +334,7 @@ export default function ExchangePage() {
|
||||||
lastUpdate={lastPriceUpdate}
|
lastUpdate={lastPriceUpdate}
|
||||||
direction={direction}
|
direction={direction}
|
||||||
agreedPrice={agreedPrice}
|
agreedPrice={agreedPrice}
|
||||||
|
premiumPercent={totalPremiumPercent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Step Indicator */}
|
{/* Step Indicator */}
|
||||||
|
|
@ -322,8 +350,8 @@ export default function ExchangePage() {
|
||||||
eurAmount={eurAmount}
|
eurAmount={eurAmount}
|
||||||
onEurAmountChange={setEurAmount}
|
onEurAmountChange={setEurAmount}
|
||||||
satsAmount={satsAmount}
|
satsAmount={satsAmount}
|
||||||
eurMin={eurMin}
|
eurMin={eurMin / 100}
|
||||||
eurMax={eurMax}
|
eurMax={eurMax / 100}
|
||||||
eurIncrement={eurIncrement}
|
eurIncrement={eurIncrement}
|
||||||
isPriceStale={isPriceStale}
|
isPriceStale={isPriceStale}
|
||||||
hasPrice={!!priceData?.price}
|
hasPrice={!!priceData?.price}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue