Step 6: Update exchange creation logic to use new pricing config
- Update ExchangeService to load pricing config from database - Update validate_eur_amount to use direction-specific limits - Update apply_premium_for_direction to calculate base + extra premium - Update create_exchange to use new premium calculation - Add tests for premium calculation (small trade extra, large trade base only, direction-specific) - Update existing tests to account for new premium calculation
This commit is contained in:
parent
d317939ad0
commit
41e158376c
2 changed files with 210 additions and 26 deletions
|
|
@ -25,13 +25,11 @@ from models import (
|
|||
from repositories.availability import AvailabilityRepository
|
||||
from repositories.exchange import ExchangeRepository
|
||||
from repositories.price import PriceRepository
|
||||
from repositories.pricing import PricingRepository
|
||||
from schemas import AvailableSlotsResponse, BookableSlot
|
||||
from shared_constants import (
|
||||
EUR_TRADE_INCREMENT,
|
||||
EUR_TRADE_MAX,
|
||||
EUR_TRADE_MIN,
|
||||
LIGHTNING_MAX_EUR,
|
||||
PREMIUM_PERCENTAGE,
|
||||
PRICE_STALENESS_SECONDS,
|
||||
SLOT_DURATION_MINUTES,
|
||||
)
|
||||
|
|
@ -48,24 +46,53 @@ class ExchangeService:
|
|||
self.price_repo = PriceRepository(db)
|
||||
self.exchange_repo = ExchangeRepository(db)
|
||||
self.availability_repo = AvailabilityRepository(db)
|
||||
self.pricing_repo = PricingRepository(db)
|
||||
|
||||
def apply_premium_for_direction(
|
||||
async def apply_premium_for_direction(
|
||||
self,
|
||||
market_price: float,
|
||||
premium_percentage: int,
|
||||
eur_amount: int,
|
||||
direction: TradeDirection,
|
||||
) -> float:
|
||||
) -> tuple[float, int]:
|
||||
"""
|
||||
Apply premium to market price based on trade direction.
|
||||
Apply premium to market price based on trade direction and amount.
|
||||
|
||||
The premium is always favorable to the admin:
|
||||
- When user BUYS BTC: user pays MORE (market * (1 + premium/100))
|
||||
- When user SELLS BTC: user receives LESS (market * (1 - premium/100))
|
||||
|
||||
Premium calculation:
|
||||
- Base premium for direction (from pricing config)
|
||||
- Add extra premium if trade amount <= small trade threshold
|
||||
|
||||
Returns:
|
||||
Tuple of (agreed_price, total_premium_percentage)
|
||||
"""
|
||||
# Load pricing config
|
||||
pricing_config = await self.pricing_repo.get_current()
|
||||
if pricing_config is None:
|
||||
raise ServiceUnavailableError("Pricing configuration not available")
|
||||
|
||||
# Get base premium for direction
|
||||
base_premium = (
|
||||
pricing_config.premium_buy
|
||||
if direction == TradeDirection.BUY
|
||||
else pricing_config.premium_sell
|
||||
)
|
||||
|
||||
# Calculate total premium (base + extra if small trade)
|
||||
if eur_amount <= pricing_config.small_trade_threshold_eur:
|
||||
total_premium = base_premium + pricing_config.small_trade_extra_premium
|
||||
else:
|
||||
total_premium = base_premium
|
||||
|
||||
# Apply premium to market price
|
||||
if direction == TradeDirection.BUY:
|
||||
return market_price * (1 + premium_percentage / 100)
|
||||
agreed_price = market_price * (1 + total_premium / 100)
|
||||
else: # SELL
|
||||
return market_price * (1 - premium_percentage / 100)
|
||||
agreed_price = market_price * (1 - total_premium / 100)
|
||||
|
||||
return agreed_price, total_premium
|
||||
|
||||
def calculate_sats_amount(
|
||||
self,
|
||||
|
|
@ -147,12 +174,39 @@ class ExchangeService:
|
|||
|
||||
return cached_price
|
||||
|
||||
async def validate_eur_amount(self, eur_amount: int) -> None:
|
||||
"""Validate EUR amount is within configured limits."""
|
||||
if eur_amount < EUR_TRADE_MIN * 100:
|
||||
raise BadRequestError(f"EUR amount must be at least €{EUR_TRADE_MIN}")
|
||||
if eur_amount > EUR_TRADE_MAX * 100:
|
||||
raise BadRequestError(f"EUR amount must be at most €{EUR_TRADE_MAX}")
|
||||
async def validate_eur_amount(
|
||||
self, eur_amount: int, direction: TradeDirection
|
||||
) -> None:
|
||||
"""Validate EUR amount is within configured limits for the given direction."""
|
||||
# Load pricing config
|
||||
pricing_config = await self.pricing_repo.get_current()
|
||||
if pricing_config is None:
|
||||
raise ServiceUnavailableError("Pricing configuration not available")
|
||||
|
||||
# Get direction-specific limits
|
||||
eur_min = (
|
||||
pricing_config.eur_min_buy
|
||||
if direction == TradeDirection.BUY
|
||||
else pricing_config.eur_min_sell
|
||||
)
|
||||
eur_max = (
|
||||
pricing_config.eur_max_buy
|
||||
if direction == TradeDirection.BUY
|
||||
else pricing_config.eur_max_sell
|
||||
)
|
||||
|
||||
if eur_amount < eur_min:
|
||||
direction_str = direction.value.upper()
|
||||
raise BadRequestError(
|
||||
f"EUR amount must be at least €{eur_min / 100:.0f} "
|
||||
f"for {direction_str} trades"
|
||||
)
|
||||
if eur_amount > eur_max:
|
||||
direction_str = direction.value.upper()
|
||||
raise BadRequestError(
|
||||
f"EUR amount must be at most €{eur_max / 100:.0f} "
|
||||
f"for {direction_str} trades"
|
||||
)
|
||||
if eur_amount % (EUR_TRADE_INCREMENT * 100) != 0:
|
||||
raise BadRequestError(
|
||||
f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}"
|
||||
|
|
@ -218,8 +272,8 @@ class ExchangeService:
|
|||
f"Trade ID: {existing_trade.public_id}"
|
||||
)
|
||||
|
||||
# Validate EUR amount
|
||||
await self.validate_eur_amount(eur_amount)
|
||||
# Validate EUR amount (direction-specific)
|
||||
await self.validate_eur_amount(eur_amount, direction)
|
||||
|
||||
# Validate Lightning threshold
|
||||
await self.validate_lightning_threshold(bitcoin_transfer_method, eur_amount)
|
||||
|
|
@ -233,10 +287,11 @@ class ExchangeService:
|
|||
# Get and validate price
|
||||
cached_price = await self.validate_price_not_stale()
|
||||
|
||||
# Calculate agreed price based on direction
|
||||
# Calculate agreed price based on direction and amount
|
||||
# (includes premium calculation)
|
||||
market_price = cached_price.price
|
||||
agreed_price = self.apply_premium_for_direction(
|
||||
market_price, PREMIUM_PERCENTAGE, direction
|
||||
agreed_price, total_premium_percentage = await self.apply_premium_for_direction(
|
||||
market_price, eur_amount, direction
|
||||
)
|
||||
|
||||
# Calculate sats amount based on agreed price
|
||||
|
|
@ -263,7 +318,7 @@ class ExchangeService:
|
|||
sats_amount=sats_amount,
|
||||
market_price_eur=market_price,
|
||||
agreed_price_eur=agreed_price,
|
||||
premium_percentage=PREMIUM_PERCENTAGE,
|
||||
premium_percentage=total_premium_percentage,
|
||||
status=ExchangeStatus.BOOKED,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -252,8 +252,10 @@ class TestCreateExchange:
|
|||
assert data["eur_amount"] == 10000
|
||||
assert data["status"] == "booked"
|
||||
assert data["sats_amount"] > 0
|
||||
# For buy, agreed price is market * 1.05
|
||||
assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001)
|
||||
# For buy with €100 (small trade): base 5% + extra 2% = 7%
|
||||
# Agreed price is market * 1.07
|
||||
assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001)
|
||||
assert data["premium_percentage"] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_exchange_sell_success(
|
||||
|
|
@ -279,8 +281,10 @@ class TestCreateExchange:
|
|||
assert data["direction"] == "sell"
|
||||
assert data["bitcoin_transfer_method"] == "lightning"
|
||||
assert data["eur_amount"] == 20000
|
||||
# For sell, agreed price is market * 0.95
|
||||
assert data["agreed_price_eur"] == pytest.approx(19000.0, rel=0.001)
|
||||
# For sell with €200 (at threshold): base 5% + extra 2% = 7%
|
||||
# Agreed price is market * 0.93
|
||||
assert data["agreed_price_eur"] == pytest.approx(18600.0, rel=0.001)
|
||||
assert data["premium_percentage"] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_book_same_slot_twice(
|
||||
|
|
@ -536,6 +540,118 @@ class TestCreateExchange:
|
|||
assert response.status_code == 400
|
||||
assert "at most" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_premium_calculation_small_trade_extra(
|
||||
self, client_factory, regular_user, admin_user
|
||||
):
|
||||
"""Premium includes extra for small trades (amount <= threshold)."""
|
||||
target_date = await setup_availability_and_price(client_factory, admin_user)
|
||||
|
||||
with mock_price_fetcher(20000.0):
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
# Trade below threshold: should get base + extra premium
|
||||
response = await client.post(
|
||||
"/api/exchange",
|
||||
json={
|
||||
"slot_start": f"{target_date}T09:00:00Z",
|
||||
"direction": "buy",
|
||||
"bitcoin_transfer_method": "onchain",
|
||||
"eur_amount": 14000, # €140, below €200 threshold, multiple of €20
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Base 5% + extra 2% = 7% total
|
||||
assert data["premium_percentage"] == 7
|
||||
# Market 20000 * 1.07 = 21400
|
||||
assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_premium_calculation_large_trade_base_only(
|
||||
self, client_factory, regular_user, admin_user
|
||||
):
|
||||
"""Premium is base only for large trades (amount > threshold)."""
|
||||
target_date = await setup_availability_and_price(client_factory, admin_user)
|
||||
|
||||
with mock_price_fetcher(20000.0):
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
# Trade above threshold: should get base premium only
|
||||
response = await client.post(
|
||||
"/api/exchange",
|
||||
json={
|
||||
"slot_start": f"{target_date}T10:00:00Z",
|
||||
"direction": "buy",
|
||||
"bitcoin_transfer_method": "onchain",
|
||||
"eur_amount": 24000, # €240, above €200 threshold, multiple of €20
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Base 5% only (no extra)
|
||||
assert data["premium_percentage"] == 5
|
||||
# Market 20000 * 1.05 = 21000
|
||||
assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_premium_calculation_direction_specific(
|
||||
self, client_factory, regular_user, admin_user
|
||||
):
|
||||
"""Premium uses direction-specific base values."""
|
||||
target_date = await setup_availability_and_price(client_factory, admin_user)
|
||||
target_date_2 = await setup_availability_and_price(
|
||||
client_factory, admin_user, target_date=in_days(2)
|
||||
)
|
||||
|
||||
# Update pricing config with different premiums for buy/sell
|
||||
async with client_factory.get_db_session() as db:
|
||||
from repositories.pricing import PricingRepository
|
||||
|
||||
repo = PricingRepository(db)
|
||||
await repo.create_or_update(
|
||||
premium_buy=10,
|
||||
premium_sell=8,
|
||||
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,
|
||||
)
|
||||
|
||||
with mock_price_fetcher(20000.0):
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
# Buy trade
|
||||
buy_response = await client.post(
|
||||
"/api/exchange",
|
||||
json={
|
||||
"slot_start": f"{target_date}T09:00:00Z",
|
||||
"direction": "buy",
|
||||
"bitcoin_transfer_method": "onchain",
|
||||
"eur_amount": 14000, # €140, multiple of €20
|
||||
},
|
||||
)
|
||||
assert buy_response.status_code == 200
|
||||
buy_data = buy_response.json()
|
||||
# Buy: base 10% + extra 2% = 12%
|
||||
assert buy_data["premium_percentage"] == 12
|
||||
|
||||
# Sell trade (different day to avoid one-trade-per-day limit)
|
||||
sell_response = await client.post(
|
||||
"/api/exchange",
|
||||
json={
|
||||
"slot_start": f"{target_date_2}T10:00:00Z",
|
||||
"direction": "sell",
|
||||
"bitcoin_transfer_method": "onchain",
|
||||
"eur_amount": 14000, # €140, multiple of €20
|
||||
},
|
||||
)
|
||||
assert sell_response.status_code == 200
|
||||
sell_data = sell_response.json()
|
||||
# Sell: base 8% + extra 2% = 10%
|
||||
assert sell_data["premium_percentage"] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amount_not_multiple_of_increment_rejected(
|
||||
self, client_factory, regular_user, admin_user
|
||||
|
|
@ -595,9 +711,22 @@ class TestCreateExchange:
|
|||
},
|
||||
)
|
||||
|
||||
# Create stale price (create_exchange doesn't fetch, just reads from DB)
|
||||
# Create stale price and pricing config
|
||||
async with client_factory.get_db_session() as db:
|
||||
await create_price_in_db(db, price=20000.0, minutes_ago=10)
|
||||
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,
|
||||
)
|
||||
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue