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:
counterweight 2025-12-26 20:24:13 +01:00
parent d317939ad0
commit 41e158376c
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
2 changed files with 210 additions and 26 deletions

View file

@ -25,13 +25,11 @@ from models import (
from repositories.availability import AvailabilityRepository from repositories.availability import AvailabilityRepository
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 AvailableSlotsResponse, BookableSlot from schemas import AvailableSlotsResponse, BookableSlot
from shared_constants import ( from shared_constants import (
EUR_TRADE_INCREMENT, EUR_TRADE_INCREMENT,
EUR_TRADE_MAX,
EUR_TRADE_MIN,
LIGHTNING_MAX_EUR, LIGHTNING_MAX_EUR,
PREMIUM_PERCENTAGE,
PRICE_STALENESS_SECONDS, PRICE_STALENESS_SECONDS,
SLOT_DURATION_MINUTES, SLOT_DURATION_MINUTES,
) )
@ -48,24 +46,53 @@ class ExchangeService:
self.price_repo = PriceRepository(db) self.price_repo = PriceRepository(db)
self.exchange_repo = ExchangeRepository(db) self.exchange_repo = ExchangeRepository(db)
self.availability_repo = AvailabilityRepository(db) self.availability_repo = AvailabilityRepository(db)
self.pricing_repo = PricingRepository(db)
def apply_premium_for_direction( async def apply_premium_for_direction(
self, self,
market_price: float, market_price: float,
premium_percentage: int, eur_amount: int,
direction: TradeDirection, 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: The premium is always favorable to the admin:
- When user BUYS BTC: user pays MORE (market * (1 + premium/100)) - When user BUYS BTC: user pays MORE (market * (1 + premium/100))
- When user SELLS BTC: user receives LESS (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: if direction == TradeDirection.BUY:
return market_price * (1 + premium_percentage / 100) agreed_price = market_price * (1 + total_premium / 100)
else: # SELL 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( def calculate_sats_amount(
self, self,
@ -147,12 +174,39 @@ class ExchangeService:
return cached_price return cached_price
async def validate_eur_amount(self, eur_amount: int) -> None: async def validate_eur_amount(
"""Validate EUR amount is within configured limits.""" self, eur_amount: int, direction: TradeDirection
if eur_amount < EUR_TRADE_MIN * 100: ) -> None:
raise BadRequestError(f"EUR amount must be at least €{EUR_TRADE_MIN}") """Validate EUR amount is within configured limits for the given direction."""
if eur_amount > EUR_TRADE_MAX * 100: # Load pricing config
raise BadRequestError(f"EUR amount must be at most €{EUR_TRADE_MAX}") 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: if eur_amount % (EUR_TRADE_INCREMENT * 100) != 0:
raise BadRequestError( raise BadRequestError(
f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}" f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}"
@ -218,8 +272,8 @@ class ExchangeService:
f"Trade ID: {existing_trade.public_id}" f"Trade ID: {existing_trade.public_id}"
) )
# Validate EUR amount # Validate EUR amount (direction-specific)
await self.validate_eur_amount(eur_amount) await self.validate_eur_amount(eur_amount, direction)
# Validate Lightning threshold # Validate Lightning threshold
await self.validate_lightning_threshold(bitcoin_transfer_method, eur_amount) await self.validate_lightning_threshold(bitcoin_transfer_method, eur_amount)
@ -233,10 +287,11 @@ class ExchangeService:
# Get and validate price # Get and validate price
cached_price = await self.validate_price_not_stale() 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 market_price = cached_price.price
agreed_price = self.apply_premium_for_direction( agreed_price, total_premium_percentage = await self.apply_premium_for_direction(
market_price, PREMIUM_PERCENTAGE, direction market_price, eur_amount, direction
) )
# Calculate sats amount based on agreed price # Calculate sats amount based on agreed price
@ -263,7 +318,7 @@ class ExchangeService:
sats_amount=sats_amount, sats_amount=sats_amount,
market_price_eur=market_price, market_price_eur=market_price,
agreed_price_eur=agreed_price, agreed_price_eur=agreed_price,
premium_percentage=PREMIUM_PERCENTAGE, premium_percentage=total_premium_percentage,
status=ExchangeStatus.BOOKED, status=ExchangeStatus.BOOKED,
) )

View file

@ -252,8 +252,10 @@ class TestCreateExchange:
assert data["eur_amount"] == 10000 assert data["eur_amount"] == 10000
assert data["status"] == "booked" assert data["status"] == "booked"
assert data["sats_amount"] > 0 assert data["sats_amount"] > 0
# For buy, agreed price is market * 1.05 # For buy with €100 (small trade): base 5% + extra 2% = 7%
assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001) # 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 @pytest.mark.asyncio
async def test_create_exchange_sell_success( async def test_create_exchange_sell_success(
@ -279,8 +281,10 @@ class TestCreateExchange:
assert data["direction"] == "sell" assert data["direction"] == "sell"
assert data["bitcoin_transfer_method"] == "lightning" assert data["bitcoin_transfer_method"] == "lightning"
assert data["eur_amount"] == 20000 assert data["eur_amount"] == 20000
# For sell, agreed price is market * 0.95 # For sell with €200 (at threshold): base 5% + extra 2% = 7%
assert data["agreed_price_eur"] == pytest.approx(19000.0, rel=0.001) # 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 @pytest.mark.asyncio
async def test_cannot_book_same_slot_twice( async def test_cannot_book_same_slot_twice(
@ -536,6 +540,118 @@ class TestCreateExchange:
assert response.status_code == 400 assert response.status_code == 400
assert "at most" in response.json()["detail"] 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 @pytest.mark.asyncio
async def test_amount_not_multiple_of_increment_rejected( async def test_amount_not_multiple_of_increment_rejected(
self, client_factory, regular_user, admin_user 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: async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=10) 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: async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post( response = await client.post(